copillm 0.2.9 → 0.3.0-beta.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.
@@ -1,26 +1,132 @@
1
- import { clearStoredCredential, saveStoredCredential } from "../../auth/credentials.js";
1
+ import { clearStoredCredential, loadStoredCredential, loadStoredCredentialForAccount, registerExistingCredentialAsDefault, saveStoredCredential } from "../../auth/credentials.js";
2
+ import { readAccountsIndex, findAccount, 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 });
55
+ const index = readAccountsIndex();
56
+ // ---- Explicit name (`--as`) -------------------------------------------
57
+ if (namedRequested) {
58
+ const accountId = opts.as.trim();
59
+ // First time we materialize the index via an explicit name: preserve any
60
+ // pre-existing single account as the default so its token isn't clobbered.
61
+ if (!index) {
62
+ const existing = await loadStoredCredential();
63
+ if (existing) {
64
+ const existingId = (await deriveAccountId(existing.token)) ?? "default";
65
+ if (existingId !== accountId) {
66
+ registerExistingCredentialAsDefault(existingId, existing.accountType);
67
+ }
68
+ }
69
+ }
70
+ const result = await addAccount({ id: accountId, accountType, token, mode: saveMode });
71
+ emitLoginResult(opts, result, false);
72
+ return;
73
+ }
74
+ // ---- No name: auto-manage by the token's GitHub login -----------------
75
+ // Deriving the login lets `copillm auth login` recognise when a *different*
76
+ // GitHub account is signing in and keep both, instead of silently
77
+ // overwriting the previous one. A same-account re-login refreshes in place.
78
+ const newLogin = await deriveAccountId(token);
79
+ if (index) {
80
+ // Add the new login as its own account (or refresh it if already known);
81
+ // when the login can't be determined, refresh the default account.
82
+ const targetId = newLogin ?? index.defaultAccount;
83
+ const wasKnown = findAccount(targetId) !== null;
84
+ const result = await addAccount({ id: targetId, accountType, token, mode: saveMode });
85
+ emitLoginResult(opts, result, !wasKnown && !result.isDefault);
86
+ return;
87
+ }
88
+ // No accounts index yet. If a *different* GitHub account is signing in, move
89
+ // to multi-account instead of overwriting the existing single credential.
90
+ if (newLogin) {
91
+ const existing = await loadStoredCredential();
92
+ if (existing) {
93
+ const existingLogin = await deriveAccountId(existing.token);
94
+ if (existingLogin !== newLogin) {
95
+ // Different (or undeterminable) account → preserve the prior login as
96
+ // the default and add the new one alongside it.
97
+ registerExistingCredentialAsDefault(existingLogin ?? "default", existing.accountType);
98
+ const result = await addAccount({ id: newLogin, accountType, token, mode: saveMode });
99
+ emitLoginResult(opts, result, !result.isDefault);
100
+ return;
101
+ }
102
+ // Same account → fall through to an in-place single-account refresh.
103
+ }
104
+ }
105
+ // Single-account login: no prior credential, the same account re-logging in,
106
+ // or an undeterminable login. Preserve the historical behaviour exactly —
107
+ // legacy storage, no accounts index created.
108
+ const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
16
109
  writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
17
110
  status: "ok",
18
111
  action: "login",
19
112
  credential_backend: backend
20
113
  });
21
114
  }
