Official

claude

Claude Code agent provider: runs the Claude Code CLI headless for each account.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/claude@0.2.0

Permissions in 0.2.0

  • Provide agents agents.provideMediumAdds agents to the app.Provide the Claude Code agent and pass its tool calls to plugin tools
  • Run named programs processMediumStarts the listed programs.Run the Claude Code CLI (sessions, sign-in, `claude update`), and ask the npm installation that owns it about a newer versionPrograms: claudenpm
  • Read files fs.readMediumReads files in the listed places.Read Claude Code's sessions, settings and skills (in ~/.claude, other accounts' ~/.claude-* folders, the shared skills and the folder you choose in Settings), the project's .claude folder, the administrator's skill policy, and once, what the previous provider keptPlaces: ~/.claude/**~/.claude*/**~/.agents/skills/**the open workspaceits own data folder/Library/Application Support/ClaudeCode/managed-settings.json/etc/claude-code/managed-settings.json${settings.configDir}
  • Environment variables envMediumReads the listed environment variables.Find Claude Code's configuration directory, and the launch settings you set in CLAUDE_* variables (extra arguments, MCP servers, setting sources, appended system prompt, extra folders)Variables: HOMECLAUDE_CONFIG_DIRCLAUDE_EXTRA_ARGSCLAUDE_MCP_CONFIGCLAUDE_STRICT_MCP_CONFIGCLAUDE_SETTING_SOURCESCLAUDE_APPEND_SYSTEM_PROMPTCLAUDE_ADD_DIRS

Files

skills.ts6 KB
// Skill discovery (the Rust `skills.rs`).
//
// The CLI publishes skills as slash commands, which says nothing about
// where a skill came from or whether the model may start it on its own.
// So the plugin reads the same two folders the CLI reads, in the same
// precedence order, and applies the same `skillOverrides` setting.
//
// The broker checks a read against the path it resolves to, so a skill
// folder that is a link out of the granted folders (the `skills`
// installer links `~/.claude/skills/<name>` into `~/.agents/skills`)
// cannot be read. Such a skill is still offered when the CLI lists it as
// a command, with the command's description.
import * as wire from "./wire.ts";
import * as Effect from "effect/Effect";
import type { Files } from "./files.ts";
import type { Skill } from "./types.ts";
import { join, readJson } from "./files.ts";

/// Where a skill lives, in precedence order: a name found in an earlier
/// root wins.
const ROOTS: [Skill["source"], string][] = [
  ["user", "skills"],
  ["project", ".claude/skills"],
];

/// The administrator's policy file, which wins over everything the user
/// wrote.
export const MANAGED_SETTINGS = [
  "/Library/Application Support/ClaudeCode/managed-settings.json",
  "/etc/claude-code/managed-settings.json",
];

function parseOverride(value: unknown) {
  if (value === "off") return "off";
  if (value === "user-invocable-only") return "user-invocable-only";
  if (value === "on" || value === "name-only") return "on";
  return null;
}

/// The settings files the CLI merges, lowest precedence first. The
/// repository root above a workspace is outside every grant and is left
/// out.
function settingsFiles(workspace: string, configDir: string | null) {
  const out = [];
  if (configDir) out.push(join(configDir, "settings.json"));
  out.push(
    join(workspace, ".claude/settings.json"),
    join(workspace, ".claude/settings.local.json"),
    ...MANAGED_SETTINGS,
  );
  return out;
}

/// The merged `skillOverrides` map. One unknown value drops every
/// override in that file, as the CLI does: a half-read policy file would
/// enable a skill the administrator meant to switch off.
export const overrides = Effect.fn("Claude.overrides")(function* (
  files: Files,
  workspace: string,
  configDir: string | null,
) {
  const merged = new Map<string, "off" | "on" | "user-invocable-only">();
  for (const file of settingsFiles(workspace, configDir)) {
    const settings = yield* readJson(files, file);
    const entries = wire.objectOf(settings).skillOverrides;
    if (!entries || typeof entries !== "object" || Array.isArray(entries)) continue;
    const parsed = Object.entries(entries).map(
      ([name, value]) => [name, typeof value === "string" ? parseOverride(value) : null] as const,
    );
    if (parsed.some(([, value]) => value === null)) {
      console.debug(`claude: ignoring skillOverrides with an unknown value in ${file}`);
      continue;
    }
    for (const [name, value] of parsed) if (value !== null) merged.set(name, value);
  }
  return merged;
});

/// Reads the `---` block at the top of a `SKILL.md`. Only flat `key:
/// value` pairs are read: the three keys that matter are scalars.
export function frontmatter(body: string) {
  const fields = { description: "", disableModelInvocation: false, userInvocable: true };
  if (!body.startsWith("---")) return fields;
  let rest = body.slice(3);
  if (rest.startsWith("\r\n")) rest = rest.slice(2);
  else if (rest.startsWith("\n")) rest = rest.slice(1);
  else return fields;
  for (const raw of rest.split("\n")) {
    const line = raw.replace(/\s+$/, "");
    if (line === "---") break;
    const colon = line.indexOf(":");
    if (colon < 0) continue;
    const key = line.slice(0, colon).trim();
    const value = line
      .slice(colon + 1)
      .trim()
      .replace(/^["']+|["']+$/g, "");
    if (key === "description") fields.description = value;
    else if (key === "disable-model-invocation")
      fields.disableModelInvocation = ["true", "yes", "on", "y", "1"].includes(value.toLowerCase());
    else if (key === "user-invocable")
      fields.userInvocable = !["false", "no", "off", "n", "0"].includes(value.toLowerCase());
  }
  return fields;
}

/// Every skill the CLI would offer in `workspace`, the user's copy winning
/// a name clash with the project's. `commands` are the CLI's slash
/// commands, the fallback for a skill whose file cannot be read.
export const listSkills = Effect.fn("Claude.listSkills")(function* (
  files: Files,
  workspace: string,
  configDir: string | null,
  commands: wire.Command[] = [],
) {
  const policy = yield* overrides(files, workspace, configDir);
  const found = new Map<string, Skill>();
  for (const [source, relative] of ROOTS) {
    if (source === "user" && !configDir) continue;
    const root = source === "user" ? join(configDir, relative) : join(workspace, relative);
    const entries = yield* files.list(root);
    if (!entries) continue;
    const names = entries
      .map((entry) => entry.name.trim())
      .filter((name) => name)
      .sort();
    for (const name of names) {
      if (found.has(name)) continue;
      const over = policy.get(name) ?? null;
      if (over === "off") continue;
      const body = yield* files.text(join(root, name, "SKILL.md"));
      if (body === null) {
        const command = commands.find((command) => command?.name === name);
        if (command) {
          found.set(name, {
            name,
            description: command.description ?? "",
            source,
            manualOnly: over === "user-invocable-only",
          });
        }
        continue;
      }
      const fields = frontmatter(body);
      // A skill the user cannot start has no place in a picker the user
      // drives; the model still reaches it through its own tool.
      if (!fields.userInvocable) continue;
      found.set(name, {
        name,
        description: fields.description,
        source,
        manualOnly: fields.disableModelInvocation || over === "user-invocable-only",
      });
    }
  }
  return [...found.keys()].sort().map((name) => found.get(name)!); // Names are the map's own keys.
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3128.7 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.