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

config.ts7.8 KB
// Where the CLI keeps its configuration, and what the user adds to the
// launch command (the Rust `config.rs`).
//
// There is no settings UI for this plugin yet, so the launch knobs come
// from the login environment (NOTES.md). They are read on every launch,
// so a change applies to the next session without a restart.
import * as Effect from "effect/Effect";
import { Env } from "convergence/effect";
import type { Instance } from "./instances.ts";
import { objectOf } from "./wire.ts";

export type Launch = ReturnType<typeof launchFromEnv>;

/// The CLI's own variable.
export const CONFIG_DIR = "CLAUDE_CONFIG_DIR";
/// Extra launch arguments, split like a shell command line.
export const EXTRA_ARGS = "CLAUDE_EXTRA_ARGS";
/// MCP server configuration files or JSON strings for `--mcp-config`.
export const MCP_CONFIG = "CLAUDE_MCP_CONFIG";
/// Ignore every MCP configuration except `MCP_CONFIG`.
export const STRICT_MCP_CONFIG = "CLAUDE_STRICT_MCP_CONFIG";
/// Comma separated `user,project,local` for `--setting-sources`.
export const SETTING_SOURCES = "CLAUDE_SETTING_SOURCES";
/// Text appended to the CLI's own system prompt.
export const APPEND_SYSTEM_PROMPT = "CLAUDE_APPEND_SYSTEM_PROMPT";
/// Extra directories the agent may touch, beyond the workspace.
export const ADD_DIRS = "CLAUDE_ADD_DIRS";

/// Every variable the plugin reads (the manifest's `env` grant).
export const ENV_VARS = [
  "HOME",
  CONFIG_DIR,
  EXTRA_ARGS,
  MCP_CONFIG,
  STRICT_MCP_CONFIG,
  SETTING_SOURCES,
  APPEND_SYSTEM_PROMPT,
  ADD_DIRS,
];

/// Reads the variables from the login environment. A variable that is
/// missing, empty or unreadable is left out.
export const loginEnv = Effect.fn("Claude.loginEnv")(function* () {
  const broker = yield* Env;
  const env: Record<string, string> = {};
  yield* Effect.forEach(
    ENV_VARS,
    Effect.fn(function* (name) {
      const value = yield* broker.get(name).pipe(
        Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview"], (error) =>
          Effect.sync(() => {
            console.warn(`claude: reading ${name} failed: ${error.message}`);
            return null;
          }),
        ),
      );
      if (typeof value === "string" && value.trim()) env[name] = value.trim();
    }),
    { concurrency: "unbounded" },
  );
  return env;
});

function flag(value: string | undefined) {
  return ["1", "true", "yes", "on"].includes(value ?? "");
}

/// `~/rest` with the home folder, anything else as written.
export function expandHome(path: string, home?: string) {
  if (path.startsWith("~/") && home) return `${home.replace(/\/+$/, "")}/${path.slice(2)}`;
  return path;
}

/// The configuration directory an account was told to use: its own, else
/// `CLAUDE_CONFIG_DIR`. `null` means the CLI's own default.
export function chosenConfigDir(instance: Instance, env: Record<string, string>) {
  const own = typeof instance.configDir === "string" ? instance.configDir.trim() : "";
  if (own) return expandHome(own, env.HOME);
  return env[CONFIG_DIR] ?? null;
}

/// The configuration directory an account uses: the chosen one, else
/// `~/.claude`.
export function configDirOf(instance: Instance, env: Record<string, string>) {
  return chosenConfigDir(instance, env) ?? (env.HOME ? `${env.HOME.replace(/\/+$/, "")}/.claude` : null);
}

/// Accounts that share this key can continue each other's conversations,
/// because a configuration directory holds the stored transcripts.
export function continuationKey(instance: Instance, env: Record<string, string>) {
  return chosenConfigDir(instance, env) ?? "";
}

/// Variables that would break the account rather than configure it:
/// moving `HOME` also moves the macOS keychain, and the CLI would lose the
/// login it keeps there.
const REFUSED_ENV = ["HOME", CONFIG_DIR];