22
- export async function runAuthLogout(opts) {
23
- const result = await clearStoredCredential();
115
+ function emitLoginResult(opts, result, hintSwitch) {
116
+ const defaultSuffix = result.isDefault ? " (default)" : "";
117
+ const switchHint = hintSwitch
118
+ ? ` It is not the default — run \`copillm auth switch ${result.id}\` to make it so.`
119
+ : "";
120
+ writeCommandOutput(opts, `Login succeeded for account "${result.id}"${defaultSuffix}. Credentials stored via ${describeBackend(result.backend)}.${switchHint}`, {
121
+ status: "ok",
122
+ action: "login",
123
+ account: result.id,
124
+ account_type: result.accountType,
125
+ is_default: result.isDefault,
126
+ credential_backend: result.backend
127
+ });
128
+ }
129
+ async function stopRunningDaemon() {
24
130
  const lockState = inspectLock();
25
131
  if (lockState.state === "running") {
26
132
  await stopByPid(lockState.lock.pid);
@@ -28,11 +134,141 @@ export async function runAuthLogout(opts) {
28
134
  else if (lockState.state === "stale") {
29
135
  releaseLock();
30
136
  }
137
+ }
138
+ export async function runAuthLogout(opts) {
139
+ // Stopping the daemon is always part of logout — its in-memory bearers are
140
+ // derived from the credentials we're clearing.
141
+ if (opts.all) {
142
+ const result = await removeAllAccounts();
143
+ await stopRunningDaemon();
144
+ writeCommandOutput(opts, `Logged out of all accounts (${result.clearedCount} credential(s) cleared).`, {
145
+ status: "ok",
146
+ action: "logout",
147
+ scope: "all",
148
+ cleared_count: result.clearedCount,
149
+ removed_accounts: result.removedAccountIds
150
+ });
151
+ return;
152
+ }
153
+ const index = readAccountsIndex();
154
+ // Single-account install (no index) and no explicit target: preserve the
155
+ // historical single-account logout behaviour.
156
+ if (!index && !opts.account) {
157
+ const result = await clearStoredCredential();
158
+ await stopRunningDaemon();
159
+ const credentialStatus = result.removed ? "removed" : "not present";
160
+ writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
161
+ status: "ok",
162
+ action: "logout",
163
+ credential_backend: result.backend,
164
+ credential_removed: result.removed
165
+ });
166
+ return;
167
+ }
168
+ if (opts.account) {
169
+ try {
170
+ assertValidAccountId(opts.account);
171
+ }
172
+ catch (error) {
173
+ const message = error instanceof InvalidAccountIdError ? error.message : "invalid account id";
174
+ writeCommandOutput(opts, `Logout failed: ${message}`, { status: "error", action: "logout", error: message });
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ }
179
+ const targetId = opts.account ?? index.defaultAccount;
180
+ const result = await removeAccountAndCredential(targetId);
181
+ await stopRunningDaemon();
31
182
  const credentialStatus = result.removed ? "removed" : "not present";
32
- writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
183
+ const tail = result.indexDeleted
184
+ ? " No accounts remain."
185
+ : result.newDefault
186
+ ? ` Default is now "${result.newDefault}".`
187
+ : "";
188
+ writeCommandOutput(opts, `Logged out of account "${result.id}". Credentials ${credentialStatus} from ${describeBackend(result.backend)}.${tail}`, {
33
189
  status: "ok",
34
190
  action: "logout",
191
+ account: result.id,
35
192
  credential_backend: result.backend,
36
- credential_removed: result.removed
193
+ credential_removed: result.removed,
194
+ new_default: result.newDefault,
195
+ index_deleted: result.indexDeleted
37
196
  });
38
197
  }
198
+ export async function runAuthSwitch(opts, accountId) {
199
+ try {
200
+ assertValidAccountId(accountId);
201
+ const index = switchDefaultAccount(accountId);
202
+ writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".`, {
203
+ status: "ok",
204
+ action: "switch",
205
+ default_account: index.defaultAccount
206
+ });
207
+ }
208
+ catch (error) {
209
+ const message = error instanceof UnknownAccountError
210
+ ? `Unknown account "${accountId}". Run \`copillm auth status\` to list accounts.`
211
+ : error instanceof InvalidAccountIdError
212
+ ? error.message
213
+ : error instanceof Error
214
+ ? error.message
215
+ : "switch failed";
216
+ writeCommandOutput(opts, `Switch failed: ${message}`, { status: "error", action: "switch", error: message });
217
+ process.exitCode = 1;
218
+ }
219
+ }
220
+ /**
221
+ * Multi-account `auth status` listing (used when an accounts index exists).
222
+ * Returns whether any account has a stored credential so the caller can pick
223
+ * the process exit code. Never prints a token.
224
+ */
225
+ export async function runAuthStatusList(opts) {
226
+ const wantUser = opts.user !== false;
227
+ const listing = await listAccountsDetailed();
228
+ const anyStored = listing.accounts.some((account) => account.stored);
229
+ const enriched = await Promise.all(listing.accounts.map(async (account) => {
230
+ let login = null;
231
+ let name = null;
232
+ if (wantUser && account.stored) {
233
+ try {
234
+ const credential = await loadStoredCredentialForAccount(account.id);
235
+ if (credential) {
236
+ const identity = await inspectGithubIdentity({ token: credential.token });
237
+ login = identity?.login ?? null;
238
+ name = identity?.name ?? null;
239
+ }
240
+ }
241
+ catch {
242
+ login = null;
243
+ name = null;
244
+ }
245
+ }
246
+ return { ...account, login, name };
247
+ }));
248
+ if (opts.json) {
249
+ process.stdout.write(JSON.stringify({
250
+ status: anyStored ? "logged_in" : "logged_out",
251
+ default: listing.defaultAccount,
252
+ accounts: enriched.map((account) => ({
253
+ id: account.id,
254
+ account_type: account.accountType,
255
+ storage: account.storage,
256
+ default: account.isDefault,
257
+ stored: account.stored,
258
+ backend: account.backend,
259
+ user: account.login ? { login: account.login, name: account.name } : null
260
+ }))
261
+ }, null, 2) + "\n");
262
+ return { anyStored };
263
+ }
264
+ process.stdout.write(`copillm — ${enriched.length} account(s)\n`);
265
+ for (const account of enriched) {
266
+ const marker = account.isDefault ? "*" : " ";
267
+ const who = account.login ? ` @${account.login}` : "";
268
+ const state = account.stored
269
+ ? formatHumanAuthStatusLine(account.backend, account.login ? { login: account.login, name: account.name } : null)
270
+ : "no credential";
271
+ process.stdout.write(`${marker} ${account.id} [${account.accountType}]${who} — ${state}\n`);
272
+ }
273
+ return { anyStored };
274
+ }
@@ -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
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,9 +49,19 @@ 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)")
@@ -51,6 +71,12 @@ export function register(program) {
51
71
  // commander's --no-user toggles opts.user to false; when the flag is
52
72
  // omitted opts.user is undefined and we treat that as "fetch by default".
53
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
+ }
54
80
  // Two paths to minimize keychain probes:
