copillm 0.1.0
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.
- package/README.md +52 -0
- package/dist/agentconfig/apply.js +53 -0
- package/dist/agentconfig/load.js +163 -0
- package/dist/agentconfig/markerBlock.js +76 -0
- package/dist/agentconfig/render.js +317 -0
- package/dist/agentconfig/schema.js +65 -0
- package/dist/auth/copilotToken.js +122 -0
- package/dist/auth/credentials.js +221 -0
- package/dist/auth/deviceFlow.js +89 -0
- package/dist/auth/ensureAuthenticated.js +55 -0
- package/dist/auth/githubIdentity.js +42 -0
- package/dist/auth/interactivePrompt.js +135 -0
- package/dist/claude/cache.js +20 -0
- package/dist/claude/settingsConflict.js +85 -0
- package/dist/cli/agentEnv.js +56 -0
- package/dist/cli/configCommands.js +149 -0
- package/dist/cli/envBlock.js +43 -0
- package/dist/cli/launchAgent.js +59 -0
- package/dist/cli/resolveAgent.js +361 -0
- package/dist/cli.js +1178 -0
- package/dist/codex/init.js +93 -0
- package/dist/config/config.js +51 -0
- package/dist/config/fsSecurity.js +39 -0
- package/dist/config/home.js +62 -0
- package/dist/config/logging.js +33 -0
- package/dist/config/upstream.js +38 -0
- package/dist/models/anthropicDefaults.js +138 -0
- package/dist/models/discovery.js +208 -0
- package/dist/pi/init.js +174 -0
- package/dist/server/anthropicModelsResponse.js +151 -0
- package/dist/server/codexSchema.js +100 -0
- package/dist/server/debugInfo.js +48 -0
- package/dist/server/lock.js +150 -0
- package/dist/server/proxy.js +715 -0
- package/dist/translation/openaiAnthropic.js +391 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
- package/dist/types/index.js +1 -0
- package/package.json +50 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { clearStoredCredential, inspectStoredCredential, loadStoredCredential, saveStoredCredential } from "./auth/credentials.js";
|
|
7
|
+
import { inspectGithubIdentity } from "./auth/githubIdentity.js";
|
|
8
|
+
import { ensureAuthenticatedInteractive as ensureAuthenticatedInteractiveImpl } from "./auth/ensureAuthenticated.js";
|
|
9
|
+
import { loginViaDeviceFlow } from "./auth/deviceFlow.js";
|
|
10
|
+
import { CopilotTokenManager } from "./auth/copilotToken.js";
|
|
11
|
+
import { confirm, choose } from "./auth/interactivePrompt.js";
|
|
12
|
+
import { loadConfig, saveConfig } from "./config/config.js";
|
|
13
|
+
import { createLogger } from "./config/logging.js";
|
|
14
|
+
import { listModels, resolveModelSelections } from "./models/discovery.js";
|
|
15
|
+
import { acquireLock, inspectLock, LockAlreadyRunningError, releaseLock } from "./server/lock.js";
|
|
16
|
+
import { startProxyServer } from "./server/proxy.js";
|
|
17
|
+
import { defaultOutputDir, generateCodexHome } from "./codex/init.js";
|
|
18
|
+
import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "./pi/init.js";
|
|
19
|
+
import { getCopillmHome } from "./config/home.js";
|
|
20
|
+
import { clearClaudeGatewayCache } from "./claude/cache.js";
|
|
21
|
+
import { detectClaudeSettingsConflicts, formatSettingsConflictWarning } from "./claude/settingsConflict.js";
|
|
22
|
+
import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "./models/anthropicDefaults.js";
|
|
23
|
+
import { isShellSyntax, renderEnvBlock } from "./cli/envBlock.js";
|
|
24
|
+
import { buildClaudeEnvBundle, buildCodexEnvBundle, buildPiEnvBundle } from "./cli/agentEnv.js";
|
|
25
|
+
import { launchAgent } from "./cli/launchAgent.js";
|
|
26
|
+
import { applyAgentConfig, formatApplyNotes } from "./agentconfig/apply.js";
|
|
27
|
+
import { registerConfigCommands } from "./cli/configCommands.js";
|
|
28
|
+
const logger = createLogger();
|
|
29
|
+
const program = new Command();
|
|
30
|
+
program.name("copillm").description("Local Copilot proxy").version("0.1.0");
|
|
31
|
+
program.enablePositionalOptions();
|
|
32
|
+
program
|
|
33
|
+
.command("login")
|
|
34
|
+
.description("[deprecated] Use `copillm auth login`")
|
|
35
|
+
.option("--json", "JSON output")
|
|
36
|
+
.action(async (opts) => {
|
|
37
|
+
emitDeprecation(opts, "login", "auth login");
|
|
38
|
+
await runAuthLogin(opts, { forceSession: false });
|
|
39
|
+
});
|
|
40
|
+
program
|
|
41
|
+
.command("logout")
|
|
42
|
+
.description("[deprecated] Use `copillm auth logout`")
|
|
43
|
+
.option("--json", "JSON output")
|
|
44
|
+
.action(async (opts) => {
|
|
45
|
+
emitDeprecation(opts, "logout", "auth logout");
|
|
46
|
+
await runAuthLogout(opts);
|
|
47
|
+
});
|
|
48
|
+
const auth = program.command("auth").description("Authentication commands");
|
|
49
|
+
auth
|
|
50
|
+
.command("login")
|
|
51
|
+
.description("Authenticate with GitHub")
|
|
52
|
+
.option("--json", "JSON output")
|
|
53
|
+
// Undocumented test seam: force the session-only backend regardless of
|
|
54
|
+
// whether the OS keychain is available. Equivalent to setting
|
|
55
|
+
// COPILLM_FORCE_SESSION_BACKEND=1 for the duration of this command.
|
|
56
|
+
.option("--force-session", "(test-only) force the session-only backend", false)
|
|
57
|
+
.action(async (opts) => {
|
|
58
|
+
await runAuthLogin(opts, { forceSession: Boolean(opts.forceSession) });
|
|
59
|
+
});
|
|
60
|
+
auth
|
|
61
|
+
.command("logout")
|
|
62
|
+
.description("Clear credentials and stop running daemon")
|
|
63
|
+
.option("--json", "JSON output")
|
|
64
|
+
.action(async (opts) => {
|
|
65
|
+
await runAuthLogout(opts);
|
|
66
|
+
});
|
|
67
|
+
auth
|
|
68
|
+
.command("status")
|
|
69
|
+
.description("Report whether a credential is stored (token is never printed)")
|
|
70
|
+
.option("--json", "JSON output")
|
|
71
|
+
.option("--no-user", "Skip the GitHub /user lookup that fetches the login name")
|
|
72
|
+
.action(async (opts) => {
|
|
73
|
+
let info;
|
|
74
|
+
try {
|
|
75
|
+
info = await inspectStoredCredential();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : "unknown_error";
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
process.stdout.write(JSON.stringify({ status: "error", error: message }, null, 2) + "\n");
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
process.stderr.write(`auth status error: ${message}\n`);
|
|
84
|
+
}
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
// commander's --no-user toggles opts.user to false; when the flag is
|
|
88
|
+
// omitted opts.user is undefined and we treat that as "fetch by default".
|
|
89
|
+
const userLookupEnabled = info.stored && opts.user !== false;
|
|
90
|
+
let identity = null;
|
|
91
|
+
if (userLookupEnabled) {
|
|
92
|
+
// inspectGithubIdentity is designed to return null on any failure, but
|
|
93
|
+
// we wrap defensively at the CLI level too: a regression in the wrapper,
|
|
94
|
+
// or a platform-specific fetch error path (e.g. Node 22 on macOS has
|
|
95
|
+
// surfaced uncaught socket rejections from privileged-port ECONNREFUSED),
|
|
96
|
+
// must never break the auth-status command. Status output should always
|
|
97
|
+
// succeed even when the network is broken.
|
|
98
|
+
try {
|
|
99
|
+
identity = await inspectGithubIdentity();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
identity = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (opts.json) {
|
|
106
|
+
process.stdout.write(JSON.stringify({
|
|
107
|
+
status: info.stored ? "logged_in" : "logged_out",
|
|
108
|
+
stored: info.stored,
|
|
109
|
+
backend: info.backend,
|
|
110
|
+
user: identity
|
|
111
|
+
}, null, 2) + "\n");
|
|
112
|
+
}
|
|
113
|
+
else if (info.stored) {
|
|
114
|
+
process.stdout.write(`${formatHumanAuthStatusLine(info.backend, identity)}\n`);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
process.stdout.write("not logged in\n");
|
|
118
|
+
}
|
|
119
|
+
process.exit(info.stored ? 0 : 2);
|
|
120
|
+
});
|
|
121
|
+
program
|
|
122
|
+
.command("start")
|
|
123
|
+
.description("Start proxy")
|
|
124
|
+
.option("--detach", "Run in detached mode")
|
|
125
|
+
.option("--debug", "Enable debug endpoints (e.g. /_debug)")
|
|
126
|
+
.option("--no-codex", "Skip generating ~/.copillm/codex/ for Codex CLI")
|
|
127
|
+
.option("--codex-model <id>", "Default Codex model slug")
|
|
128
|
+
.option("--no-pi", "Skip generating ~/.pi/agent/models.json for pi coding agent")
|
|
129
|
+
.option("--json", "JSON output")
|
|
130
|
+
.action(async (opts) => {
|
|
131
|
+
if (opts.detach) {
|
|
132
|
+
// Fail fast on missing credentials rather than letting the detached
|
|
133
|
+
// child die silently and surface as a generic "start timed out" error.
|
|
134
|
+
const authState = await inspectStoredCredential();
|
|
135
|
+
if (!authState.stored) {
|
|
136
|
+
throw new Error("Not authenticated. Run `copillm auth login` first, or start without --detach to log in interactively.");
|
|
137
|
+
}
|
|
138
|
+
const existingLock = await readLiveLock();
|
|
139
|
+
if (existingLock) {
|
|
140
|
+
const codex = opts.codex === false ? null : await refreshCodexHome(existingLock.port, opts.codexModel ?? null);
|
|
141
|
+
const pi = opts.pi === false ? null : await refreshPiHome(existingLock.port);
|
|
142
|
+
const claude = buildClaudeExportCommand(existingLock.port, null);
|
|
143
|
+
const banner = formatStartBanner({
|
|
144
|
+
port: existingLock.port,
|
|
145
|
+
pid: existingLock.pid,
|
|
146
|
+
mode: "already_running",
|
|
147
|
+
debug: false,
|
|
148
|
+
codex,
|
|
149
|
+
pi
|
|
150
|
+
});
|
|
151
|
+
writeCommandOutput(opts, banner, {
|
|
152
|
+
status: "already_running",
|
|
153
|
+
pid: existingLock.pid,
|
|
154
|
+
port: existingLock.port,
|
|
155
|
+
url: `http://127.0.0.1:${existingLock.port}`,
|
|
156
|
+
codex_home: codex?.outDir ?? null,
|
|
157
|
+
codex_export_command: codex?.exportCommand ?? null,
|
|
158
|
+
codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
|
|
159
|
+
pi_home: pi?.outDir ?? null,
|
|
160
|
+
pi_config_path: pi?.configPath ?? null,
|
|
161
|
+
pi_mirror_path: pi?.mirrorPath ?? null,
|
|
162
|
+
pi_backup_path: pi?.backupPath ?? null,
|
|
163
|
+
pi_model_count: pi?.modelCount ?? null,
|
|
164
|
+
claude_export_command: claude.command,
|
|
165
|
+
claude_env: claude.bundle.env,
|
|
166
|
+
claude_defaults: claude.defaults
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const daemonArgs = [process.argv[1], "daemon"];
|
|
171
|
+
if (opts.debug) {
|
|
172
|
+
daemonArgs.push("--debug");
|
|
173
|
+
}
|
|
174
|
+
const child = spawn(process.execPath, daemonArgs, {
|
|
175
|
+
detached: true,
|
|
176
|
+
stdio: "ignore"
|
|
177
|
+
});
|
|
178
|
+
child.unref();
|
|
179
|
+
const started = await waitForDaemonReady(child.pid ?? null, 8_000);
|
|
180
|
+
if (!started) {
|
|
181
|
+
throw new Error("Detached daemon start timed out.");
|
|
182
|
+
}
|
|
183
|
+
const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null);
|
|
184
|
+
const pi = opts.pi === false ? null : await refreshPiHome(started.port);
|
|
185
|
+
const claude = buildClaudeExportCommand(started.port, null);
|
|
186
|
+
const banner = formatStartBanner({
|
|
187
|
+
port: started.port,
|
|
188
|
+
pid: started.pid,
|
|
189
|
+
mode: "detached",
|
|
190
|
+
debug: Boolean(opts.debug),
|
|
191
|
+
codex,
|
|
192
|
+
pi
|
|
193
|
+
});
|
|
194
|
+
writeCommandOutput(opts, banner, {
|
|
195
|
+
status: "ok",
|
|
196
|
+
mode: "detached",
|
|
197
|
+
pid: started.pid,
|
|
198
|
+
port: started.port,
|
|
199
|
+
debug: Boolean(opts.debug),
|
|
200
|
+
url: `http://127.0.0.1:${started.port}`,
|
|
201
|
+
codex_home: codex?.outDir ?? null,
|
|
202
|
+
codex_export_command: codex?.exportCommand ?? null,
|
|
203
|
+
codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
|
|
204
|
+
codex_default_model: codex?.defaultModel ?? null,
|
|
205
|
+
codex_model_count: codex?.modelCount ?? null,
|
|
206
|
+
pi_home: pi?.outDir ?? null,
|
|
207
|
+
pi_config_path: pi?.configPath ?? null,
|
|
208
|
+
pi_mirror_path: pi?.mirrorPath ?? null,
|
|
209
|
+
pi_backup_path: pi?.backupPath ?? null,
|
|
210
|
+
pi_model_count: pi?.modelCount ?? null,
|
|
211
|
+
claude_export_command: claude.command,
|
|
212
|
+
claude_env: claude.bundle.env,
|
|
213
|
+
claude_defaults: claude.defaults
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Foreground path: interactively prompt for login if needed.
|
|
218
|
+
await ensureAuthenticatedInteractive();
|
|
219
|
+
const started = await runDaemon({ debug: Boolean(opts.debug) });
|
|
220
|
+
if (started.kind === "already_running") {
|
|
221
|
+
const codex = opts.codex === false ? null : await refreshCodexHome(started.lock.port, opts.codexModel ?? null);
|
|
222
|
+
const pi = opts.pi === false ? null : await refreshPiHome(started.lock.port);
|
|
223
|
+
const claude = buildClaudeExportCommand(started.lock.port, null);
|
|
224
|
+
const banner = formatStartBanner({
|
|
225
|
+
port: started.lock.port,
|
|
226
|
+
pid: started.lock.pid,
|
|
227
|
+
mode: "already_running",
|
|
228
|
+
debug: false,
|
|
229
|
+
codex,
|
|
230
|
+
pi
|
|
231
|
+
});
|
|
232
|
+
writeCommandOutput(opts, banner, {
|
|
233
|
+
status: "already_running",
|
|
234
|
+
pid: started.lock.pid,
|
|
235
|
+
port: started.lock.port,
|
|
236
|
+
url: `http://127.0.0.1:${started.lock.port}`,
|
|
237
|
+
codex_home: codex?.outDir ?? null,
|
|
238
|
+
codex_export_command: codex?.exportCommand ?? null,
|
|
239
|
+
codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
|
|
240
|
+
pi_home: pi?.outDir ?? null,
|
|
241
|
+
pi_config_path: pi?.configPath ?? null,
|
|
242
|
+
pi_mirror_path: pi?.mirrorPath ?? null,
|
|
243
|
+
pi_backup_path: pi?.backupPath ?? null,
|
|
244
|
+
pi_model_count: pi?.modelCount ?? null,
|
|
245
|
+
claude_export_command: claude.command,
|
|
246
|
+
claude_env: claude.bundle.env,
|
|
247
|
+
claude_defaults: claude.defaults
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null);
|
|
252
|
+
const pi = opts.pi === false ? null : await refreshPiHome(started.port);
|
|
253
|
+
const claude = buildClaudeExportCommand(started.port, started.callerSecret);
|
|
254
|
+
const banner = formatStartBanner({
|
|
255
|
+
port: started.port,
|
|
256
|
+
pid: process.pid,
|
|
257
|
+
mode: "foreground",
|
|
258
|
+
debug: Boolean(opts.debug),
|
|
259
|
+
codex,
|
|
260
|
+
pi
|
|
261
|
+
});
|
|
262
|
+
writeCommandOutput(opts, banner, {
|
|
263
|
+
status: "ok",
|
|
264
|
+
mode: "foreground",
|
|
265
|
+
pid: process.pid,
|
|
266
|
+
port: started.port,
|
|
267
|
+
debug: Boolean(opts.debug),
|
|
268
|
+
url: `http://127.0.0.1:${started.port}`,
|
|
269
|
+
caller_secret: started.callerSecret,
|
|
270
|
+
codex_home: codex?.outDir ?? null,
|
|
271
|
+
codex_export_command: codex?.exportCommand ?? null,
|
|
272
|
+
codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
|
|
273
|
+
codex_default_model: codex?.defaultModel ?? null,
|
|
274
|
+
codex_model_count: codex?.modelCount ?? null,
|
|
275
|
+
pi_home: pi?.outDir ?? null,
|
|
276
|
+
pi_config_path: pi?.configPath ?? null,
|
|
277
|
+
pi_mirror_path: pi?.mirrorPath ?? null,
|
|
278
|
+
pi_backup_path: pi?.backupPath ?? null,
|
|
279
|
+
pi_model_count: pi?.modelCount ?? null,
|
|
280
|
+
claude_export_command: claude.command,
|
|
281
|
+
claude_env: claude.bundle.env,
|
|
282
|
+
claude_defaults: claude.defaults
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
program
|
|
286
|
+
.command("daemon")
|
|
287
|
+
.description("Internal background command")
|
|
288
|
+
.option("--debug", "Enable debug endpoints")
|
|
289
|
+
.action(async (opts) => {
|
|
290
|
+
const started = await runDaemon({ debug: Boolean(opts.debug) });
|
|
291
|
+
if (started.kind === "already_running") {
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${opts.debug ? " [debug]" : ""}\n`);
|
|
295
|
+
});
|
|
296
|
+
program
|
|
297
|
+
.command("stop")
|
|
298
|
+
.description("Stop detached daemon")
|
|
299
|
+
.option("--json", "JSON output")
|
|
300
|
+
.action(async (opts) => {
|
|
301
|
+
const lockState = inspectLock();
|
|
302
|
+
if (lockState.state === "missing") {
|
|
303
|
+
const cache = clearClaudeGatewayCache();
|
|
304
|
+
writeCommandOutput(opts, formatStopHumanLine("Not running.", cache), {
|
|
305
|
+
status: "not_running",
|
|
306
|
+
claude_cache: cache
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (lockState.state === "stale") {
|
|
311
|
+
releaseLock();
|
|
312
|
+
const cache = clearClaudeGatewayCache();
|
|
313
|
+
writeCommandOutput(opts, formatStopHumanLine("Removed stale lock.", cache), {
|
|
314
|
+
status: "stale_lock_removed",
|
|
315
|
+
reason: lockState.reason,
|
|
316
|
+
claude_cache: cache
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
await stopByPid(lockState.lock.pid);
|
|
321
|
+
const cache = clearClaudeGatewayCache();
|
|
322
|
+
writeCommandOutput(opts, formatStopHumanLine("Stopped.", cache), {
|
|
323
|
+
status: "ok",
|
|
324
|
+
pid: lockState.lock.pid,
|
|
325
|
+
claude_cache: cache
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
program
|
|
329
|
+
.command("status")
|
|
330
|
+
.description("Show daemon status")
|
|
331
|
+
.option("--json", "JSON output")
|
|
332
|
+
.action(async (opts) => {
|
|
333
|
+
const config = loadConfig();
|
|
334
|
+
const lockState = inspectLock();
|
|
335
|
+
const checkedAtIso = new Date().toISOString();
|
|
336
|
+
const uptimeSeconds = lockState.state === "running" ? computeUptimeSeconds(lockState.lock.started_at_iso) : null;
|
|
337
|
+
// inspectStoredCredential never returns the token itself, so it's safe to
|
|
338
|
+
// include the result in the status payload.
|
|
339
|
+
let authInfo;
|
|
340
|
+
try {
|
|
341
|
+
const info = await inspectStoredCredential();
|
|
342
|
+
authInfo = { stored: info.stored, backend: info.backend, error: null };
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
const message = error instanceof Error ? error.message : "unknown_error";
|
|
346
|
+
authInfo = { stored: false, backend: null, error: message };
|
|
347
|
+
}
|
|
348
|
+
const status = {
|
|
349
|
+
running: lockState.state === "running",
|
|
350
|
+
stale: lockState.state === "stale",
|
|
351
|
+
pid: lockState.state === "running" ? lockState.lock.pid : null,
|
|
352
|
+
port: lockState.state === "running" ? lockState.lock.port : null,
|
|
353
|
+
started_at_iso: lockState.state === "running" ? lockState.lock.started_at_iso : null,
|
|
354
|
+
uptime_seconds: uptimeSeconds,
|
|
355
|
+
url: lockState.state === "running" ? `http://127.0.0.1:${lockState.lock.port}` : null,
|
|
356
|
+
require_caller_secret: config.requireCallerSecret,
|
|
357
|
+
account_type: config.accountType,
|
|
358
|
+
selected_models: config.selectedModels,
|
|
359
|
+
auth: authInfo,
|
|
360
|
+
bearer_ttl_seconds: null,
|
|
361
|
+
health_check_status_code: null,
|
|
362
|
+
health_state: null,
|
|
363
|
+
health_error: null,
|
|
364
|
+
health_status: "unknown",
|
|
365
|
+
checked_at_iso: checkedAtIso,
|
|
366
|
+
stale_reason: lockState.state === "stale" ? lockState.reason : null
|
|
367
|
+
};
|
|
368
|
+
if (lockState.state === "running") {
|
|
369
|
+
const health = await probeHealth(lockState.lock.port);
|
|
370
|
+
status.health_status = health.ok ? "ok" : "degraded";
|
|
371
|
+
status.bearer_ttl_seconds = health.bearerTtlSeconds;
|
|
372
|
+
status.health_check_status_code = health.statusCode;
|
|
373
|
+
status.health_state = health.status;
|
|
374
|
+
status.health_error = health.error;
|
|
375
|
+
}
|
|
376
|
+
if (opts.json) {
|
|
377
|
+
process.stdout.write(JSON.stringify(status, null, 2) + "\n");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (lockState.state === "running") {
|
|
381
|
+
process.stdout.write(`running (pid ${lockState.lock.pid}, port ${lockState.lock.port})\n`);
|
|
382
|
+
process.stdout.write(`health: ${status.health_status}`);
|
|
383
|
+
if (status.health_state) {
|
|
384
|
+
process.stdout.write(` (${status.health_state})`);
|
|
385
|
+
}
|
|
386
|
+
if (status.health_check_status_code !== null) {
|
|
387
|
+
process.stdout.write(` [http ${status.health_check_status_code}]`);
|
|
388
|
+
}
|
|
389
|
+
if (status.health_error) {
|
|
390
|
+
process.stdout.write(` error=${status.health_error}`);
|
|
391
|
+
}
|
|
392
|
+
process.stdout.write("\n");
|
|
393
|
+
if (status.bearer_ttl_seconds !== null) {
|
|
394
|
+
process.stdout.write(`bearer_ttl_seconds: ${status.bearer_ttl_seconds}\n`);
|
|
395
|
+
}
|
|
396
|
+
if (status.uptime_seconds !== null) {
|
|
397
|
+
process.stdout.write(`uptime_seconds: ${status.uptime_seconds}\n`);
|
|
398
|
+
}
|
|
399
|
+
writeAuthStatusLine(authInfo);
|
|
400
|
+
process.stdout.write(`checked_at: ${status.checked_at_iso}\n`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (lockState.state === "stale") {
|
|
404
|
+
process.stdout.write(`stale lock (${lockState.reason})\n`);
|
|
405
|
+
writeAuthStatusLine(authInfo);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
process.stdout.write("not running\n");
|
|
409
|
+
writeAuthStatusLine(authInfo);
|
|
410
|
+
});
|
|
411
|
+
function writeAuthStatusLine(authInfo) {
|
|
412
|
+
if (authInfo.error) {
|
|
413
|
+
process.stdout.write(`auth: error (${authInfo.error})\n`);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (authInfo.stored) {
|
|
417
|
+
process.stdout.write(`auth: logged in (${describeBackend(authInfo.backend)})\n`);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
process.stdout.write("auth: not logged in\n");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
program
|
|
424
|
+
.command("health")
|
|
425
|
+
.description("Check health endpoint")
|
|
426
|
+
.option("--json", "JSON output")
|
|
427
|
+
.action(async (opts) => {
|
|
428
|
+
const lockState = inspectLock();
|
|
429
|
+
if (lockState.state !== "running") {
|
|
430
|
+
const payload = {
|
|
431
|
+
ok: false,
|
|
432
|
+
status: lockState.state === "stale" ? "stale_lock" : "not_running",
|
|
433
|
+
detail: lockState.state === "stale" ? lockState.reason : "Daemon is not running."
|
|
434
|
+
};
|
|
435
|
+
writeHealthOutput(opts, payload);
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const response = await fetch(`http://127.0.0.1:${lockState.lock.port}/healthz`, { signal: AbortSignal.timeout(2_000) });
|
|
440
|
+
const payload = (await response.json());
|
|
441
|
+
const output = { ok: response.ok, status_code: response.status, ...payload };
|
|
442
|
+
writeHealthOutput(opts, output);
|
|
443
|
+
if (!response.ok) {
|
|
444
|
+
process.exitCode = 1;
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
const models = program.command("models").description("Model commands");
|
|
448
|
+
models
|
|
449
|
+
.command("list")
|
|
450
|
+
.description("List entitled models")
|
|
451
|
+
.option("--json", "JSON output")
|
|
452
|
+
.action(async (opts) => {
|
|
453
|
+
const config = loadConfig();
|
|
454
|
+
const creds = await loadStoredCredential();
|
|
455
|
+
if (!creds) {
|
|
456
|
+
throw new Error("Not authenticated. Run `copillm login`.");
|
|
457
|
+
}
|
|
458
|
+
const tokenManager = new CopilotTokenManager(creds.token);
|
|
459
|
+
await tokenManager.ensureToken(false);
|
|
460
|
+
const result = await listModels(config.accountType, creds.token);
|
|
461
|
+
if (opts.json) {
|
|
462
|
+
process.stdout.write(JSON.stringify({
|
|
463
|
+
models: result.models,
|
|
464
|
+
discovery: {
|
|
465
|
+
source: result.source,
|
|
466
|
+
stale: result.stale,
|
|
467
|
+
cache_age_seconds: result.cacheAgeSeconds,
|
|
468
|
+
warning: result.warning
|
|
469
|
+
}
|
|
470
|
+
}, null, 2) + "\n");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
process.stdout.write(result.models.map((model) => model.id).join("\n") + "\n");
|
|
474
|
+
if (result.stale) {
|
|
475
|
+
process.stdout.write("⚠ using stale model snapshot (upstream discovery unavailable)\n");
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
models
|
|
479
|
+
.command("select")
|
|
480
|
+
.requiredOption("--models <ids>", "Comma-separated model ids")
|
|
481
|
+
.description("Select exposed models")
|
|
482
|
+
.option("--json", "JSON output")
|
|
483
|
+
.action(async (opts) => {
|
|
484
|
+
const config = loadConfig();
|
|
485
|
+
const requested = Array.from(new Set(opts.models
|
|
486
|
+
.split(",")
|
|
487
|
+
.map((value) => value.trim())
|
|
488
|
+
.filter((value) => value.length > 0)));
|
|
489
|
+
if (requested.length === 0) {
|
|
490
|
+
throw new Error("At least one model must be selected.");
|
|
491
|
+
}
|
|
492
|
+
const creds = await loadStoredCredential();
|
|
493
|
+
if (!creds) {
|
|
494
|
+
throw new Error("Not authenticated. Run `copillm login`.");
|
|
495
|
+
}
|
|
496
|
+
const tokenManager = new CopilotTokenManager(creds.token);
|
|
497
|
+
await tokenManager.ensureToken(false);
|
|
498
|
+
const discovery = await listModels(config.accountType, creds.token);
|
|
499
|
+
const resolution = resolveModelSelections(requested, discovery.models);
|
|
500
|
+
if (resolution.unresolved.length > 0) {
|
|
501
|
+
const available = discovery.models.map((model) => model.id).join(", ");
|
|
502
|
+
throw new Error(`Unknown model selection(s): ${resolution.unresolved.join(", ")}. Available models: ${available}`);
|
|
503
|
+
}
|
|
504
|
+
const resolvedSelected = Array.from(new Set(resolution.resolved.map((entry) => entry.resolvedId)));
|
|
505
|
+
saveConfig({ ...config, selectedModels: resolvedSelected });
|
|
506
|
+
const usedAlias = resolution.resolved.some((entry) => entry.input !== entry.resolvedId);
|
|
507
|
+
writeCommandOutput(opts, `Selected ${resolvedSelected.length} model(s)${usedAlias ? " (resolved aliases)." : "."}${discovery.stale ? " Using stale snapshot." : ""}`, {
|
|
508
|
+
status: "ok",
|
|
509
|
+
selected_models: resolvedSelected,
|
|
510
|
+
requested_models: requested,
|
|
511
|
+
resolutions: resolution.resolved,
|
|
512
|
+
discovery: {
|
|
513
|
+
source: discovery.source,
|
|
514
|
+
stale: discovery.stale,
|
|
515
|
+
cache_age_seconds: discovery.cacheAgeSeconds,
|
|
516
|
+
warning: discovery.warning
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
program
|
|
521
|
+
.command("env <agent>")
|
|
522
|
+
.description("Print env vars to launch codex, claude, or pi against copillm")
|
|
523
|
+
.option("--shell <shell>", "Shell syntax: sh|fish|powershell", "sh")
|
|
524
|
+
.option("--json", "JSON output")
|
|
525
|
+
.option("--inline", "Single-line legacy export form (claude only)")
|
|
526
|
+
.action(async (agentRaw, opts) => {
|
|
527
|
+
const agent = parseAgentName(agentRaw);
|
|
528
|
+
if (!isShellSyntax(opts.shell)) {
|
|
529
|
+
throw new Error(`Unsupported --shell value: ${opts.shell}. Use sh, fish, or powershell.`);
|
|
530
|
+
}
|
|
531
|
+
const shell = opts.shell;
|
|
532
|
+
const lockState = inspectLock();
|
|
533
|
+
if (lockState.state !== "running") {
|
|
534
|
+
const message = lockState.state === "stale"
|
|
535
|
+
? `copillm has a stale lock (${lockState.reason}). Run \`copillm stop\` then \`copillm start --detach\`.`
|
|
536
|
+
: "copillm is not running. Run `copillm start --detach` first.";
|
|
537
|
+
if (opts.json) {
|
|
538
|
+
process.stdout.write(JSON.stringify({ status: "not_running", agent, error: message }, null, 2) + "\n");
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
process.stderr.write(`${message}\n`);
|
|
542
|
+
}
|
|
543
|
+
process.exit(2);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (agent === "codex") {
|
|
547
|
+
const codex = await refreshCodexHome(lockState.lock.port, null);
|
|
548
|
+
if (!codex) {
|
|
549
|
+
throw new Error("Failed to prepare Codex home (see warning above).");
|
|
550
|
+
}
|
|
551
|
+
const bundle = buildCodexEnvBundle(codex.outDir);
|
|
552
|
+
const block = renderEnvBlock({
|
|
553
|
+
agent: "codex",
|
|
554
|
+
env: bundle.env,
|
|
555
|
+
shell,
|
|
556
|
+
inlineComments: bundle.inlineComments,
|
|
557
|
+
trailingNotes: bundle.trailingNotes
|
|
558
|
+
});
|
|
559
|
+
if (opts.json) {
|
|
560
|
+
process.stdout.write(JSON.stringify({
|
|
561
|
+
agent: "codex",
|
|
562
|
+
package: "@openai/codex",
|
|
563
|
+
shell,
|
|
564
|
+
env: bundle.env,
|
|
565
|
+
shell_block: block
|
|
566
|
+
}, null, 2) + "\n");
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
process.stdout.write(`${block}\n`);
|
|
570
|
+
}
|
|
571
|
+
process.exit(0);
|
|
572
|
+
}
|
|
573
|
+
if (agent === "pi") {
|
|
574
|
+
const pi = await refreshPiHome(lockState.lock.port);
|
|
575
|
+
if (!pi) {
|
|
576
|
+
throw new Error("Failed to prepare pi models.json (see warning above).");
|
|
577
|
+
}
|
|
578
|
+
const bundle = buildPiEnvBundle(pi.outDir);
|
|
579
|
+
const block = renderEnvBlock({
|
|
580
|
+
agent: "pi",
|
|
581
|
+
env: bundle.env,
|
|
582
|
+
shell,
|
|
583
|
+
inlineComments: bundle.inlineComments,
|
|
584
|
+
trailingNotes: bundle.trailingNotes
|
|
585
|
+
});
|
|
586
|
+
if (opts.json) {
|
|
587
|
+
process.stdout.write(JSON.stringify({
|
|
588
|
+
agent: "pi",
|
|
589
|
+
package: "@earendil-works/pi-coding-agent",
|
|
590
|
+
shell,
|
|
591
|
+
env: bundle.env,
|
|
592
|
+
shell_block: block,
|
|
593
|
+
pi_home: pi.outDir,
|
|
594
|
+
pi_config_path: pi.configPath,
|
|
595
|
+
pi_mirror_path: pi.mirrorPath,
|
|
596
|
+
pi_backup_path: pi.backupPath,
|
|
597
|
+
pi_model_count: pi.modelCount
|
|
598
|
+
}, null, 2) + "\n");
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
process.stdout.write(`${block}\n`);
|
|
602
|
+
}
|
|
603
|
+
process.exit(0);
|
|
604
|
+
}
|
|
605
|
+
const claude = buildClaudeExportCommand(lockState.lock.port, null);
|
|
606
|
+
const settingsConflicts = detectClaudeSettingsConflicts(claude.bundle.env);
|
|
607
|
+
if (opts.inline) {
|
|
608
|
+
if (opts.json) {
|
|
609
|
+
process.stdout.write(JSON.stringify({ agent: "claude", inline: claude.command }, null, 2) + "\n");
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
process.stdout.write(`${claude.command}\n`);
|
|
613
|
+
}
|
|
614
|
+
for (const line of formatSettingsConflictWarning(settingsConflicts)) {
|
|
615
|
+
process.stderr.write(`${line}\n`);
|
|
616
|
+
}
|
|
617
|
+
process.exit(0);
|
|
618
|
+
}
|
|
619
|
+
const block = renderEnvBlock({
|
|
620
|
+
agent: "claude",
|
|
621
|
+
env: claude.bundle.env,
|
|
622
|
+
shell,
|
|
623
|
+
inlineComments: claude.bundle.inlineComments,
|
|
624
|
+
trailingNotes: claude.bundle.trailingNotes
|
|
625
|
+
});
|
|
626
|
+
if (opts.json) {
|
|
627
|
+
process.stdout.write(JSON.stringify({
|
|
628
|
+
agent: "claude",
|
|
629
|
+
package: "@anthropic-ai/claude-code",
|
|
630
|
+
shell,
|
|
631
|
+
env: claude.bundle.env,
|
|
632
|
+
shell_block: block,
|
|
633
|
+
defaults: claude.defaults,
|
|
634
|
+
settings_conflicts: settingsConflicts.conflicts
|
|
635
|
+
}, null, 2) + "\n");
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
process.stdout.write(`${block}\n`);
|
|
639
|
+
}
|
|
640
|
+
for (const line of formatSettingsConflictWarning(settingsConflicts)) {
|
|
641
|
+
process.stderr.write(`${line}\n`);
|
|
642
|
+
}
|
|
643
|
+
process.exit(0);
|
|
644
|
+
});
|
|
645
|
+
program
|
|
646
|
+
.command("codex")
|
|
647
|
+
.description("Launch Codex CLI against copillm (auto-starts daemon, downloads codex if missing)")
|
|
648
|
+
.option("--copillm-use <spec>", "Pin codex package version (e.g. 1.4.7 or @openai/codex@1.4.7)")
|
|
649
|
+
.option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
|
|
650
|
+
.option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
|
|
651
|
+
.option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
|
|
652
|
+
.allowUnknownOption(true)
|
|
653
|
+
.passThroughOptions()
|
|
654
|
+
.helpOption(false)
|
|
655
|
+
.argument("[args...]", "Args forwarded to codex")
|
|
656
|
+
.action(async (forwardedArgs, opts) => {
|
|
657
|
+
const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
|
|
658
|
+
const codex = await refreshCodexHome(lock.port, null);
|
|
659
|
+
if (!codex) {
|
|
660
|
+
throw new Error("Failed to prepare Codex home (see warning above).");
|
|
661
|
+
}
|
|
662
|
+
const bundle = buildCodexEnvBundle(codex.outDir);
|
|
663
|
+
const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CODEX_VERSION ?? undefined;
|
|
664
|
+
const applyResult = applyAgentConfig({
|
|
665
|
+
agent: "codex",
|
|
666
|
+
cwd: process.cwd(),
|
|
667
|
+
codexHomeDir: codex.outDir,
|
|
668
|
+
profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
|
|
669
|
+
skip: Boolean(opts.copillmNoConfig)
|
|
670
|
+
});
|
|
671
|
+
for (const line of formatApplyNotes(applyResult, "codex")) {
|
|
672
|
+
process.stderr.write(`${line}\n`);
|
|
673
|
+
}
|
|
674
|
+
const env = { ...bundle.env, ...applyResult.envOverlay };
|
|
675
|
+
const exitCode = await launchAgent({
|
|
676
|
+
agent: "codex",
|
|
677
|
+
args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
|
|
678
|
+
env,
|
|
679
|
+
pinnedSpec
|
|
680
|
+
});
|
|
681
|
+
process.exit(exitCode);
|
|
682
|
+
});
|
|
683
|
+
program
|
|
684
|
+
.command("claude")
|
|
685
|
+
.description("Launch Claude Code against copillm (auto-starts daemon, downloads claude if missing)")
|
|
686
|
+
.option("--copillm-use <spec>", "Pin claude package version (e.g. 1.0.0 or @anthropic-ai/claude-code@1.0.0)")
|
|
687
|
+
.option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
|
|
688
|
+
.option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
|
|
689
|
+
.option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
|
|
690
|
+
.allowUnknownOption(true)
|
|
691
|
+
.passThroughOptions()
|
|
692
|
+
.helpOption(false)
|
|
693
|
+
.argument("[args...]", "Args forwarded to claude")
|
|
694
|
+
.action(async (forwardedArgs, opts) => {
|
|
695
|
+
const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
|
|
696
|
+
const claude = buildClaudeExportCommand(lock.port, null);
|
|
697
|
+
const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CLAUDE_VERSION ?? undefined;
|
|
698
|
+
const conflicts = detectClaudeSettingsConflicts(claude.bundle.env);
|
|
699
|
+
for (const line of formatSettingsConflictWarning(conflicts)) {
|
|
700
|
+
process.stderr.write(`${line}\n`);
|
|
701
|
+
}
|
|
702
|
+
const applyResult = applyAgentConfig({
|
|
703
|
+
agent: "claude",
|
|
704
|
+
cwd: process.cwd(),
|
|
705
|
+
profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
|
|
706
|
+
skip: Boolean(opts.copillmNoConfig)
|
|
707
|
+
});
|
|
708
|
+
for (const line of formatApplyNotes(applyResult, "claude")) {
|
|
709
|
+
process.stderr.write(`${line}\n`);
|
|
710
|
+
}
|
|
711
|
+
const env = { ...claude.bundle.env, ...applyResult.envOverlay };
|
|
712
|
+
const exitCode = await launchAgent({
|
|
713
|
+
agent: "claude",
|
|
714
|
+
args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
|
|
715
|
+
env,
|
|
716
|
+
pinnedSpec
|
|
717
|
+
});
|
|
718
|
+
process.exit(exitCode);
|
|
719
|
+
});
|
|
720
|
+
program
|
|
721
|
+
.command("pi")
|
|
722
|
+
.description("Launch pi coding agent against copillm (auto-starts daemon, downloads pi if missing)")
|
|
723
|
+
.option("--copillm-use <spec>", "Pin pi package version (e.g. 0.75.4 or @earendil-works/pi-coding-agent@0.75.4)")
|
|
724
|
+
.option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
|
|
725
|
+
.option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
|
|
726
|
+
.option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
|
|
727
|
+
.allowUnknownOption(true)
|
|
728
|
+
.passThroughOptions()
|
|
729
|
+
.helpOption(false)
|
|
730
|
+
.argument("[args...]", "Args forwarded to pi")
|
|
731
|
+
.action(async (forwardedArgs, opts) => {
|
|
732
|
+
const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
|
|
733
|
+
const pi = await refreshPiHome(lock.port);
|
|
734
|
+
if (!pi) {
|
|
735
|
+
throw new Error("Failed to prepare pi models.json (see warning above).");
|
|
736
|
+
}
|
|
737
|
+
const bundle = buildPiEnvBundle(pi.outDir);
|
|
738
|
+
const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_PI_VERSION ?? undefined;
|
|
739
|
+
const applyResult = applyAgentConfig({
|
|
740
|
+
agent: "pi",
|
|
741
|
+
cwd: process.cwd(),
|
|
742
|
+
profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
|
|
743
|
+
skip: Boolean(opts.copillmNoConfig)
|
|
744
|
+
});
|
|
745
|
+
for (const line of formatApplyNotes(applyResult, "pi")) {
|
|
746
|
+
process.stderr.write(`${line}\n`);
|
|
747
|
+
}
|
|
748
|
+
const env = { ...bundle.env, ...applyResult.envOverlay };
|
|
749
|
+
const exitCode = await launchAgent({
|
|
750
|
+
agent: "pi",
|
|
751
|
+
args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
|
|
752
|
+
env,
|
|
753
|
+
pinnedSpec
|
|
754
|
+
});
|
|
755
|
+
process.exit(exitCode);
|
|
756
|
+
});
|
|
757
|
+
registerConfigCommands(program);
|
|
758
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
759
|
+
if (error instanceof Error) {
|
|
760
|
+
logger.error({ err: error }, error.message);
|
|
761
|
+
process.stderr.write(`${error.message}\n`);
|
|
762
|
+
process.exit(1);
|
|
763
|
+
}
|
|
764
|
+
throw error;
|
|
765
|
+
});
|
|
766
|
+
async function runDaemon(options) {
|
|
767
|
+
const config = loadConfig();
|
|
768
|
+
const creds = await loadStoredCredential();
|
|
769
|
+
if (!creds) {
|
|
770
|
+
throw new Error("Not authenticated. Run `copillm login` first.");
|
|
771
|
+
}
|
|
772
|
+
const tokenManager = new CopilotTokenManager(creds.token);
|
|
773
|
+
await tokenManager.ensureToken(false);
|
|
774
|
+
const callerSecret = config.requireCallerSecret ? randomUUID() : null;
|
|
775
|
+
if (callerSecret) {
|
|
776
|
+
process.stdout.write(`Caller secret: ${callerSecret}\n`);
|
|
777
|
+
}
|
|
778
|
+
const ports = candidatePorts(config.preferredPort);
|
|
779
|
+
let server = null;
|
|
780
|
+
let selectedPort = null;
|
|
781
|
+
for (const port of ports) {
|
|
782
|
+
try {
|
|
783
|
+
await acquireLock(port, { isRunning: async (lock) => probeLivez(lock.port) });
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
if (error instanceof LockAlreadyRunningError) {
|
|
787
|
+
tokenManager.clear();
|
|
788
|
+
return { kind: "already_running", lock: error.lock };
|
|
789
|
+
}
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
server = await startProxyServer({
|
|
794
|
+
port,
|
|
795
|
+
config,
|
|
796
|
+
tokenManager,
|
|
797
|
+
callerSecret,
|
|
798
|
+
logger,
|
|
799
|
+
debug: Boolean(options?.debug),
|
|
800
|
+
githubToken: creds.token
|
|
801
|
+
});
|
|
802
|
+
selectedPort = port;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
releaseLock();
|
|
807
|
+
if (isAddrInUse(error)) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (!server || selectedPort === null) {
|
|
814
|
+
tokenManager.clear();
|
|
815
|
+
throw new Error(`No available port in configured range (${ports[0]}-${ports[ports.length - 1]}).`);
|
|
816
|
+
}
|
|
817
|
+
let shuttingDown = false;
|
|
818
|
+
const shutdown = async () => {
|
|
819
|
+
if (shuttingDown) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
shuttingDown = true;
|
|
823
|
+
try {
|
|
824
|
+
await withTimeout(server.close(), 5_000, "Timed out while draining requests.");
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
logger.warn({ err: error }, "graceful shutdown timed out");
|
|
828
|
+
}
|
|
829
|
+
finally {
|
|
830
|
+
tokenManager.clear();
|
|
831
|
+
releaseLock();
|
|
832
|
+
process.exit(0);
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
process.once("SIGINT", () => {
|
|
836
|
+
void shutdown();
|
|
837
|
+
});
|
|
838
|
+
process.once("SIGTERM", () => {
|
|
839
|
+
void shutdown();
|
|
840
|
+
});
|
|
841
|
+
return { kind: "started", port: selectedPort, callerSecret };
|
|
842
|
+
}
|
|
843
|
+
function candidatePorts(preferredPort) {
|
|
844
|
+
const ports = [];
|
|
845
|
+
for (let offset = 0; offset < 10; offset += 1) {
|
|
846
|
+
const port = preferredPort + offset;
|
|
847
|
+
if (port <= 65535) {
|
|
848
|
+
ports.push(port);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return ports;
|
|
852
|
+
}
|
|
853
|
+
function describeBackend(backend) {
|
|
854
|
+
switch (backend) {
|
|
855
|
+
case "keytar":
|
|
856
|
+
return "OS keychain";
|
|
857
|
+
case "file":
|
|
858
|
+
return "credentials file";
|
|
859
|
+
case "session":
|
|
860
|
+
return "in-memory (session only)";
|
|
861
|
+
default:
|
|
862
|
+
return "no backend";
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function formatHumanAuthStatusLine(backend, identity) {
|
|
866
|
+
if (!identity) {
|
|
867
|
+
return `logged in (${describeBackend(backend)})`;
|
|
868
|
+
}
|
|
869
|
+
const nameSuffix = identity.name && identity.name !== identity.login ? ` (${identity.name})` : "";
|
|
870
|
+
return `logged in as @${identity.login}${nameSuffix} (${describeBackend(backend)})`;
|
|
871
|
+
}
|
|
872
|
+
function emitDeprecation(opts, oldCmd, newCmd) {
|
|
873
|
+
if (opts.json) {
|
|
874
|
+
// Keep stdout pristine for JSON consumers; deprecation goes to stderr.
|
|
875
|
+
process.stderr.write(`note: \`copillm ${oldCmd}\` is deprecated; use \`copillm ${newCmd}\`\n`);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
process.stderr.write(`note: \`copillm ${oldCmd}\` is deprecated; use \`copillm ${newCmd}\`\n`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async function runAuthLogin(opts, options) {
|
|
882
|
+
if (options.forceSession) {
|
|
883
|
+
process.env.COPILLM_FORCE_SESSION_BACKEND = "1";
|
|
884
|
+
}
|
|
885
|
+
const config = loadConfig();
|
|
886
|
+
const token = await loginViaDeviceFlow();
|
|
887
|
+
const saveMode = options.forceSession ? "session" : "auto";
|
|
888
|
+
const backend = await saveStoredCredential(token, config.accountType, { mode: saveMode });
|
|
889
|
+
writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
|
|
890
|
+
status: "ok",
|
|
891
|
+
action: "login",
|
|
892
|
+
credential_backend: backend
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
async function runAuthLogout(opts) {
|
|
896
|
+
const result = await clearStoredCredential();
|
|
897
|
+
const lockState = inspectLock();
|
|
898
|
+
if (lockState.state === "running") {
|
|
899
|
+
await stopByPid(lockState.lock.pid);
|
|
900
|
+
}
|
|
901
|
+
else if (lockState.state === "stale") {
|
|
902
|
+
releaseLock();
|
|
903
|
+
}
|
|
904
|
+
const credentialStatus = result.removed ? "removed" : "not present";
|
|
905
|
+
writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
|
|
906
|
+
status: "ok",
|
|
907
|
+
action: "logout",
|
|
908
|
+
credential_backend: result.backend,
|
|
909
|
+
credential_removed: result.removed
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Build the default dependency bundle for ensureAuthenticatedInteractive.
|
|
914
|
+
* Lives here (rather than inside the auth module) so the auth module stays
|
|
915
|
+
* UI-framework-agnostic and tests can supply alternative implementations.
|
|
916
|
+
*/
|
|
917
|
+
function defaultEnsureAuthDeps() {
|
|
918
|
+
return {
|
|
919
|
+
inspectStoredCredential,
|
|
920
|
+
isTty: () => process.stdin.isTTY === true,
|
|
921
|
+
confirm,
|
|
922
|
+
choose,
|
|
923
|
+
loginViaDeviceFlow,
|
|
924
|
+
loadAccountType: () => loadConfig().accountType,
|
|
925
|
+
saveStoredCredential,
|
|
926
|
+
describeBackend,
|
|
927
|
+
print: (line) => process.stdout.write(line),
|
|
928
|
+
setEnv: (key, value) => {
|
|
929
|
+
process.env[key] = value;
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
async function ensureAuthenticatedInteractive() {
|
|
934
|
+
return ensureAuthenticatedInteractiveImpl(defaultEnsureAuthDeps());
|
|
935
|
+
}
|
|
936
|
+
function writeCommandOutput(opts, humanLine, payload) {
|
|
937
|
+
if (opts.json) {
|
|
938
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
process.stdout.write(`${humanLine}\n`);
|
|
942
|
+
}
|
|
943
|
+
function formatStopHumanLine(primary, cache) {
|
|
944
|
+
if (cache.cleared) {
|
|
945
|
+
return `${primary} Cleared Claude Code gateway cache.`;
|
|
946
|
+
}
|
|
947
|
+
if (cache.reason === "not_present") {
|
|
948
|
+
return primary;
|
|
949
|
+
}
|
|
950
|
+
return `${primary} Could not clear Claude Code gateway cache: ${cache.reason ?? "unknown error"}.`;
|
|
951
|
+
}
|
|
952
|
+
function writeHealthOutput(opts, payload) {
|
|
953
|
+
if (opts.json) {
|
|
954
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
958
|
+
}
|
|
959
|
+
function isAddrInUse(error) {
|
|
960
|
+
return error instanceof Error && "code" in error && error.code === "EADDRINUSE";
|
|
961
|
+
}
|
|
962
|
+
async function refreshCodexHome(port, model) {
|
|
963
|
+
try {
|
|
964
|
+
const home = getCopillmHome();
|
|
965
|
+
return await generateCodexHome({
|
|
966
|
+
outDir: defaultOutputDir(home),
|
|
967
|
+
model,
|
|
968
|
+
port,
|
|
969
|
+
providerId: "copillm",
|
|
970
|
+
reasoningEffort: null
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
const message = error instanceof Error ? error.message : "unknown_error";
|
|
975
|
+
process.stderr.write(`warning: failed to generate ~/.copillm/codex/ — ${message}\n`);
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
async function refreshPiHome(port) {
|
|
980
|
+
try {
|
|
981
|
+
const home = getCopillmHome();
|
|
982
|
+
return await generatePiHome({
|
|
983
|
+
outDir: defaultPiOutputDir(home),
|
|
984
|
+
port,
|
|
985
|
+
providerId: "copillm"
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
catch (error) {
|
|
989
|
+
const message = error instanceof Error ? error.message : "unknown_error";
|
|
990
|
+
process.stderr.write(`warning: failed to generate pi models.json — ${message}\n`);
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function buildClaudeExportCommand(port, callerSecret) {
|
|
995
|
+
const modelIds = readModelIdsFromCache();
|
|
996
|
+
const defaults = computeAnthropicDefaults(modelIds);
|
|
997
|
+
const command = buildClaudeExport({
|
|
998
|
+
port,
|
|
999
|
+
callerSecret,
|
|
1000
|
+
defaults,
|
|
1001
|
+
enableGatewayDiscovery: true
|
|
1002
|
+
});
|
|
1003
|
+
const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true });
|
|
1004
|
+
return { command, defaults, bundle };
|
|
1005
|
+
}
|
|
1006
|
+
function formatStartBanner(input) {
|
|
1007
|
+
const verb = input.mode === "foreground" ? "listening on" : "running on";
|
|
1008
|
+
const lines = [];
|
|
1009
|
+
const debugSuffix = input.debug ? " [debug]" : "";
|
|
1010
|
+
const modeSuffix = input.mode === "already_running" ? " (already running)" : "";
|
|
1011
|
+
lines.push(`\u25CF copillm ${verb} http://127.0.0.1:${input.port} (pid ${input.pid})${debugSuffix}${modeSuffix}`);
|
|
1012
|
+
if (input.codex) {
|
|
1013
|
+
lines.push(` ${input.codex.modelCount} Copilot models discovered \u00B7 default: ${input.codex.defaultModel}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (input.pi) {
|
|
1016
|
+
lines.push(` pi: wrote ${input.pi.modelCount} models to ${displayHomePath(input.pi.configPath)}${input.pi.backupPath ? ` (backed up prior config to ${displayHomePath(input.pi.backupPath)})` : ""}`);
|
|
1017
|
+
}
|
|
1018
|
+
lines.push(``);
|
|
1019
|
+
lines.push(`Launch an agent against copillm:`);
|
|
1020
|
+
if (input.codex) {
|
|
1021
|
+
lines.push(` copillm codex # starts Codex CLI, preconfigured`);
|
|
1022
|
+
}
|
|
1023
|
+
lines.push(` copillm claude # starts Claude Code, preconfigured`);
|
|
1024
|
+
if (input.pi) {
|
|
1025
|
+
lines.push(` copillm pi # starts pi coding agent, preconfigured`);
|
|
1026
|
+
}
|
|
1027
|
+
lines.push(``);
|
|
1028
|
+
lines.push(`Or print env vars to use yourself:`);
|
|
1029
|
+
if (input.codex) {
|
|
1030
|
+
lines.push(` copillm env codex`);
|
|
1031
|
+
}
|
|
1032
|
+
lines.push(` copillm env claude`);
|
|
1033
|
+
if (input.pi) {
|
|
1034
|
+
lines.push(` copillm env pi`);
|
|
1035
|
+
}
|
|
1036
|
+
return lines.join("\n");
|
|
1037
|
+
}
|
|
1038
|
+
function displayHomePath(p) {
|
|
1039
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
1040
|
+
if (home && p.startsWith(home)) {
|
|
1041
|
+
return p.replace(home, "~");
|
|
1042
|
+
}
|
|
1043
|
+
return p;
|
|
1044
|
+
}
|
|
1045
|
+
async function probeLivez(port) {
|
|
1046
|
+
try {
|
|
1047
|
+
const response = await fetch(`http://127.0.0.1:${port}/livez`, { signal: AbortSignal.timeout(800) });
|
|
1048
|
+
return response.ok;
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async function probeHealth(port) {
|
|
1055
|
+
try {
|
|
1056
|
+
const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1_500) });
|
|
1057
|
+
const payload = (await response.json());
|
|
1058
|
+
return {
|
|
1059
|
+
ok: response.ok,
|
|
1060
|
+
statusCode: response.status,
|
|
1061
|
+
status: typeof payload.status === "string" ? payload.status : null,
|
|
1062
|
+
error: typeof payload.error === "string" ? payload.error : null,
|
|
1063
|
+
bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
return { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed" };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
async function waitForDaemonReady(pid, timeoutMs) {
|
|
1071
|
+
const startedAt = Date.now();
|
|
1072
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
1073
|
+
const lockState = inspectLock();
|
|
1074
|
+
if (lockState.state === "running" && (await probeLivez(lockState.lock.port))) {
|
|
1075
|
+
return { pid: lockState.lock.pid, port: lockState.lock.port };
|
|
1076
|
+
}
|
|
1077
|
+
if (pid !== null && !isPidAlive(pid)) {
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
await sleep(150);
|
|
1081
|
+
}
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
function isPidAlive(pid) {
|
|
1085
|
+
try {
|
|
1086
|
+
process.kill(pid, 0);
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
async function stopByPid(pid) {
|
|
1094
|
+
if (!sendSignalIfAlive(pid, "SIGTERM")) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const stopDeadline = Date.now() + 8_000;
|
|
1098
|
+
while (Date.now() < stopDeadline) {
|
|
1099
|
+
const lockState = inspectLock();
|
|
1100
|
+
if (lockState.state !== "running" || lockState.lock.pid !== pid) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
await sleep(150);
|
|
1104
|
+
}
|
|
1105
|
+
if (!sendSignalIfAlive(pid, "SIGKILL")) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const killDeadline = Date.now() + 2_000;
|
|
1109
|
+
while (Date.now() < killDeadline) {
|
|
1110
|
+
const lockState = inspectLock();
|
|
1111
|
+
if (lockState.state !== "running" || lockState.lock.pid !== pid) {
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
await sleep(100);
|
|
1115
|
+
}
|
|
1116
|
+
throw new Error(`Failed to stop daemon pid ${pid}.`);
|
|
1117
|
+
}
|
|
1118
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
1119
|
+
const timeoutPromise = sleep(timeoutMs).then(() => {
|
|
1120
|
+
throw new Error(message);
|
|
1121
|
+
});
|
|
1122
|
+
return Promise.race([promise, timeoutPromise]);
|
|
1123
|
+
}
|
|
1124
|
+
function sendSignalIfAlive(pid, signal) {
|
|
1125
|
+
try {
|
|
1126
|
+
process.kill(pid, signal);
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
catch (error) {
|
|
1130
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
throw error;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async function readLiveLock() {
|
|
1137
|
+
const lockState = inspectLock();
|
|
1138
|
+
if (lockState.state !== "running") {
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
return (await probeLivez(lockState.lock.port)) ? lockState.lock : null;
|
|
1142
|
+
}
|
|
1143
|
+
function computeUptimeSeconds(startedAtIso) {
|
|
1144
|
+
const startedMs = Date.parse(startedAtIso);
|
|
1145
|
+
if (!Number.isFinite(startedMs)) {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
return Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
|
|
1149
|
+
}
|
|
1150
|
+
function parseAgentName(raw) {
|
|
1151
|
+
const v = raw.trim().toLowerCase();
|
|
1152
|
+
if (v === "codex" || v === "claude" || v === "pi")
|
|
1153
|
+
return v;
|
|
1154
|
+
throw new Error(`Unknown agent: ${raw}. Expected "codex", "claude", or "pi".`);
|
|
1155
|
+
}
|
|
1156
|
+
async function ensureDaemonRunningForLauncher(opts) {
|
|
1157
|
+
const live = await readLiveLock();
|
|
1158
|
+
if (live)
|
|
1159
|
+
return live;
|
|
1160
|
+
process.stderr.write(`Starting copillm in background...\n`);
|
|
1161
|
+
const daemonArgs = [process.argv[1], "daemon"];
|
|
1162
|
+
if (opts.debug)
|
|
1163
|
+
daemonArgs.push("--debug");
|
|
1164
|
+
const child = spawn(process.execPath, daemonArgs, {
|
|
1165
|
+
detached: true,
|
|
1166
|
+
stdio: "ignore"
|
|
1167
|
+
});
|
|
1168
|
+
child.unref();
|
|
1169
|
+
const started = await waitForDaemonReady(child.pid ?? null, 10_000);
|
|
1170
|
+
if (!started) {
|
|
1171
|
+
throw new Error("Auto-start of copillm daemon timed out.");
|
|
1172
|
+
}
|
|
1173
|
+
const inspection = inspectLock();
|
|
1174
|
+
if (inspection.state !== "running") {
|
|
1175
|
+
throw new Error("copillm daemon failed to register a lock after auto-start.");
|
|
1176
|
+
}
|
|
1177
|
+
return inspection.lock;
|
|
1178
|
+
}
|