copillm 0.2.1 → 0.2.2

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
@@ -24,6 +24,7 @@ import { isShellSyntax, renderEnvBlock } from "./cli/envBlock.js";
24
24
  import { buildClaudeEnvBundle, buildCodexEnvBundle, buildPiEnvBundle } from "./cli/agentEnv.js";
25
25
  import { launchAgent } from "./cli/launchAgent.js";
26
26
  import { applyAgentConfig, formatApplyNotes } from "./agentconfig/apply.js";
27
+ import { applyYolo, resolveYolo } from "./agents/registry.js";
27
28
  import { registerConfigCommands } from "./cli/configCommands.js";
28
29
  import { installProcessSafetyNet } from "./cli/processSafetyNet.js";
29
30
  const logger = createLogger();
@@ -304,11 +305,19 @@ program
304
305
  .action(async (opts) => {
305
306
  const debug = resolveCopillmDebug(opts.debug);
306
307
  enableRuntimeDebug(debug);
307
- const started = await runDaemon({ debug });
308
- if (started.kind === "already_running") {
309
- process.exit(0);
308
+ try {
309
+ const started = await runDaemon({ debug });
310
+ if (started.kind === "already_running") {
311
+ process.exit(0);
312
+ }
313
+ process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${debug ? " [debug]" : ""}\n`);
314
+ }
315
+ catch (err) {
316
+ const message = err instanceof Error ? err.message : String(err);
317
+ logger.fatal({ err }, "daemon failed to start");
318
+ process.stderr.write(`copillm daemon: ${message}\n`);
319
+ process.exit(1);
310
320
  }
311
- process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${debug ? " [debug]" : ""}\n`);
312
321
  });
313
322
  program
314
323
  .command("stop")
@@ -666,6 +675,7 @@ program
666
675
  .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
667
676
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
668
677
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
678
+ .option("--yolo", "Skip approvals/sandbox (injects --dangerously-bypass-approvals-and-sandbox). Env: COPILLM_YOLO")
669
679
  .allowUnknownOption(true)
670
680
  .passThroughOptions()
671
681
  .helpOption(false)
@@ -691,9 +701,11 @@ program
691
701
  process.stderr.write(`${line}\n`);
692
702
  }
693
703
  const env = { ...bundle.env, ...applyResult.envOverlay };
