copillm 0.2.8 → 0.3.0-beta.1

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.
Files changed (50) hide show
  1. package/README.md +70 -2
  2. package/dist/agentconfig/load.js +9 -1
  3. package/dist/agentconfig/render.js +8 -5
  4. package/dist/agentconfig/schema.js +6 -0
  5. package/dist/auth/accountManager.js +118 -0
  6. package/dist/auth/accounts.js +161 -0
  7. package/dist/auth/copilotToken.js +92 -23
  8. package/dist/auth/credentials.js +216 -40
  9. package/dist/auth/deviceFlow.js +110 -23
  10. package/dist/auth/githubIdentity.js +14 -10
  11. package/dist/cli/agentEnv.js +15 -9
  12. package/dist/cli/auth/runAuth.js +206 -9
  13. package/dist/cli/commands/agents/claude.js +22 -2
  14. package/dist/cli/commands/agents/codex.js +22 -2
  15. package/dist/cli/commands/agents/copilot.js +25 -4
  16. package/dist/cli/commands/agents/pi.js +22 -2
  17. package/dist/cli/commands/agents/shared.js +57 -0
  18. package/dist/cli/commands/auth.js +58 -7
  19. package/dist/cli/commands/daemon.js +79 -17
  20. package/dist/cli/commands/models.js +0 -5
  21. package/dist/cli/copillmFlags.js +8 -0
  22. package/dist/cli/daemon/lifecycle.js +26 -0
  23. package/dist/cli/daemon/probes.js +99 -33
  24. package/dist/cli/daemon/runDaemon.js +21 -2
  25. package/dist/cli/index.js +12 -0
  26. package/dist/cli/integrations/claudeExport.js +6 -4
  27. package/dist/cli/integrations/refreshCodex.js +5 -2
  28. package/dist/cli/integrations/refreshPi.js +5 -2
  29. package/dist/cli/packageInfo.js +1 -1
  30. package/dist/cli/shared/devMode.js +98 -0
  31. package/dist/config/accountId.js +44 -0
  32. package/dist/config/config.js +13 -2
  33. package/dist/config/home.js +69 -0
  34. package/dist/integrations/claude/cache.js +5 -2
  35. package/dist/integrations/claude/settingsConflict.js +5 -2
  36. package/dist/integrations/codex/init.js +31 -10
  37. package/dist/integrations/pi/init.js +8 -17
  38. package/dist/models/anthropicDefaults.js +13 -4
  39. package/dist/models/discovery.js +141 -15
  40. package/dist/server/accountResolver.js +85 -0
  41. package/dist/server/debugInfo.js +69 -24
  42. package/dist/server/errors.js +18 -0
  43. package/dist/server/proxy.js +40 -8
  44. package/dist/server/routes/debug.js +11 -1
  45. package/dist/server/routes/models.js +12 -6
  46. package/dist/server/routes/proxyForward.js +3 -3
  47. package/dist/server/routes/shared.js +66 -21
  48. package/dist/server/upstream/copilotClient.js +1 -30
  49. package/dist/server/upstream/retryPolicy.js +99 -0
  50. package/package.json +4 -1
@@ -1,26 +1,93 @@
1
- import { clearStoredCredential, saveStoredCredential } from "../../auth/credentials.js";
1
+ import { clearStoredCredential, loadStoredCredential, loadStoredCredentialForAccount, registerExistingCredentialAsDefault, saveStoredCredential } from "../../auth/credentials.js";
2
+ import { readAccountsIndex, assertValidAccountId, InvalidAccountIdError, UnknownAccountError } from "../../auth/accounts.js";
3
+ import { addAccount, listAccountsDetailed, removeAccountAndCredential, removeAllAccounts, switchDefaultAccount } from "../../auth/accountManager.js";
2
4
  import { loginViaDeviceFlow } from "../../auth/deviceFlow.js";
5
+ import { inspectGithubIdentity } from "../../auth/githubIdentity.js";
3
6
  import { loadConfig } from "../../config/config.js";
