copillm 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Per-agent capability registry. The single source of truth for agent-specific
3
+ * behaviour that copillm needs to know about (yolo mapping, future: model
4
+ * pinning, debug surfaces, etc.). Adding a new agent should be one row here
5
+ * plus wiring in `src/cli.ts` — never a new branch in the action handlers.
6
+ *
7
+ * Note: the `AgentName` union lives in `../integrations/registry.ts` (paired
8
+ * with npm package / bin name metadata). We reuse it here so the two
9
+ * registries can never diverge.
10
+ */
11
+ export const AGENTS = {
12
+ claude: {
13
+ name: "claude",
14
+ yolo: { mode: "inject", flags: ["--dangerously-skip-permissions"] }
15
+ },
16
+ codex: {
17
+ name: "codex",
18
+ yolo: { mode: "inject", flags: ["--dangerously-bypass-approvals-and-sandbox"] }
19
+ },
20
+ copilot: {
21
+ name: "copilot",
22
+ yolo: { mode: "inject", flags: ["--allow-all"] }
23
+ },
24
+ pi: {
25
+ name: "pi",
26
+ yolo: {
27
+ mode: "unsupported",
28
+ reason: "pi has no blanket-approve flag; use its per-tool approvals instead"
29
+ }
30
+ }
31
+ };
32
+ /**
33
+ * Resolve `--yolo` for a given agent and return the (possibly transformed)
34
+ * argv to forward downstream. Pure function aside from the optional warning
35
+ * sink — easy to unit-test.
36
+ */
37
+ export function applyYolo(options) {
38
+ const args = [...options.userArgs];
39
+ if (!options.yolo)
40
+ return args;
41
+ const spec = AGENTS[options.agent].yolo;
42
+ switch (spec.mode) {
43
+ case "inject": {
44
+ const alreadyPresent = spec.flags.some((flag) => args.includes(flag));
45
+ if (alreadyPresent)
46
+ return args;
47
+ return [...spec.flags, ...args];
48
+ }
49
+ case "passthrough": {
50
+ if (args.includes("--yolo"))
51
+ return args;
52
+ return ["--yolo", ...args];
53
+ }
54
+ case "unsupported": {
55
+ const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
56
+ warn(`copillm: --yolo ignored for ${options.agent} (${spec.reason})`);
57
+ return args;
58
+ }
59
+ }
60
+ }
61
+ /**
62
+ * Read the `COPILLM_YOLO` env var as a boolean. Accepts "1", "true", "yes"
63
+ * (case-insensitive) as truthy; everything else (including unset) is false.
64
+ */
65
+ export function yoloFromEnv(env = process.env) {
66
+ const raw = env.COPILLM_YOLO?.trim().toLowerCase();
67
+ return raw === "1" || raw === "true" || raw === "yes";
68
+ }
69
+ /** Combine the per-launch flag with the env var fallback. */
70
+ export function resolveYolo(flag, env = process.env) {
71
+ return Boolean(flag) || yoloFromEnv(env);
72
+ }
@@ -18,16 +18,57 @@ let sessionCredential = null;
18
18
  function forceSessionBackend() {
19
19
  return process.env.COPILLM_FORCE_SESSION_BACKEND === "1";
20
20
  }
