copillm 0.2.9 → 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.
@@ -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
  }
@@ -1,14 +1,16 @@
1
1
  import { buildClaudeEnvBundle } from "../agentEnv.js";
2
2
  import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "../../models/anthropicDefaults.js";
3
- export function buildClaudeExportCommand(port, callerSecret) {
4
- const modelIds = readModelIdsFromCache();
3
+ export function buildClaudeExportCommand(port, callerSecret, opts) {
4
+ const pathPrefix = opts?.pathPrefix ?? "";
5
+ const modelIds = readModelIdsFromCache(opts?.cacheId);
5
6
  const defaults = computeAnthropicDefaults(modelIds);
6
7
  const command = buildClaudeExport({
7
8
  port,
8
9
  callerSecret,
9
10
  defaults,
10
- enableGatewayDiscovery: true
11
+ enableGatewayDiscovery: true,
12
+ pathPrefix
11
13
  });
12
- const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true });
14
+ const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true, pathPrefix });
13
15
  return { command, defaults, bundle };
14
16
  }
@@ -1,6 +1,6 @@
1
1
  import { getCopillmHome } from "../../config/home.js";
2
2
  import { defaultOutputDir, generateCodexHome } from "../../integrations/codex/init.js";
3
- export async function refreshCodexHome(port, model, precomputed) {
3
+ export async function refreshCodexHome(port, model, precomputed, opts) {
4
4
  try {
5
5
  const home = getCopillmHome();
6
6
  return await generateCodexHome({
@@ -9,7 +9,9 @@ export async function refreshCodexHome(port, model, precomputed) {
9
9
  port,
10
10
  providerId: "copillm",
11
11
  reasoningEffort: null,
12
- precomputed
12
+ precomputed,
13
+ pathPrefix: opts?.pathPrefix,
14
+ account: opts?.account
13
15
  });
14
16
  }
15
17
  catch (error) {
@@ -1,13 +1,15 @@
1
1
  import { getCopillmHome } from "../../config/home.js";
2
2
  import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "../../integrations/pi/init.js";
3
- export async function refreshPiHome(port, precomputed) {
3
+ export async function refreshPiHome(port, precomputed, opts) {
4
4
  try {
5
5
  const home = getCopillmHome();
6
6
  return await generatePiHome({
7
7
  outDir: defaultPiOutputDir(home),
8
8
  port,
9
9
  providerId: "copillm",
10
- precomputed
10
+ precomputed,
11
+ pathPrefix: opts?.pathPrefix,
12
+ account: opts?.account
11
13
  });
12
14
  }
13
15
  catch (error) {
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.2.9"
4
+ version: "0.3.0-beta.1"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Account-id validation, shared by the `auth` layer (which writes the accounts
3
+ * index) and the `models` layer (which embeds the id in a per-account model
4
+ * cache filename). Lives in `config` so both layers can import it without
5
+ * crossing a forbidden import boundary.
6
+ *
7
+ * An account id is embedded in filenames (`credentials.<id>.json`,
8
+ * `models.cache.<id>.json`) and in a keychain account string, so it must be a
9
+ * safe single path segment. GitHub logins are `[A-Za-z0-9-]`; copillm also
10
+ * permits `.` and `_` for synthetic ids.
11
+ */
12
+ export const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
13
+ export const MAX_ACCOUNT_ID_LENGTH = 64;
14
+ export class InvalidAccountIdError extends Error {
15
+ accountId;
16
+ constructor(accountId, reason) {
17
+ super(`Invalid account id "${accountId}": ${reason}`);
18
+ this.accountId = accountId;
19
+ this.name = "InvalidAccountIdError";
20
+ }
21
+ }
22
+ /**
23
+ * Validate an account id before it is used in a filename or keychain key.
24
+ * Throws `InvalidAccountIdError` on anything that isn't a safe path segment.
25
+ */
26
+ export function assertValidAccountId(accountId) {
27
+ if (accountId.length === 0) {
28
+ throw new InvalidAccountIdError(accountId, "id must not be empty.");
29
+ }
30
+ if (accountId.length > MAX_ACCOUNT_ID_LENGTH) {
31
+ throw new InvalidAccountIdError(accountId, `id must be at most ${MAX_ACCOUNT_ID_LENGTH} characters.`);
32
+ }
33
+ if (!ACCOUNT_ID_PATTERN.test(accountId)) {
34
+ throw new InvalidAccountIdError(accountId, "id may only contain letters, digits, '.', '_' and '-', and must start with a letter or digit.");
35
+ }
36
+ }
37
+ /**
38
+ * Non-throwing variant for hot paths (e.g. parsing an optional account prefix
39
+ * out of a request URL) where an invalid id should just mean "not an account
40
+ * prefix" rather than an error.
41
+ */
42
+ export function isValidAccountId(accountId) {
43
+ return accountId.length > 0 && accountId.length <= MAX_ACCOUNT_ID_LENGTH && ACCOUNT_ID_PATTERN.test(accountId);
44
+ }
@@ -20,6 +20,29 @@ export function credentialsPath() {
20
20
  export function credentialsReadPath() {
21
21
  return resolveReadablePath("credentials.json");
22
22
  }
23
+ /**
24
+ * Path to the multi-account index (`accounts.json`). Metadata only — never
25
+ * holds a token. Absent on single-account installs, which keep using the
26
+ * legacy `credentials.json` / keychain entry as the implicit default account.
27
+ */
28
+ export function accountsIndexPath() {
29
+ return path.join(getCopillmHome(), "accounts.json");
30
+ }
31
+ export function accountsIndexReadPath() {
32
+ return resolveReadablePath("accounts.json");
33
+ }
34
+ /**
35
+ * Plaintext-fallback credential file for a *named* (non-default) account. The
36
+ * default account keeps the legacy `credentials.json` path for backward
37
+ * compatibility; additional accounts are namespaced by id so their tokens
38
+ * never collide with — or overwrite — the pre-existing default.
39
+ */
40
+ export function accountCredentialsPath(accountId) {
41
+ return path.join(getCopillmHome(), `credentials.${accountId}.json`);
42
+ }
43
+ export function accountCredentialsReadPath(accountId) {
44
+ return resolveReadablePath(`credentials.${accountId}.json`);
45
+ }
23
46
  export function lockPath() {
24
47
  return path.join(getCopillmHome(), "copillm.pid");
25
48
  }
@@ -32,6 +55,18 @@ export function modelsCachePath() {
32
55
  export function modelsCacheReadPath() {
33
56
  return resolveReadablePath("models.cache.json");
34
57
  }
58
+ /**
59
+ * Per-account model-discovery cache. Different accounts can be entitled to
60
+ * different model catalogs, so each named account caches into its own
61
+ * `models.cache.<id>.json`. The primary/legacy account keeps the shared
62
+ * `models.cache.json` (above), so single-account installs are unaffected.
63
+ */
64
+ export function accountModelsCachePath(accountId) {
65
+ return path.join(getCopillmHome(), `models.cache.${accountId}.json`);
66
+ }
67
+ export function accountModelsCacheReadPath(accountId) {
68
+ return resolveReadablePath(`models.cache.${accountId}.json`);
69
+ }
35
70
  export function debugLogPath() {
36
71
  return path.join(getCopillmHome(), "debug.log");
37
72
  }
@@ -7,13 +7,14 @@ import { ensureSecureDirectory, writeFileSecureAtomic } from "../../config/fsSec
7
7
  import { buildCodexCatalog } from "../../server/codexSchema.js";
8
8
  import { inspectLock } from "../../server/lock.js";
9
9
  export async function generateCodexHome(options) {
10
- const { discovery } = await resolveStartContext(options.precomputed);
10
+ const { discovery } = await resolveStartContext(options.precomputed, options.account);
11
11
  const catalog = buildCodexCatalog(discovery.models);
12
12
  if (catalog.models.length === 0) {
13
13
  throw new Error("No Codex-eligible models found in the live catalog.");
14
14
  }
15
15
  const port = options.port;
16
- const proxyUrl = `http://127.0.0.1:${port}/codex/v1`;
16
+ const prefix = options.pathPrefix ?? "";
17
+ const proxyUrl = `http://127.0.0.1:${port}${prefix}/codex/v1`;
17
18
  const baseUrl = proxyUrl;
18
19
  const defaultModel = options.model ?? pickDefaultModel(catalog.models.map((model) => model.slug));
19
20
  if (!catalog.models.some((model) => model.slug === defaultModel)) {
@@ -47,11 +48,19 @@ export async function generateCodexHome(options) {
47
48
  * Exported so `generatePiHome` (and any future agent init) can use the
48
49
  * same loader and the same "if precomputed, reuse it" contract.
49
50
  */
50
- export async function resolveStartContext(precomputed) {
51
+ export async function resolveStartContext(precomputed, account) {
51
52
  if (precomputed) {
52
53
  return precomputed;
53
54
  }
54
55
  const config = loadConfig();
56
+ if (account) {
57
+ const discovery = await listModelsUnion(account.accountType, account.githubToken, 3, undefined, account.cacheId);
58
+ return {
59
+ config,
60
+ creds: { token: account.githubToken, accountType: account.accountType, source: "session" },
61
+ discovery
62
+ };
63
+ }
55
64
  const creds = await loadStoredCredential();
56
65
  if (!creds) {
57
66
  throw new Error("Not authenticated. Run `copillm login` first.");
@@ -4,7 +4,7 @@ import { ensureSecureDirectory, writeFileSecureAtomic } from "../../config/fsSec
4
4
  import { piAgentDir } from "../../config/home.js";
5
5
  import { resolveStartContext } from "../codex/init.js";
6
6
  export async function generatePiHome(options) {
7
- const { discovery } = await resolveStartContext(options.precomputed);
7
+ const { discovery } = await resolveStartContext(options.precomputed, options.account);
8
8
  const eligible = discovery.models.filter(isPickerEligible);
9
9
  // Split the catalog by which upstream endpoint each model supports. Models
10
10
  // that advertise `/chat/completions` flow through copillm's Anthropic surface
@@ -20,9 +20,10 @@ export async function generatePiHome(options) {
20
20
  if (anthropicEligible.length === 0 && responsesEligible.length === 0) {
21
21
  throw new Error("No models discovered for pi config.");
22
22
  }
23
- const proxyUrl = `http://127.0.0.1:${options.port}/anthropic`;
23
+ const prefix = options.pathPrefix ?? "";
24
+ const proxyUrl = `http://127.0.0.1:${options.port}${prefix}/anthropic`;
24
25
  // OpenAI SDK posts to `<baseUrl>/responses`, so the baseUrl must include `/v1`.
25
- const responsesProxyUrl = `http://127.0.0.1:${options.port}/codex/v1`;
26
+ const responsesProxyUrl = `http://127.0.0.1:${options.port}${prefix}/codex/v1`;
26
27
  const providerId = options.providerId.trim().length > 0 ? options.providerId : "copillm";
27
28
  const responsesProviderId = `${providerId}-responses`;
28
29
  const providers = {};
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { z } from "zod";
3
- import { modelsCacheReadPath } from "../config/home.js";
3
+ import { accountModelsCacheReadPath, modelsCacheReadPath } from "../config/home.js";
4
+ import { assertValidAccountId } from "../config/accountId.js";
4
5
  export const ANTHROPIC_FAMILIES = ["opus", "sonnet", "haiku"];
5
6
  const SUFFIX_BLOCKLIST = [
6
7
  "-high",
@@ -32,8 +33,15 @@ export function computeAnthropicDefaults(modelIds) {
32
33
  haiku: pickPlainLatest(byFamily.haiku)
33
34
  };
34
35
  }
35
- export function readModelIdsFromCache() {
36
- const file = modelsCacheReadPath();
36
+ export function readModelIdsFromCache(accountId) {
37
+ let file;
38
+ if (accountId === undefined) {
39
+ file = modelsCacheReadPath();
40
+ }
41
+ else {
42
+ assertValidAccountId(accountId);
43
+ file = accountModelsCacheReadPath(accountId);
44
+ }
37
45
  if (!fs.existsSync(file)) {
38
46
  return [];
39
47
  }
@@ -51,8 +59,9 @@ export function readModelIdsFromCache() {
51
59
  }
52
60
  export function buildClaudeExportCommand(input) {
53
61
  const token = input.callerSecret ?? "copillm-local";
62
+ const prefix = input.pathPrefix ?? "";
54
63
  const parts = [
55
- `ANTHROPIC_BASE_URL=http://127.0.0.1:${input.port}/anthropic`,
64
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:${input.port}${prefix}/anthropic`,
56
65
  `ANTHROPIC_AUTH_TOKEN=${token}`
57
66
  ];
58
67
  if (input.defaults.opus) {