/// The environment an account's child gets on top of the login
/// environment. Only a chosen directory is exported: setting
/// `CLAUDE_CONFIG_DIR` at all, even to the default `~/.claude`, makes the
/// CLI read a credentials file instead of the keychain and report the user
/// as signed out. `CLAUDE_CONFIG_DIR` from the login environment reaches
/// the child as it is, which is the same directory.
export function childEnv(instance: Instance, env: Record<string, string>) {
  const out: Record<string, string> = {};
  for (const [name, value] of Object.entries(instance.env ?? {})) {
    if (REFUSED_ENV.includes(name)) {
      console.warn(`claude: ignoring ${name} in the ${instance.id || "default"} account: it would move the login`);
      continue;
    }
    if (typeof value === "string") out[name] = value;
  }
  const own = typeof instance.configDir === "string" ? instance.configDir.trim() : "";
  const dir = own ? expandHome(own, env.HOME).replace(/\/+$/, "") : "";
  // `~/.claude` chosen by hand (in Settings, say) is the CLI's own: exporting
  // it would sign the account out.
  if (dir && !(env.HOME && dir === `${env.HOME.replace(/\/+$/, "")}/.claude`)) out[CONFIG_DIR] = dir;
  return out;
}

/// Everything the user added to the launch command.
export function launchFromEnv(env: Record<string, string>) {
  return {
    mcpConfig: env[MCP_CONFIG] ? splitArgs(env[MCP_CONFIG] ?? "") : [],
    strictMcpConfig: flag(env[STRICT_MCP_CONFIG]),
    settingSources: env[SETTING_SOURCES] ?? null,
    appendSystemPrompt: env[APPEND_SYSTEM_PROMPT] ?? null,
    addDirs: env[ADD_DIRS] ? splitArgs(env[ADD_DIRS] ?? "") : [],
    extraArgs: env[EXTRA_ARGS] ? splitArgs(env[EXTRA_ARGS] ?? "") : [],
  };
}

export function emptyLaunch() {
  return launchFromEnv({});
}

/// The arguments a launch configuration contributes. `--mcp-config` and
/// `--add-dir` take a list and keep consuming words until the next flag,
/// so each value gets its own flag. `appendSystemPrompt` is not a flag: it
/// travels in the `initialize` request with the host's instructions. The
/// workspace is granted explicitly because the working directory may be a
/// link the CLI resolves elsewhere.
export function launchArgs(launch: Launch, workspace: string) {
  const args = [];
  for (const config of launch.mcpConfig) args.push("--mcp-config", config);
  if (launch.strictMcpConfig) args.push("--strict-mcp-config");
  if (launch.settingSources) args.push("--setting-sources", launch.settingSources);
  for (const directory of [workspace, ...launch.addDirs]) args.push("--add-dir", directory);
  args.push(...launch.extraArgs);
  return args;
}

/// What the user's settings say about dynamic workflows. The CLI reports
/// neither switch, so both come from `settings.json`. A missing key or an
/// unreadable file means the CLI's own default: on.
export function workflowSettings(settings: unknown) {
  const off = (key: string) => objectOf(settings)[key] === false;
  const enabled = !off("enableWorkflows");
  return { enabled, keyword: enabled && !off("workflowKeywordTriggerEnabled") };
}

/// Splits a command line the way a shell would: single quotes are
/// literal, double quotes keep spaces, and a backslash escapes the next
/// character outside single quotes. An empty quoted word is a word.
export function splitArgs(line: string) {
  const words = [];
  let word = "";
  let started = false;
  let quote: string | null = null;
  const chars = [...line];
  for (let i = 0; i < chars.length; i += 1) {
    const c = chars[i] ?? "";
    if (quote !== null) {
      if (c === quote) quote = null;
      else if (quote === "'") word += c;
      else if (c === "\\") {
        i += 1;
        word += chars[i] ?? "\\";
      } else word += c;
      continue;
    }
    if (c === "'" || c === '"') {
      quote = c;
      started = true;
    } else if (c === "\\") {
      i += 1;
      word += chars[i] ?? "\\";
    } else if (/\s/.test(c)) {
      if (started || word) {
        words.push(word);
        word = "";
        started = false;
      }
    } else word += c;
  }
  if (started || word) words.push(word);
  return words;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.