704
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
705
+ const args = applyYolo({ agent: "codex", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
694
706
  const exitCode = await launchAgent({
695
707
  agent: "codex",
696
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
708
+ args,
697
709
  env,
698
710
  pinnedSpec
699
711
  });
@@ -706,6 +718,7 @@ program
706
718
  .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
707
719
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
708
720
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
721
+ .option("--yolo", "Skip permission prompts (injects --dangerously-skip-permissions). Env: COPILLM_YOLO")
709
722
  .allowUnknownOption(true)
710
723
  .passThroughOptions()
711
724
  .helpOption(false)
@@ -730,9 +743,11 @@ program
730
743
  process.stderr.write(`${line}\n`);
731
744
  }
732
745
  const env = { ...claude.bundle.env, ...applyResult.envOverlay };
746
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
747
+ const args = applyYolo({ agent: "claude", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
733
748
  const exitCode = await launchAgent({
734
749
  agent: "claude",
735
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
750
+ args,
736
751
  env,
737
752
  pinnedSpec
738
753
  });
@@ -745,6 +760,7 @@ program
745
760
  .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
746
761
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
747
762
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
763
+ .option("--yolo", "Skip approvals if supported (pi has no equivalent; emits a warning). Env: COPILLM_YOLO")
748
764
  .allowUnknownOption(true)
749
765
  .passThroughOptions()
750
766
  .helpOption(false)
@@ -769,9 +785,11 @@ program
769
785
  process.stderr.write(`${line}\n`);
770
786
  }
771
787
  const env = { ...bundle.env, ...applyResult.envOverlay };
788
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
789
+ const args = applyYolo({ agent: "pi", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
772
790
  const exitCode = await launchAgent({
773
791
  agent: "pi",
774
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
792
+ args,
775
793
  env,
776
794
  pinnedSpec
777
795
  });
@@ -783,6 +801,7 @@ program
783
801
  .option("--copillm-use <spec>", "Pin copilot package version (e.g. 1.0.52 or @github/copilot@1.0.52)")
784
802
  .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
785
803
  .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
804
+ .option("--yolo", "Allow all tools/paths/URLs (injects --allow-all). Env: COPILLM_YOLO")
786
805
  .allowUnknownOption(true)
787
806
  .passThroughOptions()
788
807
  .helpOption(false)
@@ -812,9 +831,11 @@ program
812
831
  ...applyResult.envOverlay,
813
832
  COPILOT_GITHUB_TOKEN: credential.token
814
833
  };
834
+ const baseArgs = [...(forwardedArgs ?? []), ...applyResult.cliArgs];
835
+ const args = applyYolo({ agent: "copilot", userArgs: baseArgs, yolo: resolveYolo(opts.yolo) });
815
836
  const exitCode = await launchAgent({
816
837
  agent: "copilot",
817
- args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
838
+ args,
818
839
  env,
819
840
  pinnedSpec
820
841
  });
@@ -919,7 +940,7 @@ function candidatePorts(preferredPort) {
919
940
  }
920
941
  function describeBackend(backend) {
921
942
  switch (backend) {
922
- case "keytar":
943
+ case "keyring":
923
944
  return "OS keychain";
924
945
  case "file":
925
946
  return "credentials file";
@@ -1274,6 +1295,12 @@ async function ensureDaemonRunningForLauncher(opts) {
1274
1295
  await warnIfDebugRequestedButInactive(opts.debug, live.port);
1275
1296
  return live;
1276
1297
  }
1298
+ // Fail fast on missing credentials rather than spawning a detached daemon
1299
+ // that will die silently and surface as a generic "start timed out" error.
1300
+ const authState = await inspectStoredCredential();
1301
+ if (!authState.stored) {
1302
+ throw new Error("Not authenticated. Run `copillm auth login` first.");
1303
+ }
1277
1304
  const debugLog = currentDebugLogPath(opts.debug);
1278
1305
  process.stderr.write(opts.debug && debugLog
1279
1306
  ? `Starting copillm in background with debug logging at ${displayHomePath(debugLog)}...\n`
@@ -1283,17 +1310,40 @@ async function ensureDaemonRunningForLauncher(opts) {
1283
1310
  daemonArgs.push("--debug");
1284
1311
  const child = spawn(process.execPath, daemonArgs, {
1285
1312
  detached: true,
1286
- stdio: "ignore",
1313
+ stdio: ["ignore", "ignore", "pipe"],
1287
1314
  env: daemonSpawnEnv(opts.debug)
1288
1315
  });
1289
1316
  child.unref();
1317
+ const stderrChunks = [];
1318
+ let stderrBytes = 0;
1319
+ const STDERR_TAIL_LIMIT = 8 * 1024;
1320
+ if (child.stderr) {
1321
+ child.stderr.on("data", (chunk) => {
1322
+ stderrChunks.push(chunk);
1323
+ stderrBytes += chunk.length;
1324
+ while (stderrBytes > STDERR_TAIL_LIMIT && stderrChunks.length > 1) {
1325
+ stderrBytes -= stderrChunks[0].length;
1326
+ stderrChunks.shift();
1327
+ }
1328
+ });
1329
+ child.stderr.on("error", () => {
1330
+ // Ignore — best-effort capture only.
1331
+ });
1332
+ }
1333
+ const formatStderrTail = () => {
1334
+ const tail = Buffer.concat(stderrChunks).toString("utf8").trim();
1335
+ return tail ? `\nDaemon stderr (tail):\n${tail}` : "";
1336
+ };
1290
1337
  const started = await waitForDaemonReady(child.pid ?? null, 10_000);
1291
1338
  if (!started) {
1292
- throw new Error("Auto-start of copillm daemon timed out.");
1339
+ if (child.pid !== undefined && !isPidAlive(child.pid)) {
1340
+ throw new Error(`copillm daemon exited before becoming ready.${formatStderrTail()}`);
1341
+ }
1342
+ throw new Error(`Auto-start of copillm daemon timed out.${formatStderrTail()}`);
1293
1343
  }
1294
1344
  const inspection = inspectLock();
1295
1345
  if (inspection.state !== "running") {
1296
- throw new Error("copillm daemon failed to register a lock after auto-start.");
1346
+ throw new Error(`copillm daemon failed to register a lock after auto-start.${formatStderrTail()}`);
1297
1347
  }
1298
1348
  return inspection.lock;
1299
1349
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",