4
7
  import { inspectLock, releaseLock } from "../../server/lock.js";
5
8
  import { stopByPid } from "../daemon/lifecycle.js";
6
- import { describeBackend } from "../shared/backends.js";
9
+ import { describeBackend, formatHumanAuthStatusLine } from "../shared/backends.js";
7
10
  import { writeCommandOutput } from "../shared/output.js";
11
+ /**
12
+ * Derive a friendly, path-safe account id from the GitHub login behind a token.
13
+ * Returns null when the lookup fails or the login isn't a valid id.
14
+ */
15
+ async function deriveAccountId(token) {
16
+ let identity;
17
+ try {
18
+ identity = await inspectGithubIdentity({ token });
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ const login = identity?.login;
24
+ if (!login) {
25
+ return null;
26
+ }
27
+ try {
28
+ assertValidAccountId(login);
29
+ return login;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
8
35
  export async function runAuthLogin(opts, options) {
9
36
  if (options.forceSession) {
10
37
  process.env.COPILLM_FORCE_SESSION_BACKEND = "1";
11
38
  }
12
39
  const config = loadConfig();
40
+ const accountType = opts.accountType ?? config.accountType;
41
+ const namedRequested = typeof opts.as === "string" && opts.as.trim().length > 0;
42
+ if (namedRequested) {
43
+ try {
44
+ assertValidAccountId(opts.as.trim());
45
+ }
46
+ catch (error) {
47
+ const message = error instanceof InvalidAccountIdError ? error.message : "invalid account id";
48
+ writeCommandOutput(opts, `Login failed: ${message}`, { status: "error", action: "login", error: message });
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ }
13
53
  const token = await loginViaDeviceFlow();
14
54
  const saveMode = options.forceSession ? "session" : "auto";
15
- const backend = await saveStoredCredential(token, config.accountType, { mode: saveMode });
16
- writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
55
+ const index = readAccountsIndex();
56
+ // Pure single-account login: no index and no explicit name. Preserve the
57
+ // historical behaviour exactly — legacy storage, no accounts index created.
58
+ if (!index && !namedRequested) {
59
+ const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
60
+ writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
61
+ status: "ok",
62
+ action: "login",
63
+ credential_backend: backend
64
+ });
65
+ return;
66
+ }
67
+ const accountId = namedRequested ? opts.as.trim() : index.defaultAccount;
68
+ // First time we materialize the index via an explicit name: preserve any
69
+ // pre-existing single account as the default so its token isn't clobbered.
70
+ if (!index && namedRequested) {
71
+ const existing = await loadStoredCredential();
72
+ if (existing) {
73
+ const existingId = (await deriveAccountId(existing.token)) ?? "default";
74
+ if (existingId !== accountId) {
75
+ registerExistingCredentialAsDefault(existingId, existing.accountType);
76
+ }
77
+ }
78
+ }
79
+ const result = await addAccount({ id: accountId, accountType, token, mode: saveMode });
80
+ const defaultSuffix = result.isDefault ? " (default)" : "";
81
+ writeCommandOutput(opts, `Login succeeded for account "${result.id}"${defaultSuffix}. Credentials stored via ${describeBackend(result.backend)}.`, {
17
82
  status: "ok",
18
83
  action: "login",
19
- credential_backend: backend
84
+ account: result.id,
85
+ account_type: result.accountType,
86
+ is_default: result.isDefault,
87
+ credential_backend: result.backend
20
88
  });
21
89
  }
22
- export async function runAuthLogout(opts) {
23
- const result = await clearStoredCredential();
90
+ async function stopRunningDaemon() {
24
91
  const lockState = inspectLock();
25
92
  if (lockState.state === "running") {
26
93
  await stopByPid(lockState.lock.pid);
@@ -28,11 +95,141 @@ export async function runAuthLogout(opts) {
28
95
  else if (lockState.state === "stale") {
29
96
  releaseLock();
30
97
  }
98
+ }
99
+ export async function runAuthLogout(opts) {
100
+ // Stopping the daemon is always part of logout — its in-memory bearers are
101
+ // derived from the credentials we're clearing.
102
+ if (opts.all) {
103
+ const result = await removeAllAccounts();
104
+ await stopRunningDaemon();
105
+ writeCommandOutput(opts, `Logged out of all accounts (${result.clearedCount} credential(s) cleared).`, {
106
+ status: "ok",
107
+ action: "logout",
108
+ scope: "all",
109
+ cleared_count: result.clearedCount,
110
+ removed_accounts: result.removedAccountIds
111
+ });
112
+ return;
113
+ }
114
+ const index = readAccountsIndex();
115
+ // Single-account install (no index) and no explicit target: preserve the
116
+ // historical single-account logout behaviour.
117
+ if (!index && !opts.account) {
118
+ const result = await clearStoredCredential();
119
+ await stopRunningDaemon();
120
+ const credentialStatus = result.removed ? "removed" : "not present";
121
+ writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
122
+ status: "ok",
123
+ action: "logout",
124
+ credential_backend: result.backend,
125
+ credential_removed: result.removed
126
+ });
127
+ return;
128
+ }
129
+ if (opts.account) {
130
+ try {
131
+ assertValidAccountId(opts.account);
132
+ }
133
+ catch (error) {
134
+ const message = error instanceof InvalidAccountIdError ? error.message : "invalid account id";
135
+ writeCommandOutput(opts, `Logout failed: ${message}`, { status: "error", action: "logout", error: message });
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ }
140
+ const targetId = opts.account ?? index.defaultAccount;
141
+ const result = await removeAccountAndCredential(targetId);
142
+ await stopRunningDaemon();
31
143
  const credentialStatus = result.removed ? "removed" : "not present";
32
- writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
144
+ const tail = result.indexDeleted
145
+ ? " No accounts remain."
146
+ : result.newDefault
147
+ ? ` Default is now "${result.newDefault}".`
148
+ : "";
149
+ writeCommandOutput(opts, `Logged out of account "${result.id}". Credentials ${credentialStatus} from ${describeBackend(result.backend)}.${tail}`, {
33
150
  status: "ok",
34
151
  action: "logout",
152
+ account: result.id,
35
153
  credential_backend: result.backend,
36
- credential_removed: result.removed
154
+ credential_removed: result.removed,
155
+ new_default: result.newDefault,
156
+ index_deleted: result.indexDeleted
37
157
  });
38
158
  }
159
+ export async function runAuthSwitch(opts, accountId) {
160
+ try {
161
+ assertValidAccountId(accountId);
162
+ const index = switchDefaultAccount(accountId);
163
+ writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".`, {
164
+ status: "ok",
165
+ action: "switch",
166
+ default_account: index.defaultAccount
167
+ });
168
+ }
169
+ catch (error) {
170
+ const message = error instanceof UnknownAccountError
171
+ ? `Unknown account "${accountId}". Run \`copillm auth status\` to list accounts.`
172
+ : error instanceof InvalidAccountIdError
173
+ ? error.message
174
+ : error instanceof Error
175
+ ? error.message
176
+ : "switch failed";
177
+ writeCommandOutput(opts, `Switch failed: ${message}`, { status: "error", action: "switch", error: message });
178
+ process.exitCode = 1;
179
+ }
180
+ }
181
+ /**
182
+ * Multi-account `auth status` listing (used when an accounts index exists).
183
+ * Returns whether any account has a stored credential so the caller can pick
184
+ * the process exit code. Never prints a token.
185
+ */
186
+ export async function runAuthStatusList(opts) {
187
+ const wantUser = opts.user !== false;
188
+ const listing = await listAccountsDetailed();
189
+ const anyStored = listing.accounts.some((account) => account.stored);
190
+ const enriched = await Promise.all(listing.accounts.map(async (account) => {
191
+ let login = null;
192
+ let name = null;
193
+ if (wantUser && account.stored) {
194
+ try {
195
+ const credential = await loadStoredCredentialForAccount(account.id);
196
+ if (credential) {
197
+ const identity = await inspectGithubIdentity({ token: credential.token });
198
+ login = identity?.login ?? null;
199
+ name = identity?.name ?? null;
200
+ }
201
+ }
202
+ catch {
203
+ login = null;
204
+ name = null;
205
+ }
206
+ }
207
+ return { ...account, login, name };
208
+ }));
209
+ if (opts.json) {
210
+ process.stdout.write(JSON.stringify({
211
+ status: anyStored ? "logged_in" : "logged_out",
212
+ default: listing.defaultAccount,
213
+ accounts: enriched.map((account) => ({
214
+ id: account.id,
215
+ account_type: account.accountType,
216
+ storage: account.storage,
217
+ default: account.isDefault,
218
+ stored: account.stored,
219
+ backend: account.backend,
220
+ user: account.login ? { login: account.login, name: account.name } : null
221
+ }))
222
+ }, null, 2) + "\n");
223
+ return { anyStored };
224
+ }
225
+ process.stdout.write(`copillm — ${enriched.length} account(s)\n`);
226
+ for (const account of enriched) {
227
+ const marker = account.isDefault ? "*" : " ";
228
+ const who = account.login ? ` @${account.login}` : "";
229
+ const state = account.stored
230
+ ? formatHumanAuthStatusLine(account.backend, account.login ? { login: account.login, name: account.name } : null)
231
+ : "no credential";
232
+ process.stdout.write(`${marker} ${account.id} [${account.accountType}]${who} — ${state}\n`);
233
+ }
234
+ return { anyStored };
235
+ }
@@ -5,7 +5,7 @@ import { ensureDaemonRunningForLauncher } from "../../daemon/ensureRunning.js";
5
5
  import { launchAgent } from "../../launchAgent.js";
6
6
  import { buildClaudeExportCommand } from "../../integrations/claudeExport.js";
7
7
  import { enableRuntimeDebug, resolveCopillmDebug } from "../../shared/debug.js";
8
- import { applyYoloForLaunch } from "./shared.js";
8
+ import { applyYoloForLaunch, formatLaunchAccountNotice, resolveLaunchAccount } from "./shared.js";
9
9
  export function register(program) {
10
10
  program
11
11
  .command("claude")
@@ -16,9 +16,29 @@ export function register(program) {
16
16
  .action(async (forwardedArgs) => {
17
17
  const { opts, forwarded } = processCopillmArgs(forwardedArgs ?? []);
18
18
  const debug = resolveCopillmDebug(opts.copillmDebug);
19
+ let launchAccount;
20
+ try {
21
+ launchAccount = await resolveLaunchAccount({
22
+ flag: opts.copillmAccount,
23
+ envValue: process.env.COPILLM_ACCOUNT,
24
+ cwd: process.cwd(),
25
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null
26
+ });
27
+ }
28
+ catch (error) {
29
+ process.stderr.write(`copillm: ${error instanceof Error ? error.message : String(error)}\n`);
30
+ process.exit(1);
31
+ return;
32
+ }
33
+ if (launchAccount) {
34
+ process.stderr.write(`${formatLaunchAccountNotice(launchAccount)}\n`);
35
+ }
19
36
  enableRuntimeDebug(debug);
20
37
  const lock = await ensureDaemonRunningForLauncher({ debug });
21
- const claude = buildClaudeExportCommand(lock.port, null);
38
+ const claude = buildClaudeExportCommand(lock.port, null, {
39
+ pathPrefix: launchAccount?.pathPrefix,
40
+ cacheId: launchAccount?.cacheId
41
+ });
22
42
  const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CLAUDE_VERSION ?? undefined;
23
43
  const conflicts = detectClaudeSettingsConflicts(claude.bundle.env);
24
44
  for (const line of formatSettingsConflictWarning(conflicts)) {
@@ -5,7 +5,7 @@ import { ensureDaemonRunningForLauncher } from "../../daemon/ensureRunning.js";
5
5
  import { launchAgent } from "../../launchAgent.js";
6
6
  import { refreshCodexHome } from "../../integrations/refreshCodex.js";
7
7
  import { enableRuntimeDebug, resolveCopillmDebug } from "../../shared/debug.js";
8
- import { applyYoloForLaunch } from "./shared.js";
8
+ import { applyYoloForLaunch, formatLaunchAccountNotice, resolveLaunchAccount } from "./shared.js";
9
9
  export function register(program) {
10
10
  program
11
11
  .command("codex")
@@ -16,9 +16,29 @@ export function register(program) {
16
16
  .action(async (forwardedArgs) => {
17
17
  const { opts, forwarded } = processCopillmArgs(forwardedArgs ?? []);
18
18
  const debug = resolveCopillmDebug(opts.copillmDebug);
19
+ let launchAccount;
20
+ try {
21
+ launchAccount = await resolveLaunchAccount({
22
+ flag: opts.copillmAccount,
23
+ envValue: process.env.COPILLM_ACCOUNT,
24
+ cwd: process.cwd(),
25
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null
26
+ });
27
+ }
28
+ catch (error) {
29
+ process.stderr.write(`copillm: ${error instanceof Error ? error.message : String(error)}\n`);
30
+ process.exit(1);
31
+ return;
32
+ }
33
+ if (launchAccount) {
34
+ process.stderr.write(`${formatLaunchAccountNotice(launchAccount)}\n`);
35
+ }
19
36
  enableRuntimeDebug(debug);
20
37
  const lock = await ensureDaemonRunningForLauncher({ debug });
21
- const codex = await refreshCodexHome(lock.port, null);
38
+ const codex = await refreshCodexHome(lock.port, null, undefined, {
39
+ pathPrefix: launchAccount?.pathPrefix,
40
+ account: launchAccount?.account
41
+ });
22
42
  if (!codex) {
23
43
  throw new Error("Failed to prepare Codex home (see warning above).");
24
44
  }
@@ -2,7 +2,7 @@ import { applyAgentConfig, formatApplyNotes } from "../../../agentconfig/apply.j
2
2
  import { loadStoredCredential } from "../../../auth/credentials.js";
3
3
  import { processCopillmArgs } from "../../copillmFlags.js";
4
4
  import { launchAgent } from "../../launchAgent.js";
5
- import { applyYoloForLaunch } from "./shared.js";
5
+ import { applyYoloForLaunch, formatLaunchAccountNotice, resolveLaunchAccount } from "./shared.js";
6
6
  export function register(program) {
7
7
  program
8
8
  .command("copilot")
@@ -12,8 +12,29 @@ export function register(program) {
12
12
  .argument("[args...]", "Args forwarded to copilot")
13
13
  .action(async (forwardedArgs) => {
14
14
  const { opts, forwarded } = processCopillmArgs(forwardedArgs ?? []);
15
- const credential = await loadStoredCredential();
16
- if (!credential) {
15
+ let launchAccount;
16
+ try {
17
+ launchAccount = await resolveLaunchAccount({
18
+ flag: opts.copillmAccount,
19
+ envValue: process.env.COPILLM_ACCOUNT,
20
+ cwd: process.cwd(),
21
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null
22
+ });
23
+ }
24
+ catch (error) {
25
+ process.stderr.write(`copillm: ${error instanceof Error ? error.message : String(error)}\n`);
26
+ process.exit(1);
27
+ return;
28
+ }
29
+ if (launchAccount) {
30
+ process.stderr.write(`${formatLaunchAccountNotice(launchAccount)}\n`);
31
+ }
32
+ // Copilot CLI talks to GitHub directly with the account's OAuth token,
33
+ // so account selection picks which token to inject (not a URL prefix).
34
+ const githubToken = launchAccount
35
+ ? launchAccount.account.githubToken
36
+ : (await loadStoredCredential())?.token ?? null;
37
+ if (!githubToken) {
17
38
  process.stderr.write("copillm: no stored GitHub credential — run `copillm auth login` first.\n");
18
39
  process.exit(1);
19
40
  return;
@@ -34,7 +55,7 @@ export function register(program) {
34
55
  // short-circuits its device-flow login when copillm already has a token.
35
56
  const env = {
36
57
  ...applyResult.envOverlay,
37
- COPILOT_GITHUB_TOKEN: credential.token
58
+ COPILOT_GITHUB_TOKEN: githubToken
38
59
  };
39
60
  const baseArgs = [...forwarded, ...applyResult.cliArgs];
40
61
  const args = applyYoloForLaunch({ agent: "copilot", flag: opts.yolo, applyResult, baseArgs });
@@ -5,7 +5,7 @@ import { ensureDaemonRunningForLauncher } from "../../daemon/ensureRunning.js";
5
5
  import { launchAgent } from "../../launchAgent.js";
6
6
  import { refreshPiHome } from "../../integrations/refreshPi.js";
7
7
  import { enableRuntimeDebug, resolveCopillmDebug } from "../../shared/debug.js";
8
- import { applyYoloForLaunch } from "./shared.js";
8
+ import { applyYoloForLaunch, formatLaunchAccountNotice, resolveLaunchAccount } from "./shared.js";
9
9
  export function register(program) {
10
10
  program
11
11
  .command("pi")
@@ -16,9 +16,29 @@ export function register(program) {
16
16
  .action(async (forwardedArgs) => {
17
17
  const { opts, forwarded } = processCopillmArgs(forwardedArgs ?? []);
18
18
  const debug = resolveCopillmDebug(opts.copillmDebug);
19
+ let launchAccount;
20
+ try {
21
+ launchAccount = await resolveLaunchAccount({
22
+ flag: opts.copillmAccount,
23
+ envValue: process.env.COPILLM_ACCOUNT,
24
+ cwd: process.cwd(),
25
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null
26
+ });
27
+ }
28
+ catch (error) {
29
+ process.stderr.write(`copillm: ${error instanceof Error ? error.message : String(error)}\n`);
30
+ process.exit(1);
31
+ return;
32
+ }
33
+ if (launchAccount) {
34
+ process.stderr.write(`${formatLaunchAccountNotice(launchAccount)}\n`);
35
+ }
19
36
  enableRuntimeDebug(debug);
20
37
  const lock = await ensureDaemonRunningForLauncher({ debug });
21
- const pi = await refreshPiHome(lock.port);
38
+ const pi = await refreshPiHome(lock.port, undefined, {
39
+ pathPrefix: launchAccount?.pathPrefix,
40
+ account: launchAccount?.account
41
+ });
22
42
  if (!pi) {
23
43
  throw new Error("Failed to prepare pi models.json (see warning above).");
24
44
  }
@@ -1,4 +1,61 @@
1
1
  import { applyYolo, resolveYoloWithSource } from "../../../agents/registry.js";
2
+ import { loadAgentConfig } from "../../../agentconfig/load.js";
3
+ import { findAccount } from "../../../auth/accounts.js";
4
+ import { assertValidAccountId } from "../../../config/accountId.js";
5
+ import { loadStoredCredentialForAccount } from "../../../auth/credentials.js";
6
+ /**
7
+ * Resolve which account a launch targets, applying precedence
8
+ * `--account` > `COPILLM_ACCOUNT` > the active profile's `account` > default.
9
+ * Returns null for the default account (no prefix — today's behaviour).
10
+ *
11
+ * Throws a user-facing Error when a requested account is malformed, not
12
+ * registered, or has no stored credential, so the launcher can fail fast
13
+ * before starting the daemon or the agent.
14
+ */
15
+ export async function resolveLaunchAccount(input) {
16
+ let requested;
17
+ let source;
18
+ if (input.flag && input.flag.trim().length > 0) {
19
+ requested = input.flag.trim();
20
+ source = "flag";
21
+ }
22
+ else if (input.envValue && input.envValue.trim().length > 0) {
23
+ requested = input.envValue.trim();
24
+ source = "env";
25
+ }
26
+ else {
27
+ const config = loadAgentConfig({ cwd: input.cwd, profileOverride: input.profileOverride });
28
+ const pinned = config?.resolved.account ?? null;
29
+ if (pinned) {
30
+ requested = pinned;
31
+ source = "profile";
32
+ }
33
+ }
34
+ if (!requested) {
35
+ return null;
36
+ }
37
+ assertValidAccountId(requested);
38
+ const record = findAccount(requested);
39
+ if (!record) {
40
+ throw new Error(`Unknown account "${requested}". Run \`copillm auth status\` to list accounts.`);
41
+ }
42
+ const credential = await loadStoredCredentialForAccount(requested);
43
+ if (!credential) {
44
+ throw new Error(`No stored credential for account "${requested}". Run \`copillm auth login --as ${requested}\`.`);
45
+ }
46
+ const cacheId = record.storage === "legacy" ? undefined : requested;
47
+ return {
48
+ accountId: requested,
49
+ pathPrefix: `/${requested}`,
50
+ cacheId,
51
+ account: { accountType: credential.accountType, githubToken: credential.token, cacheId },
52
+ source: source
53
+ };
54
+ }
55
+ export function formatLaunchAccountNotice(resolved) {
56
+ const from = resolved.source === "flag" ? "--account" : resolved.source === "env" ? "COPILLM_ACCOUNT" : "profile";
57
+ return `copillm: using account "${resolved.accountId}" (from ${from})`;
58
+ }
2
59
  /**
3
60
  * Shared yolo wiring for the four agent subcommands. Resolves precedence
4
61
  * (flag > env > profile > defaults > off), runs `applyYolo` with source
@@ -1,9 +1,17 @@
1
- import { inspectStoredCredential } from "../../auth/credentials.js";
1
+ import { inspectStoredCredential, loadStoredCredentialForStatus } from "../../auth/credentials.js";
2
+ import { readAccountsIndex } from "../../auth/accounts.js";
2
3
  import { inspectGithubIdentity } from "../../auth/githubIdentity.js";
3
4
  import { ensureAuthenticatedInteractive } from "../auth/ensure.js";
4
- import { runAuthLogin, runAuthLogout } from "../auth/runAuth.js";
5
+ import { runAuthLogin, runAuthLogout, runAuthStatusList, runAuthSwitch } from "../auth/runAuth.js";
5
6
  import { formatHumanAuthStatusLine } from "../shared/backends.js";
6
7
  import { emitDeprecation } from "../shared/deprecation.js";
8
+ const ACCOUNT_TYPES = ["individual", "business", "enterprise"];
9
+ function parseAccountType(value) {
10
+ if (ACCOUNT_TYPES.includes(value)) {
11
+ return value;
12
+ }
13
+ throw new Error(`Invalid --account-type "${value}". Expected one of: ${ACCOUNT_TYPES.join(", ")}.`);
14
+ }
7
15
  // Re-export for callers (e.g. start command) that need the interactive prompt.
8
16
  export { ensureAuthenticatedInteractive };
9
17
  export function register(program) {
@@ -28,6 +36,8 @@ export function register(program) {
28
36
  .command("login")
29
37
  .description("Authenticate with GitHub")
30
38
  .option("--json", "JSON output")
39
+ .option("--as <account>", "Name this account (enables multiple accounts)")
40
+ .option("--account-type <type>", "Account plan type: individual | business | enterprise", parseAccountType)
31
41
  // Undocumented test seam: force the session-only backend regardless of
32
42
  // whether the OS keychain is available. Equivalent to setting
33
43
  // COPILLM_FORCE_SESSION_BACKEND=1 for the duration of this command.
@@ -39,18 +49,61 @@ export function register(program) {
39
49
  .command("logout")
40
50
  .description("Clear credentials and stop running daemon")
41
51
  .option("--json", "JSON output")
52
+ .option("--account <account>", "Log out a specific account (default: the default account)")
53
+ .option("--all", "Log out of every account")
42
54
  .action(async (opts) => {
43
55
  await runAuthLogout(opts);
44
56
  });
57
+ auth
58
+ .command("switch")
59
+ .argument("<account>", "Account id to make the default")
60
+ .description("Set the default account")
61
+ .option("--json", "JSON output")
62
+ .action(async (account, opts) => {
63
+ await runAuthSwitch(opts, account);
64
+ });
45
65
  auth
46
66
  .command("status")
47
67
  .description("Report whether a credential is stored (token is never printed)")
48
68
  .option("--json", "JSON output")
49
69
  .option("--no-user", "Skip the GitHub /user lookup that fetches the login name")
50
70
  .action(async (opts) => {
71
+ // commander's --no-user toggles opts.user to false; when the flag is
72
+ // omitted opts.user is undefined and we treat that as "fetch by default".
73
+ const wantUserLookup = opts.user !== false;
74
+ // Multi-account installs (an accounts index exists) get the per-account
75
+ // listing. Single-account installs keep the exact original output below.
76
+ if (readAccountsIndex()) {
77
+ const { anyStored } = await runAuthStatusList(opts);
78
+ process.exit(anyStored ? 0 : 2);
79
+ }
80
+ // Two paths to minimize keychain probes:
81
+ // - With user lookup (default): `loadStoredCredentialForStatus()`
82
+ // does ONE keychain read that yields backend + token. Pass the
83
+ // token into `inspectGithubIdentity({ token })` so it doesn't
84
+ // re-read the keychain.
85
+ // - Without user lookup (--no-user): `inspectStoredCredential()`
86
+ // does ONE keychain probe and never sees the token. Preserves
87
+ // the no-token invariant for the surface where it matters most.
88
+ //
89
+ // Previously, the user-lookup path made TWO keychain reads — one in
90
+ // `inspectStoredCredential` then another in `inspectGithubIdentity` →
91
+ // `loadStoredCredential`. That doubled macOS keychain audit-log
92
+ // entries and doubled permission-prompt exposure on misconfigured
93
+ // systems.
51
94
  let info;
95
+ let token;
52
96
  try {
53
- info = await inspectStoredCredential();
97
+ if (wantUserLookup) {
98
+ const loaded = await loadStoredCredentialForStatus();
99
+ info = { stored: loaded.stored, backend: loaded.backend };
100
+ if (loaded.stored) {
101
+ token = loaded.token;
102
+ }
103
+ }
104
+ else {
105
+ info = await inspectStoredCredential();
106
+ }
54
107
  }
55
108
  catch (error) {
56
109
  const message = error instanceof Error ? error.message : "unknown_error";
@@ -62,9 +115,7 @@ export function register(program) {
62
115
  }
63
116
  process.exit(1);
64
117
  }
65
- // commander's --no-user toggles opts.user to false; when the flag is
66
- // omitted opts.user is undefined and we treat that as "fetch by default".
67
- const userLookupEnabled = info.stored && opts.user !== false;
118
+ const userLookupEnabled = info.stored && wantUserLookup;
68
119
  let identity = null;
69
120
  if (userLookupEnabled) {
70
121
  // inspectGithubIdentity is designed to return null on any failure, but
@@ -74,7 +125,7 @@ export function register(program) {
74
125
  // must never break the auth-status command. Status output should always
75
126
  // succeed even when the network is broken.
76
127
  try {
77
- identity = await inspectGithubIdentity();
128
+ identity = await inspectGithubIdentity({ token });
78
129
  }
79
130
  catch {
80
131
  identity = null;