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.
package/README.md CHANGED
@@ -28,6 +28,18 @@ Alternatively, you can invoke it directly with `npx` without a global install:
28
28
  npx copillm --help
29
29
  ```
30
30
 
31
+ ### Preview (beta) releases
32
+
33
+ Experimental builds are published to the `beta` channel ahead of a stable
34
+ release. They let you try in-progress features early; expect rough edges. Stable
35
+ installs are never affected unless you explicitly opt in:
36
+
37
+ ```bash
38
+ npm install -g copillm@beta
39
+ ```
40
+
41
+ To return to the stable channel, reinstall without the tag: `npm install -g copillm@latest`.
42
+
31
43
  ## Quick start
32
44
 
33
45
  ```bash
@@ -48,6 +60,62 @@ copillm claude --model opus
48
60
  copillm codex --help
49
61
  ```
50
62
 
63
+ ## Multiple accounts
64
+
65
+ copillm can hold more than one GitHub account at once and serve them from the
66
+ same daemon. If you only ever use one account, nothing changes — you never see
67
+ any of this.
68
+
69
+ ```bash
70
+ # Your first login is the default account (no naming needed).
71
+ copillm auth login
72
+
73
+ # Add another account under a name of your choice.
74
+ copillm auth login --as work
75
+ copillm auth login --as work --account-type business # set its plan type
76
+
77
+ # See every account; the default is marked with *.
78
+ copillm auth status
79
+
80
+ # Change which account is the default.
81
+ copillm auth switch work
82
+
83
+ # Log out of one account, or all of them.
84
+ copillm auth logout --account work
85
+ copillm auth logout --all
86
+ ```
87
+
88
+ The **default account** is what every agent and the model endpoints use unless
89
+ told otherwise. `copillm auth status` lists each account with its plan type and
90
+ whether a credential is stored; tokens are never printed.
91
+
92
+ Different accounts can be entitled to different models, so each account keeps
93
+ its own model list.
94
+
95
+ ### Launching an agent against a specific account
96
+
97
+ Point any agent at a non-default account for a single launch with `--account`,
98
+ or set `COPILLM_ACCOUNT` in the environment:
99
+
100
+ ```bash
101
+ copillm codex --account work
102
+ COPILLM_ACCOUNT=work copillm claude
103
+ ```
104
+
105
+ To make it automatic, pin an account to a profile in `~/.copillm/agent.toml`
106
+ (or a project's `.copillm/agent.toml`):
107
+
108
+ ```toml
109
+ [profiles.work]
110
+ account = "work"
111
+ ```
112
+
113
+ Then `copillm codex --profile work` always uses the `work` account. Precedence
114
+ is `--account` > `COPILLM_ACCOUNT` > the profile's pinned account > the default
115
+ account. copillm prints a short notice such as `using account "work" (from
116
+ profile)` so you always know which account a launch is using, and refuses to
117
+ launch with a clear error if the account isn't one you've logged into.
118
+
51
119
  ## Documentation
52
120
 
53
121
  Full documentation is published at **[jcjc-dev.github.io/copillm](https://jcjc-dev.github.io/copillm/)**.
@@ -63,7 +131,7 @@ Full documentation is published at **[jcjc-dev.github.io/copillm](https://jcjc-d
63
131
 
64
132
  ## Contributing
65
133
 
66
- Bug reports and pull requests are welcome. Please read the [development guide](https://jcjc-dev.github.io/copillm/development/) before opening a pull request.
134
+ Bug reports and pull requests are welcome. Develop against an isolated dev daemon (`npm run dev:start`) so you don't disturb a running copillm, and run `npm run lint && npm test && npm run test:e2e:pr` before opening a pull request. See the [development guide](https://jcjc-dev.github.io/copillm/development/) for the full workflow.
67
135
 
68
136
  ## Disclaimer
69
137
 
@@ -97,6 +97,14 @@ function mergeAndResolve(input) {
97
97
  const instructions = instructionsBody !== null && instructionsBody.trim().length > 0
98
98
  ? { body: instructionsBody }
99
99
  : null;
100
+ // Merge the pinned account: later layers (project over global, profile over
101
+ // defaults) win. Empty string is treated as unset.
102
+ let account = null;
103
+ for (const layer of layers) {
104
+ if (layer.account !== undefined && layer.account.trim().length > 0) {
105
+ account = layer.account.trim();
106
+ }
107
+ }
100
108
  // Merge mcp.servers map; later layers replace earlier same-named entries.
101
109
  // Defaults are always-on: a profile may override a default by name but
102
110
  // cannot remove it.
@@ -114,7 +122,7 @@ function mergeAndResolve(input) {
114
122
  permissions: mergeRecord(layers, "permissions")
115
123
  };
116
124
  const yolo = mergeYolo(layers);
117
- return { instructions, mcpServers: servers, yolo, reserved };
125
+ return { instructions, mcpServers: servers, account, yolo, reserved };
118
126
  }
119
127
  /**
120
128
  * Layer yolo blocks across defaults + active profile. Later layers (project
@@ -69,6 +69,12 @@ const SectionSchema = z
69
69
  instructions: InstructionsSchema.optional(),
70
70
  mcp: McpSchema.optional(),
71
71
  yolo: YoloSchema.optional(),
72
+ /**
73
+ * Pin a copillm account for launches that use this profile. The launcher
74
+ * routes the agent at this account unless overridden by `--account` /
75
+ * `COPILLM_ACCOUNT`. Must name an account from `copillm auth status`.
76
+ */
77
+ account: z.string().min(1).optional(),
72
78
  // v1 reserved sections: validated as objects but not interpreted.
73
79
  skills: PassthroughRecord.optional(),
74
80
  agents: PassthroughRecord.optional(),
@@ -0,0 +1,118 @@
1
+ import { findAccount, readAccountsIndex, removeAccount, setDefaultAccountId, upsertAccount, assertValidAccountId } from "./accounts.js";
2
+ import { clearStoredCredential, clearStoredCredentialForAccount, inspectStoredCredentialForAccount, saveStoredCredentialForAccount } from "./credentials.js";
3
+ /**
4
+ * Add or update an explicitly-identified account, materializing/extending the
5
+ * accounts index, and store its credential.
6
+ *
7
+ * Storage scheme follows the credential-store invariant: the **first** account
8
+ * (no index yet) takes legacy storage so it keeps the original keychain entry /
9
+ * `credentials.json`; every account added afterwards is namespaced. An existing
10
+ * account keeps whatever storage it already has.
11
+ */
12
+ export async function addAccount(input) {
13
+ assertValidAccountId(input.id);
14
+ const index = readAccountsIndex();
15
+ const existing = findAccount(input.id);
16
+ const storage = existing ? existing.storage : index ? "namespaced" : "legacy";
17
+ upsertAccount({
18
+ id: input.id,
19
+ accountType: input.accountType,
20
+ storage,
21
+ addedAt: existing?.addedAt ?? new Date().toISOString()
22
+ });
23
+ // saveStoredCredentialForAccount resolves storage from the index record we
24
+ // just wrote, so it lands in the right (legacy vs namespaced) location.
25
+ const backend = await saveStoredCredentialForAccount(input.id, input.token, input.accountType, {
26
+ mode: input.mode ?? "auto"
27
+ });
28
+ let isDefault = readAccountsIndex()?.defaultAccount === input.id;
29
+ if (input.makeDefault && !isDefault) {
30
+ setDefaultAccountId(input.id);
31
+ isDefault = true;
32
+ }
33
+ return { id: input.id, accountType: input.accountType, storage, backend, isDefault };
34
+ }
35
+ /**
36
+ * Detailed, token-free view of every registered account for `auth status`.
37
+ * Returns `hasIndex: false` for single-account installs so the caller can use
38
+ * the legacy single-account output unchanged.
39
+ */
40
+ export async function listAccountsDetailed() {
41
+ const index = readAccountsIndex();
42
+ if (!index) {
43
+ return { hasIndex: false, defaultAccount: null, accounts: [] };
44
+ }
45
+ const accounts = [];
46
+ for (const record of index.accounts) {
47
+ const info = await inspectStoredCredentialForAccount(record.id);
48
+ accounts.push({
49
+ id: record.id,
50
+ accountType: record.accountType,
51
+ storage: record.storage,
52
+ isDefault: record.id === index.defaultAccount,
53
+ stored: info.stored,
54
+ backend: info.backend
55
+ });
56
+ }
57
+ return { hasIndex: true, defaultAccount: index.defaultAccount, accounts };
58
+ }
59
+ /**
60
+ * Remove one account: clear its credential first (while its index record still
61
+ * exists, so the correct storage location is targeted), then drop it from the
62
+ * index (which reassigns the default, or deletes the index when it was the
63
+ * last account). Clearing is best-effort — an absent credential is reported as
64
+ * `removed: false` rather than failing the removal.
65
+ */
66
+ export async function removeAccountAndCredential(id) {
67
+ assertValidAccountId(id);
68
+ let removed = false;
69
+ let backend = "file";
70
+ try {
71
+ const cleared = await clearStoredCredentialForAccount(id);
72
+ removed = cleared.removed;
73
+ backend = cleared.backend;
74
+ }
75
+ catch {
76
+ // No backend available to clear (e.g. nothing was stored). The account is
77
+ // still removed from the index below.
78
+ removed = false;
79
+ }
80
+ const index = removeAccount(id);
81
+ return {
82
+ id,
83
+ removed,
84
+ backend,
85
+ newDefault: index?.defaultAccount ?? null,
86
+ indexDeleted: index === null
87
+ };
88
+ }
89
+ /**
90
+ * Remove every account and delete the index. For a single-account install (no
91
+ * index) this just clears the legacy credential.
92
+ */
93
+ export async function removeAllAccounts() {
94
+ const index = readAccountsIndex();
95
+ if (!index) {
96
+ const cleared = await clearStoredCredential();
97
+ return { clearedCount: cleared.removed ? 1 : 0, removedAccountIds: [], indexDeleted: false };
98
+ }
99
+ const ids = index.accounts.map((account) => account.id);
100
+ let clearedCount = 0;
101
+ for (const id of ids) {
102
+ try {
103
+ const cleared = await clearStoredCredentialForAccount(id);
104
+ if (cleared.removed) {
105
+ clearedCount += 1;
106
+ }
107
+ }
108
+ catch {
109
+ // Best-effort: an account with nothing stored still gets removed.
110
+ }
111
+ removeAccount(id);
112
+ }
113
+ return { clearedCount, removedAccountIds: ids, indexDeleted: true };
114
+ }
115
+ /** Point the default at an existing account. Throws `UnknownAccountError`. */
116
+ export function switchDefaultAccount(id) {
117
+ return setDefaultAccountId(id);
118
+ }
@@ -0,0 +1,161 @@
1
+ import fs from "node:fs";
2
+ import { z } from "zod";
3
+ import { ensureAppHome } from "../config/config.js";
4
+ import { accountsIndexPath, accountsIndexReadPath } from "../config/home.js";
5
+ import { writeFileSecureAtomic } from "../config/fsSecurity.js";
6
+ import { ACCOUNT_ID_PATTERN, MAX_ACCOUNT_ID_LENGTH, assertValidAccountId, InvalidAccountIdError } from "../config/accountId.js";
7
+ // Re-exported for callers that historically imported account-id validation
8
+ // from this module (e.g. `credentials.ts`). The canonical definition now lives
9
+ // in `config/accountId.ts` so the `models` layer can share it.
10
+ export { assertValidAccountId, InvalidAccountIdError };
11
+ // GitHub logins are `[A-Za-z0-9-]` and copillm allows `.` / `_` for synthetic
12
+ // ids. The id is embedded in a filename (`credentials.<id>.json`) and a
13
+ // keychain account string; the canonical validation lives in
14
+ // `config/accountId.ts` (shared with the models layer).
15
+ const AccountRecordSchema = z.object({
16
+ id: z.string().min(1).max(MAX_ACCOUNT_ID_LENGTH).regex(ACCOUNT_ID_PATTERN),
17
+ accountType: z.enum(["individual", "business", "enterprise"]),
18
+ storage: z.enum(["legacy", "namespaced"]),
19
+ addedAt: z.string().min(1)
20
+ });
21
+ const AccountsIndexSchema = z
22
+ .object({
23
+ version: z.literal(1),
24
+ defaultAccount: z.string().min(1),
25
+ accounts: z.array(AccountRecordSchema)
26
+ })
27
+ .superRefine((value, ctx) => {
28
+ const ids = value.accounts.map((account) => account.id);
29
+ if (new Set(ids).size !== ids.length) {
30
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "accounts.json contains duplicate account ids." });
31
+ }
32
+ if (!ids.includes(value.defaultAccount)) {
33
+ ctx.addIssue({
34
+ code: z.ZodIssueCode.custom,
35
+ message: `accounts.json defaultAccount "${value.defaultAccount}" is not present in accounts.`
36
+ });
37
+ }
38
+ if (value.accounts.filter((account) => account.storage === "legacy").length > 1) {
39
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "accounts.json may declare at most one legacy-storage account." });
40
+ }
41
+ });
42
+ /**
43
+ * Read and validate the accounts index. Returns `null` when no index exists
44
+ * (the single-account / legacy case). Throws if the file exists but is
45
+ * corrupt, so a damaged index surfaces loudly rather than silently dropping
46
+ * accounts.
47
+ */
48
+ export function readAccountsIndex() {
49
+ const path = accountsIndexReadPath();
50
+ if (!fs.existsSync(path)) {
51
+ return null;
52
+ }
53
+ let raw;
54
+ try {
55
+ raw = JSON.parse(fs.readFileSync(path, "utf8"));
56
+ }
57
+ catch (error) {
58
+ const detail = error instanceof Error ? error.message : "unknown error";
59
+ throw new Error(`Accounts index exists but contains invalid JSON at ${path}: ${detail}`);
60
+ }
61
+ const parsed = AccountsIndexSchema.safeParse(raw);
62
+ if (!parsed.success) {
63
+ throw new Error(`Accounts index exists but is invalid at ${path}: ${parsed.error.issues.map((i) => i.message).join("; ")}`);
64
+ }
65
+ return parsed.data;
66
+ }
67
+ export function writeAccountsIndex(index) {
68
+ const validated = AccountsIndexSchema.parse(index);
69
+ ensureAppHome();
70
+ writeFileSecureAtomic(accountsIndexPath(), JSON.stringify(validated, null, 2), 0o600);
71
+ }
72
+ export function listAccounts() {
73
+ return readAccountsIndex()?.accounts ?? [];
74
+ }
75
+ /**
76
+ * The default account id, or `null` when no index exists. A `null` return is
77
+ * the signal to callers to use the legacy single-account storage path.
78
+ */
79
+ export function getDefaultAccountId() {
80
+ return readAccountsIndex()?.defaultAccount ?? null;
81
+ }
82
+ export function findAccount(accountId) {
83
+ return readAccountsIndex()?.accounts.find((account) => account.id === accountId) ?? null;
84
+ }
85
+ /**
86
+ * Insert or update an account record, then persist the index. When the index
87
+ * does not yet exist it is created with this account as the default. Returns
88
+ * the resulting index.
89
+ */
90
+ export function upsertAccount(record) {
91
+ assertValidAccountId(record.id);
92
+ AccountRecordSchema.parse(record);
93
+ const existing = readAccountsIndex();
94
+ if (!existing) {
95
+ const index = { version: 1, defaultAccount: record.id, accounts: [record] };
96
+ writeAccountsIndex(index);
97
+ return index;
98
+ }
99
+ const accounts = existing.accounts.filter((account) => account.id !== record.id);
100
+ accounts.push(record);
101
+ const index = { ...existing, accounts };
102
+ writeAccountsIndex(index);
103
+ return index;
104
+ }
105
+ export class UnknownAccountError extends Error {
106
+ accountId;
107
+ constructor(accountId) {
108
+ super(`Unknown account "${accountId}".`);
109
+ this.accountId = accountId;
110
+ this.name = "UnknownAccountError";
111
+ }
112
+ }
113
+ /**
114
+ * Point the default at an existing account. Throws `UnknownAccountError` if the
115
+ * id isn't registered, so a typo can't silently orphan the default.
116
+ */
117
+ export function setDefaultAccountId(accountId) {
118
+ const existing = readAccountsIndex();
119
+ if (!existing || !existing.accounts.some((account) => account.id === accountId)) {
120
+ throw new UnknownAccountError(accountId);
121
+ }
122
+ const index = { ...existing, defaultAccount: accountId };
123
+ writeAccountsIndex(index);
124
+ return index;
125
+ }
126
+ /**
127
+ * Remove an account from the index. Returns the updated index, or `null` if no
128
+ * index existed. When the removed account was the default, the default falls
129
+ * back to the first remaining account (or the index is deleted entirely if no
130
+ * accounts remain). Token removal is the caller's responsibility.
131
+ */
132
+ export function removeAccount(accountId) {
133
+ const existing = readAccountsIndex();
134
+ if (!existing) {
135
+ return null;
136
+ }
137
+ const accounts = existing.accounts.filter((account) => account.id !== accountId);
138
+ if (accounts.length === existing.accounts.length) {
139
+ return existing;
140
+ }
141
+ if (accounts.length === 0) {
142
+ deleteAccountsIndex();
143
+ return null;
144
+ }
145
+ const defaultAccount = accounts.some((account) => account.id === existing.defaultAccount)
146
+ ? existing.defaultAccount
147
+ : accounts[0].id;
148
+ const index = { ...existing, defaultAccount, accounts };
149
+ writeAccountsIndex(index);
150
+ return index;
151
+ }
152
+ function deleteAccountsIndex() {
153
+ const canonical = accountsIndexPath();
154
+ if (fs.existsSync(canonical)) {
155
+ fs.unlinkSync(canonical);
156
+ }
157
+ const readable = accountsIndexReadPath();
158
+ if (readable !== canonical && fs.existsSync(readable)) {
159
+ fs.unlinkSync(readable);
160
+ }
161
+ }