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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 128.7 KB | 4 permissions | Listed |
No comments yet.