55
81
  // - With user lookup (default): `loadStoredCredentialForStatus()`
56
82
  // does ONE keychain read that yields backend + token. Pass the
@@ -40,6 +40,14 @@ export const COPILLM_FLAGS = [
40
40
  kind: "swallow",
41
41
  description: "Override active profile for this launch"
42
42
  },
43
+ {
44
+ flag: "--copillm-account",
45
+ aliases: ["--account"],
46
+ takesValue: true,
47
+ dest: "copillmAccount",
48
+ kind: "swallow",
49
+ description: "Route this launch at a specific copillm account"
50
+ },
43
51
  {
44
52
  flag: "--copillm-no-config",
45
53
  aliases: ["--no-config"],
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadStoredCredential } from "../../auth/credentials.js";
3
+ import { readAccountsIndex } from "../../auth/accounts.js";
3
4
  import { CopilotTokenManager } from "../../auth/copilotToken.js";
4
5
  import { loadConfig } from "../../config/config.js";
5
6
  import { acquireLock, LockAlreadyRunningError, releaseLock } from "../../server/lock.js";
6
7
  import { startProxyServer } from "../../server/proxy.js";
8
+ import { DaemonAccountResolver } from "../../server/accountResolver.js";
7
9
  import { installProcessSafetyNet } from "../processSafetyNet.js";
8
10
  import { getRootLogger } from "../shared/debug.js";
9
11
  import { withTimeout } from "./lifecycle.js";
@@ -30,6 +32,22 @@ export async function runDaemon(options) {
30
32
  }
31
33
  const tokenManager = new CopilotTokenManager(creds.token);
32
34
  await tokenManager.ensureToken(false);
35
+ // Build the default account's resolved identity. With no accounts index this
36
+ // is the legacy single account (accountId null, legacy model cache). With an
37
+ // index, it reflects the configured default account's id, plan type, and
38
+ // storage scheme — so model discovery and the cache key stay correct.
39
+ const accountsIndex = readAccountsIndex();
40
+ const defaultRecord = accountsIndex
41
+ ? accountsIndex.accounts.find((account) => account.id === accountsIndex.defaultAccount) ?? null
42
+ : null;
43
+ const defaultAccount = {
44
+ accountId: defaultRecord?.id ?? null,
45
+ githubToken: creds.token,
46
+ tokenManager,
47
+ accountType: defaultRecord?.accountType ?? config.accountType,
48
+ cacheId: defaultRecord && defaultRecord.storage === "namespaced" ? defaultRecord.id : undefined
49
+ };
50
+ const accountResolver = new DaemonAccountResolver({ default: defaultAccount });
33
51
  const callerSecret = config.requireCallerSecret ? randomUUID() : null;
34
52
  if (callerSecret) {
35
53
  process.stdout.write(`Caller secret: ${callerSecret}\n`);
@@ -53,6 +71,7 @@ export async function runDaemon(options) {
53
71
  port,
54
72
  config,
55
73
  tokenManager,
74
+ accountResolver,
56
75
  callerSecret,
57
76
  logger,
58
77
  debug: Boolean(options?.debug),
@@ -70,7 +89,7 @@ export async function runDaemon(options) {
70
89
  }
71
90
  }
72
91
  if (!server || selectedPort === null) {
73
- tokenManager.clear();
92
+ accountResolver.clearAll();
74
93
  throw new Error(`No available port in configured range (${ports[0]}-${ports[ports.length - 1]}).`);
75
94
  }
76
95
  installProcessSafetyNet(logger);
@@ -87,7 +106,7 @@ export async function runDaemon(options) {
87
106
  logger.warn({ err: error }, "graceful shutdown timed out");
88
107
  }
89
108
  finally {
90
- tokenManager.clear();
109
+ accountResolver.clearAll();
91
110
  releaseLock();
92
111
  process.exit(0);
93
112
  }