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

account.ts7.9 KB
// What the CLI says about the signed in account and about its own
// installation (the Rust `account.rs`).
import * as Effect from "effect/Effect";
import { Process } from "convergence/effect";
import { ProviderError } from "./errors.ts";
import type { AgentInfo } from "./types.ts";
import { objectOf } from "./wire.ts";
import { runProcess } from "../sdk/effect.ts";
import { latestNpmEffect, isNewer } from "../sdk/maintenance.ts";
import { CONFIG_DIR } from "./config.ts";

type Owner = { manager: "npm"; prefix: string } | { manager: "native" | "" };

/// The npm package that ships the CLI.
export const PACKAGE = "@anthropic-ai/claude-code";
/// How long `claude auth status` may take. It reads local credentials, so
/// a slow answer means something is wrong rather than busy.
const PROBE_TIMEOUT = 20_000;
/// How long an upgrade may take before it is reported as stuck.
const UPDATE_TIMEOUT = 300_000;

/// The answer of `claude auth status --json`, or `null` for output that is
/// not one.
export function parseAccount(output: string) {
  let status;
  try {
    const value: unknown = JSON.parse(output);
    if (!value || typeof value !== "object") return null;
    status = objectOf(value);
  } catch {
    return null;
  }
  const text = (key: string) => (typeof status[key] === "string" && status[key] ? status[key] : null);
  return {
    loggedIn: status.loggedIn === true,
    email: text("email"),
    plan: text("subscriptionType"),
    apiProvider: text("apiProvider"),
    authMethod: text("authMethod"),
    configDirectory: text("configDirectory"),
    orgId: text("orgId"),
    orgName: text("orgName"),
  };
}

/// Native initialize describes the credentials the session actually uses;
/// auth status adds the organization id and effective configuration directory.
/// Neither an email cached under API-key auth nor a directory alone proves a
/// subscription identity. Unreported or disagreeing evidence stays unknown.
export function recoveryIdentity(raw: unknown, account: ReturnType<typeof parseAccount>, configDir: string | null) {
  const native = objectOf(raw);
  if (!account?.loggedIn || account.authMethod !== "claude.ai" || account.apiProvider !== "firstParty") return null;
  if (!account.email || !account.orgId || !account.orgName || !account.configDirectory || !configDir) return null;
  if (native.apiProvider !== "firstParty" || native.email !== account.email || native.organization !== account.orgName)
    return null;
  if (native.apiKeySource || (native.tokenSource && native.tokenSource !== "claude.ai")) return null;
  if (account.configDirectory.replace(/\/+$/, "") !== configDir.replace(/\/+$/, "")) return null;
  return JSON.stringify(["claude/oauth", account.configDirectory.replace(/\/+$/, ""), account.email, account.orgId]);
}

/// A one line description of who is signed in, for `AgentInfo`.
export function accountSummary(account: NonNullable<ReturnType<typeof parseAccount>>) {
  const plan = account.plan ? account.plan.charAt(0).toUpperCase() + account.plan.slice(1) : null;
  if (account.email && plan) return `Signed in as ${account.email} on the ${plan} plan.`;
  if (account.email) return `Signed in as ${account.email}.`;
  if (plan) return `Signed in on the ${plan} plan.`;
  return account.apiProvider ? `Signed in through ${account.apiProvider}.` : null;
}

/// Asks the CLI who is signed in, with the account's own environment and
/// the session's working directory when reconciling runtime credentials.
export const probeAccount = Effect.fn("Claude.probeAccount")(function* (env: Record<string, string>, cwd?: string) {
  const result = yield* runProcess("claude", ["auth", "status", "--json"], { env, cwd, timeout: PROBE_TIMEOUT });
  if (result.timedOut) return yield* new ProviderError({ message: "claude auth status did not answer in time" });
  const account = parseAccount(result.stdout);
  if (!account)
    return yield* new ProviderError({ message: `claude auth status returned no account: ${result.stderr.trim()}` });
  return account;
});