21
- async function tryImportKeytar() {
21
+ async function tryImportKeyring() {
22
22
  if (forceSessionBackend()) {
23
23
  return null;
24
24
  }
25
25
  try {
26
- const mod = await import("keytar");
27
- return mod.default;
26
+ const mod = (await import("@napi-rs/keyring"));
27
+ // Test seam: mocks can return `null` or `{ default: null }` to simulate an
28
+ // unavailable backend without throwing from the vi.mock factory (which
29
+ // confuses vitest's hoisting and our isMissingKeyringError check).
30
+ if (!mod) {
31
+ return null;
32
+ }
33
+ const AsyncEntry = mod.AsyncEntry ?? (mod.default && typeof mod.default === "object" ? mod.default.AsyncEntry : undefined);
34
+ if (typeof AsyncEntry !== "function") {
35
+ return null;
36
+ }
37
+ return {
38
+ async getPassword(service, account) {
39
+ const entry = new AsyncEntry(service, account);
40
+ try {
41
+ const value = await entry.getPassword();
42
+ return value ?? null;
43
+ }
44
+ catch (error) {
45
+ if (isNoEntryError(error)) {
46
+ return null;
47
+ }
48
+ throw error;
49
+ }
50
+ },
51
+ async setPassword(service, account, password) {
52
+ const entry = new AsyncEntry(service, account);
53
+ await entry.setPassword(password);
54
+ },
55
+ async deletePassword(service, account) {
56
+ const entry = new AsyncEntry(service, account);
57
+ try {
58
+ await entry.deletePassword();
59
+ return true;
60
+ }
61
+ catch (error) {
62
+ if (isNoEntryError(error)) {
63
+ return false;
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ };
28
69
  }
29
70
  catch (error) {
30
- if (isMissingKeytarError(error)) {
71
+ if (isMissingKeyringError(error)) {
31
72
  return null;
32
73
  }
33
74
  if (error instanceof Error) {
@@ -36,15 +77,25 @@ async function tryImportKeytar() {
36
77
  throw new Error("Failed to initialize OS keychain backend: unknown error");
37
78
  }
38
79
  }
39
- async function resolveKeytar() {
80
+ // keyring-rs returns a "NoEntry" error when an item doesn't exist. Map that to
81
+ // null/false to preserve the keytar-style "missing is not an error" semantics
82
+ // that the rest of credentials.ts is built around.
83
+ function isNoEntryError(error) {
84
+ if (!(error instanceof Error)) {
85
+ return false;
86
+ }
87
+ const message = error.message.toLowerCase();
88
+ return message.includes("no matching entry") || message.includes("no entry") || message.includes("noentry");
89
+ }
90
+ async function resolveKeyring() {
40
91
  if (forceSessionBackend()) {
41
- return { keytar: null, reason: "forced_session_backend" };
92
+ return { keyring: null, reason: "forced_session_backend" };
42
93
  }
43
- const keytar = await tryImportKeytar();
44
- if (keytar) {
45
- return { keytar, reason: null };
94
+ const keyring = await tryImportKeyring();
95
+ if (keyring) {
96
+ return { keyring, reason: null };
46
97
  }
47
- return { keytar: null, reason: "keytar module is unavailable on this machine" };
98
+ return { keyring: null, reason: "keyring module is unavailable on this machine" };
48
99
  }
49
100
  function parseCredentialFile() {
50
101
  const path = credentialsReadPath();
@@ -86,13 +137,13 @@ function canUsePlaintextFallback() {
86
137
  }
87
138
  return process.stdin.isTTY || process.env.COPILLM_ALLOW_PLAINTEXT_CREDENTIALS === "1";
88
139
  }
89
- function isMissingKeytarError(error) {
140
+ function isMissingKeyringError(error) {
90
141
  if (!(error instanceof Error)) {
91
142
  return false;
92
143
  }
93
144
  const message = error.message.toLowerCase();
94
- return (message.includes("cannot find package 'keytar'") ||
95
- message.includes("cannot find module 'keytar'") ||
145
+ return (message.includes("cannot find package '@napi-rs/keyring") ||
146
+ message.includes("cannot find module '@napi-rs/keyring") ||
96
147
  message.includes("module not found"));
97
148
  }
98
149
  /**
@@ -108,14 +159,14 @@ export async function inspectStoredCredential() {
108
159
  if (fs.existsSync(credentialsReadPath())) {
109
160
  return { stored: true, backend: "file" };
110
161
  }
111
- const { keytar } = await resolveKeytar();
112
- if (!keytar) {
162
+ const { keyring } = await resolveKeyring();
163
+ if (!keyring) {
113
164
  return { stored: false, backend: null };
114
165
  }
115
166
  try {
116
- const token = await keytar.getPassword(SERVICE, ACCOUNT);
167
+ const token = await keyring.getPassword(SERVICE, ACCOUNT);
117
168
  if (token) {
118
- return { stored: true, backend: "keytar" };
169
+ return { stored: true, backend: "keyring" };
119
170
  }
120
171
  return { stored: false, backend: null };
121
172
  }
@@ -134,13 +185,13 @@ export async function loadStoredCredential() {
134
185
  const parsed = parseCredentialFile();
135
186
  return { token: parsed.token, accountType: parsed.accountType, source: "file" };
136
187
  }
137
- const { keytar } = await resolveKeytar();
138
- if (!keytar) {
188
+ const { keyring } = await resolveKeyring();
189
+ if (!keyring) {
139
190
  return null;
140
191
  }
141
192
  let token;
142
193
  try {
143
- token = await keytar.getPassword(SERVICE, ACCOUNT);
194
+ token = await keyring.getPassword(SERVICE, ACCOUNT);
144
195
  }
145
196
  catch (error) {
146
197
  if (error instanceof Error) {
@@ -151,7 +202,7 @@ export async function loadStoredCredential() {
151
202
  if (!token) {
152
203
  return null;
153
204
  }
154
- return { token, accountType: "individual", source: "keytar" };
205
+ return { token, accountType: "individual", source: "keyring" };
155
206
  }
156
207
  export async function saveStoredCredential(token, accountType, options = {}) {
157
208
  const mode = options.mode ?? "auto";
@@ -164,11 +215,11 @@ export async function saveStoredCredential(token, accountType, options = {}) {
164
215
  writeCredentialFile(token, accountType);
165
216
  return "file";
166
217
  }
167
- const { keytar, reason } = await resolveKeytar();
168
- if (keytar) {
218
+ const { keyring, reason } = await resolveKeyring();
219
+ if (keyring) {
169
220
  try {
170
- await keytar.setPassword(SERVICE, ACCOUNT, token);
171
- return "keytar";
221
+ await keyring.setPassword(SERVICE, ACCOUNT, token);
222
+ return "keyring";
172
223
  }
173
224
  catch (error) {
174
225
  if (error instanceof Error) {
@@ -196,11 +247,11 @@ export async function clearStoredCredential() {
196
247
  }
197
248
  return { backend: "file", removed: true };
198
249
  }
199
- const { keytar, reason } = await resolveKeytar();
200
- if (keytar) {
250
+ const { keyring, reason } = await resolveKeyring();
251
+ if (keyring) {
201
252
  try {
202
- const removed = await keytar.deletePassword(SERVICE, ACCOUNT);
203
- return { backend: "keytar", removed: removed || hadSession };
253
+ const removed = await keyring.deletePassword(SERVICE, ACCOUNT);
254
+ return { backend: "keyring", removed: removed || hadSession };
204
255
  }
205
256
  catch (error) {
206
257
  if (error instanceof Error) {
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { createRequire } from "node:module";
4
5
  import { setTimeout as sleep } from "node:timers/promises";
5
6
  import { Command } from "commander";
6
7
  import { clearStoredCredential, inspectStoredCredential, loadStoredCredential, saveStoredCredential } from "./auth/credentials.js";
@@ -24,11 +25,18 @@ import { isShellSyntax, renderEnvBlock } from "./cli/envBlock.js";
24
25
  import { buildClaudeEnvBundle, buildCodexEnvBundle, buildPiEnvBundle } from "./cli/agentEnv.js";
25
26
  import { launchAgent } from "./cli/launchAgent.js";
26
27
  import { applyAgentConfig, formatApplyNotes } from "./agentconfig/apply.js";
28
+ import { applyYolo, resolveYolo } from "./agents/registry.js";
27
29
  import { registerConfigCommands } from "./cli/configCommands.js";
28
30
  import { installProcessSafetyNet } from "./cli/processSafetyNet.js";
29
31
  const logger = createLogger();
30
32
  const program = new Command();
31
- program.name("copillm").description("Local Copilot proxy").version("0.1.0");
33
+ // Resolve the package version from package.json at runtime so `--version` stays
34
+ // in sync with whatever was published. Using createRequire keeps this working
35
+ // under NodeNext ESM without needing an import-assertion syntax flag, and
36
+ // resolves the same file in both `dist/cli.js` (one level deep) and `src/cli.ts`
37
+ // when invoked via tsx.
38
+ const pkgVersion = createRequire(import.meta.url)("../package.json").version;
39
+ program.name("copillm").description("Local Copilot proxy").version(pkgVersion);
32
40
  program.enablePositionalOptions();
33
41
  program.option("--debug", "Enable copillm debug mode (debug endpoint plus verbose daemon diagnostics)");
34
42
  program
@@ -304,11 +312,19 @@ program
304
312
  .action(async (opts) => {
305
313
  const debug = resolveCopillmDebug(opts.debug);
306
314
  enableRuntimeDebug(debug);
307
- const started = await runDaemon({ debug });
308
- if (started.kind === "already_running") {
309
- process.exit(0);
315
+ try {
316
+ const started = await runDaemon({ debug });
317
+ if (started.kind === "already_running") {
318
+ process.exit(0);
319
+ }
320
+ process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${debug ? " [debug]" : ""}\n`);
321
+ }
322
+ catch (err) {
323
+ const message = err instanceof Error ? err.message : String(err);
324
+ logger.fatal({ err }, "daemon failed to start");
325
+ process.stderr.write(`copillm daemon: ${message}\n`);
326
+ process.exit(1);
310
327
  }
311
- process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${debug ? " [debug]" : ""}\n`);
312
328
  });
313
329
  program
314
330
  .command("stop")
@@ -666,6 +682,7 @@ program
666
682
  .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
667
683
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
668
684
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
685
+ .option("--yolo", "Skip approvals/sandbox (injects --dangerously-bypass-approvals-and-sandbox). Env: COPILLM_YOLO")
669
686
  .allowUnknownOption(true)
670
687
  .passThroughOptions()
671
688
  .helpOption(false)
@@ -691,9 +708,11 @@ program
691
708
  process.stderr.write(`${line}\n`);
692
709
  }
693
710
  const env = { ...bundle.env, ...applyResult.envOverlay };
711
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
712
+ const args = applyYolo({ agent: "codex", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
694
713
  const exitCode = await launchAgent({
695
714
  agent: "codex",
696
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
715
+ args,
697
716
  env,
698
717
  pinnedSpec
699
718
  });
@@ -706,6 +725,7 @@ program
706
725
  .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
707
726
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
708
727
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
728
+ .option("--yolo", "Skip permission prompts (injects --dangerously-skip-permissions). Env: COPILLM_YOLO")
709
729
  .allowUnknownOption(true)
710
730
  .passThroughOptions()
711
731
  .helpOption(false)
@@ -730,9 +750,11 @@ program
730
750
  process.stderr.write(`${line}\n`);
731
751
  }
732
752
  const env = { ...claude.bundle.env, ...applyResult.envOverlay };
753
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
754
+ const args = applyYolo({ agent: "claude", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
733
755
  const exitCode = await launchAgent({
734
756
  agent: "claude",
735
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
757
+ args,
736
758
  env,
737
759
  pinnedSpec
738
760
  });
@@ -745,6 +767,7 @@ program
745
767
  .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
746
768
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
747
769
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
770
+ .option("--yolo", "Skip approvals if supported (pi has no equivalent; emits a warning). Env: COPILLM_YOLO")
748
771
  .allowUnknownOption(true)
749
772
  .passThroughOptions()
750
773
  .helpOption(false)
@@ -769,9 +792,11 @@ program
769
792
  process.stderr.write(`${line}\n`);
770
793
  }
771
794
  const env = { ...bundle.env, ...applyResult.envOverlay };
795
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
796
+ const args = applyYolo({ agent: "pi", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
772
797
  const exitCode = await launchAgent({
773
798
  agent: "pi",
774
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
799
+ args,
775
800
  env,
776
801
  pinnedSpec
777
802
  });
@@ -783,6 +808,7 @@ program
783
808
  .option("--copillm-use <spec>", "Pin copilot package version (e.g. 1.0.52 or @github/copilot@1.0.52)")
784
809
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
785
810
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
811
+ .option("--yolo", "Allow all tools/paths/URLs (injects --allow-all). Env: COPILLM_YOLO")
786
812
  .allowUnknownOption(true)
787
813
  .passThroughOptions()
788
814
  .helpOption(false)
@@ -812,9 +838,11 @@ program
812
838
  ...applyResult.envOverlay,
813
839
  COPILOT_GITHUB_TOKEN: credential.token
814
840
  };
841
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
842
+ const args = applyYolo({ agent: "copilot", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
815
843
  const exitCode = await launchAgent({
816
844
  agent: "copilot",
817
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
845
+ args,
818
846
  env,
819
847
  pinnedSpec
820
848
  });
@@ -919,7 +947,7 @@ function candidatePorts(preferredPort) {
919
947
  }
920
948
  function describeBackend(backend) {
921
949
  switch (backend) {
922
- case "keytar":
950
+ case "keyring":
923
951
  return "OS keychain";
924
952
  case "file":
925
953
  return "credentials file";
@@ -1274,6 +1302,12 @@ async function ensureDaemonRunningForLauncher(opts) {
1274
1302
  await warnIfDebugRequestedButInactive(opts.debug, live.port);
1275
1303
  return live;
1276
1304
  }
1305
+ // Fail fast on missing credentials rather than spawning a detached daemon
1306
+ // that will die silently and surface as a generic "start timed out" error.
1307
+ const authState = await inspectStoredCredential();
1308
+ if (!authState.stored) {
1309
+ throw new Error("Not authenticated. Run `copillm auth login` first.");
1310
+ }
1277
1311
  const debugLog = currentDebugLogPath(opts.debug);
1278
1312
  process.stderr.write(opts.debug && debugLog
1279
1313
  ? `Starting copillm in background with debug logging at ${displayHomePath(debugLog)}...\n`
@@ -1283,17 +1317,40 @@ async function ensureDaemonRunningForLauncher(opts) {
1283
1317
  daemonArgs.push("--debug");
1284
1318
  const child = spawn(process.execPath, daemonArgs, {
1285
1319
  detached: true,
1286
- stdio: "ignore",
1320
+ stdio: ["ignore", "ignore", "pipe"],
1287
1321
  env: daemonSpawnEnv(opts.debug)
1288
1322
  });
1289
1323
  child.unref();
1324
+ const stderrChunks = [];
1325
+ let stderrBytes = 0;
1326
+ const STDERR_TAIL_LIMIT = 8 * 1024;
1327
+ if (child.stderr) {
1328
+ child.stderr.on("data", (chunk) => {
1329
+ stderrChunks.push(chunk);
1330
+ stderrBytes += chunk.length;
1331
+ while (stderrBytes > STDERR_TAIL_LIMIT && stderrChunks.length > 1) {
1332
+ stderrBytes -= stderrChunks[0].length;
1333
+ stderrChunks.shift();
1334
+ }
1335
+ });
1336
+ child.stderr.on("error", () => {
1337
+ // Ignore — best-effort capture only.
1338
+ });
1339
+ }
1340
+ const formatStderrTail = () => {
1341
+ const tail = Buffer.concat(stderrChunks).toString("utf8").trim();
1342
+ return tail ? `\nDaemon stderr (tail):\n${tail}` : "";
1343
+ };
1290
1344
  const started = await waitForDaemonReady(child.pid ?? null, 10_000);
1291
1345
  if (!started) {
1292
- throw new Error("Auto-start of copillm daemon timed out.");
1346
+ if (child.pid !== undefined && !isPidAlive(child.pid)) {
1347
+ throw new Error(`copillm daemon exited before becoming ready.${formatStderrTail()}`);
1348
+ }
1349
+ throw new Error(`Auto-start of copillm daemon timed out.${formatStderrTail()}`);
1293
1350
  }
1294
1351
  const inspection = inspectLock();
1295
1352
  if (inspection.state !== "running") {
1296
- throw new Error("copillm daemon failed to register a lock after auto-start.");
1353
+ throw new Error(`copillm daemon failed to register a lock after auto-start.${formatStderrTail()}`);
1297
1354
  }
1298
1355
  return inspection.lock;
1299
1356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,8 +34,8 @@
34
34
  "prepack": "npm run build"
35
35
  },
36
36
  "dependencies": {
37
+ "@napi-rs/keyring": "^1.3.0",
37
38
  "commander": "^12.1.0",
38
- "keytar": "^7.9.0",
39
39
  "pino": "^9.4.0",
40
40
  "pino-pretty": "^11.2.2",
41
41
  "smol-toml": "^1.6.1",