/// A readable failure from a CLI command: its stderr, or its stdout when
/// the command wrote its complaint there instead.
export function messageOf(result: Effect.Success<ReturnType<typeof runProcess>>) {
  if (result.stderr.trim()) return result.stderr.trim();
  if (result.stdout.trim()) return result.stdout.trim();
  return `claude exited with ${result.code ?? result.signal}`;
}

/// The message shown when nobody is signed in. It names the configuration
/// directory, because the login has to be made against the same one.
export function signedOutMessage(chosenDir: string | null) {
  return chosenDir
    ? `Run \`claude auth login\` with ${CONFIG_DIR}=${chosenDir} to sign in.`
    : "Run `claude auth login` to sign in.";
}

/// Which installer owns the binary at `realPath` (links followed):
/// `{ manager: "native" }`, `{ manager: "npm", prefix }` or `{ manager: ""
/// }`. Anything unproven stays `""`, so Convergence never runs a package
/// manager against an install it cannot show it created.
export function managerOf(realPath: unknown): Owner {
  if (!realPath) return { manager: "" };
  const original = String(realPath).replace(/\\/g, "/");
  const path = original.toLowerCase();
  if (
    path.endsWith("/.local/bin/claude") ||
    path.endsWith("/.local/bin/claude.exe") ||
    path.includes("/.local/share/claude/")
  ) {
    return { manager: "native" };
  }
  const segment = `/lib/node_modules/${PACKAGE}/`;
  const at = path.lastIndexOf(segment);
  if (at >= 0) {
    const prefix = original.slice(0, at);
    // A `node_modules` above the package means a project checkout, not a
    // global install, and npm would refuse to upgrade it in place.
    if (!prefix.toLowerCase().includes("/node_modules/")) return { manager: "npm", prefix: prefix || "/" };
  }
  return { manager: "" };
}

/// The installed version: `claude --version`, trimmed.
export const which = Effect.fn("Claude.which")(function* () {
  return yield* (yield* Process)
    .which("claude")
    .pipe(
      Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview"], () =>
        Effect.succeed({ path: null, realPath: null }),
      ),
    );
});
export const installedVersion = Effect.fn("Claude.installedVersion")(
  function* () {
    return (yield* runProcess("claude", ["--version"], { timeout: PROBE_TIMEOUT })).stdout.trim() || null;
  },
  Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview", "TransportFailed"], () =>
    Effect.succeed(null),
  ),
);
export const maintenance = Effect.fn("Claude.maintenance")(function* (version: string | null) {
  const { realPath } = yield* which();
  const owner = managerOf(realPath);
  const latest =
    owner.manager === "npm" || owner.manager === "native"
      ? yield* latestNpmEffect(PACKAGE, owner.manager === "npm" ? owner.prefix : null)
      : null;
  const out: NonNullable<AgentInfo["maintenance"]> = { canUpdate: owner.manager !== "" };
  if (version) out.installedVersion = version;
  if (version && latest && isNewer(version, latest)) out.latestVersion = latest;
  if (owner.manager) out.manager = owner.manager;
  return { out, owner, realPath };
});
export const update = Effect.fn("Claude.update")(function* (env: Record<string, string>) {
  const { realPath } = yield* which();
  const owner = managerOf(realPath);
  let result;
  if (owner.manager === "native") result = yield* runProcess("claude", ["update"], { env, timeout: UPDATE_TIMEOUT });
  else if (owner.manager === "npm")
    result = yield* runProcess("npm", ["install", "-g", "--prefix", owner.prefix, `${PACKAGE}@latest`], {
      timeout: UPDATE_TIMEOUT,
    });
  else
    return yield* new ProviderError({
      message: `nothing proves which installer owns ${realPath ?? "claude"}, so it must be updated by hand`,
    });
  if (result.timedOut) return yield* new ProviderError({ message: "the update did not finish in time" });
  if (result.code !== 0)
    return yield* new ProviderError({ message: `the update failed: ${result.stderr.trim() || result.stdout.trim()}` });
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.