passalong 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,13 +5,14 @@ Hand work to your AI coding agents, and get back what they did and how they chec
5
5
  Write a task, a bug or a finished change once. The agent that takes it starts knowing what done
6
6
  looks like and what was already decided, reports as it goes, and hands back a write-up with the
7
7
  evidence for each thing you asked for, which you approve or send back. One agent per job, and
8
- everyone can see who has what. Works with Claude Code, Cursor, Gemini, ChatGPT, or anything that
8
+ everyone can see who has what. Works with Claude Code, Codex, ChatGPT, Claude, or anything that
9
9
  speaks MCP or HTTP. See [passalong.dev](https://passalong.dev).
10
10
 
11
11
  ```sh
12
12
  npm i -g passalong
13
13
  passalong setup # installs the Claude Code capture skill + registers the MCP server
14
14
  passalong login # optional: sync guides across machines and get share links
15
+ codex mcp add passalong -- passalong mcp # Codex: the same MCP server
15
16
  ```
16
17
 
17
18
  ## The loop
@@ -41,7 +42,11 @@ passalong archive <id> off your board and out of the free tier's count (w
41
42
  passalong promote <id> deprecated: mark promoted
42
43
  passalong rm <id> delete a guide locally and from sync
43
44
  passalong export [dir] dump every guide as plain markdown
44
- passalong login [token] sign in with your email and password, or attach this machine with a token
45
+ passalong login [token] sign in from the browser, or attach this machine with a token
46
+ passalong login --new add another account to this machine
47
+ passalong accounts the accounts signed in here, and which is the default
48
+ passalong use <name> make one of them the default
49
+ passalong logout <name> forget one of them
45
50
  passalong me [--handle H] [--name N] [--email E] who you are to teammates
46
51
  passalong team current team and its members
47
52
  passalong team create <name> start a team (you become its owner)
@@ -103,3 +108,15 @@ Plain markdown with frontmatter in `~/.passalong/guides`. Yours to edit, grep, a
103
108
  `passalong export` dumps everything. Works with no account; `passalong login` adds sync and share links.
104
109
 
105
110
  Point at a self-hosted server with `PASSALONG_API=https://your-host` before `passalong login`.
111
+
112
+ ## More than one account
113
+
114
+ A machine can be signed in to several accounts, say a work one and a personal one.
115
+ `passalong login --new` adds another beside the first, `passalong accounts` lists them, and
116
+ `passalong use <name>` picks the default a person at a terminal gets.
117
+
118
+ An agent has no terminal to be asked at, so with two or more accounts and nothing saying which, it
119
+ is refused and told to ask you. It puts the question to you in plain words, you answer, and it calls
120
+ `use_account` with the name. That lasts for the session and moves nothing on disk. From a shell,
121
+ `--as <name>` or `PASSALONG_ACCOUNT=<name>` says the same for one command. The team you work in
122
+ (`passalong team use`) belongs to the account, not the machine.
package/bin/passalong CHANGED
@@ -39,7 +39,11 @@
39
39
  // passalong archive <id> off your board and out of the free tier's count (was `done`)
40
40
  // passalong rm <id> delete a guide locally and from sync
41
41
  // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
42
- // passalong login [token] sign in with your email and password, or attach this machine with a token
42
+ // passalong login [token] sign in from the browser, or attach this machine with a token
43
+ // passalong login --new add another account to this machine
44
+ // passalong accounts the accounts signed in here, and which is the default
45
+ // passalong use <name> make one of them the default
46
+ // passalong logout <name> forget one of them
43
47
  // passalong me [--handle H] [--name N] [--email E] who you are to teammates
44
48
  // passalong team current team and its members
45
49
  // passalong team create <name> start a team (you become its owner)
@@ -51,7 +55,7 @@
51
55
  // passalong mcp run the MCP server over stdio
52
56
  // passalong version print the installed version (also -v, --version)
53
57
  import { execFileSync, spawn, spawnSync } from "node:child_process";
54
- import { createHash } from "node:crypto";
58
+ import { createHash, randomBytes } from "node:crypto";
55
59
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
56
60
  import { homedir, hostname } from "node:os";
57
61
  import { dirname, join, resolve } from "node:path";
@@ -94,6 +98,118 @@ const tty = process.stdout.isTTY && process.stdin.isTTY;
94
98
  * Written against readline rather than a dependency because this package has none beyond the MCP
95
99
  * SDK and zod, and asking for a line of text is not where that changes.
96
100
  */
101
+ /** Open a page in the default browser, and say nothing if there is none to open. */
102
+ function openPage(url) {
103
+ const [cmd, args] =
104
+ process.platform === "darwin"
105
+ ? ["open", [url]]
106
+ : process.platform === "win32"
107
+ ? ["cmd", ["/c", "start", "", url]]
108
+ : ["xdg-open", [url]];
109
+ try {
110
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref();
111
+ } catch {}
112
+ }
113
+
114
+ /**
115
+ * Keep a login under a name, and say what that did. The first one on a machine is the default; one
116
+ * added beside it is not, because a default that changes by itself is how an agent ends up acting
117
+ * as the wrong person. A machine that has only the older single login gets that one named first,
118
+ * so adding a second does not lose it.
119
+ */
120
+ async function keepLogin(token, base = api.baseUrl()) {
121
+ const who = await api.meWith(token, base).catch((e) => die(`token rejected: ${e.message}`));
122
+ const cfg = store.readConfig();
123
+ if (cfg.token && !cfg.accounts && cfg.token !== token) {
124
+ const old = await api.meWith(cfg.token, cfg.api || base).catch(() => null);
125
+ store.saveAccount(
126
+ { token: cfg.token, api: cfg.api || base, account: old?.account || "", handle: old?.handle || "", email: old?.email || "", team: cfg.team || "" },
127
+ { activate: true },
128
+ );
129
+ }
130
+ const before = Object.keys(store.readAccounts()).length;
131
+ const name = store.saveAccount(
132
+ { token, api: base, account: who.account, handle: who.handle || "", email: who.email || "" },
133
+ { activate: before === 0 },
134
+ );
135
+ if (!api.currentTeam() && who.teams?.length === 1) store.saveTeam(name, who.teams[0].slug);
136
+ const now = store.readConfig();
137
+ const count = Object.keys(store.readAccounts(now)).length;
138
+ console.error(`signed in as ${name} (account ${who.account}) on ${base}. Sync is on.`);
139
+ if (count > 1)
140
+ console.error(
141
+ `${count} accounts are signed in here; the default is ${now.active}. ` +
142
+ (now.active === name ? "" : `Switch it with \`passalong use ${name}\`. `) +
143
+ "Agents are asked which to use each session.",
144
+ );
145
+ return { name, who };
146
+ }
147
+
148
+ /**
149
+ * Sign in by approving in the browser: show a code, wait, and be handed a token. No password is
150
+ * typed here and no token is pasted between windows. Needs no terminal to ask on, so an agent can
151
+ * run it and tell the person which page to open.
152
+ */
153
+ async function browserLogin() {
154
+ const verifier = randomBytes(32).toString("hex");
155
+ const challenge = createHash("sha256").update(verifier).digest("hex");
156
+ const start = await api.deviceStart(challenge, `${hostname()} cli`).catch((e) => die(e.message));
157
+ console.error(`Open ${start.url}`);
158
+ console.error(`and check the page shows the code ${start.code}, then press Approve.`);
159
+ if (tty) openPage(start.url);
160
+ console.error("Waiting for you to approve it here… (Ctrl-C to stop)");
161
+ const until = Date.now() + start.expires_in * 1000;
162
+ while (Date.now() < until) {
163
+ await new Promise((r) => setTimeout(r, start.interval * 1000));
164
+ const got = await api.devicePoll(start.id, verifier).catch((e) => die(e.message));
165
+ if (!got) continue;
166
+ await keepLogin(got.token);
167
+ console.error("Attach other machines with:");
168
+ console.error(` passalong login ${got.token}`);
169
+ console.error("(keep it private — it is a credential, not a password)");
170
+ console.error("Give teammates a name to address you by: passalong me --handle <you>");
171
+ return;
172
+ }
173
+ die("nobody approved it in time. Run `passalong login` again.");
174
+ }
175
+
176
+ /** The old way, kept for anyone who asks for it with `--email`: an email and a password, typed here. */
177
+ async function passwordLogin(f) {
178
+ // An account you cannot sign in to is one that dies with this file. This used to mint one
179
+ // silently and then suggest adding a password afterwards; it asks up front instead.
180
+ if (!tty)
181
+ die(
182
+ "no terminal to ask on. Sign in at " +
183
+ `${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
184
+ );
185
+
186
+ const email = f.email || (await ask("Email: "));
187
+ if (!email) die("an email is needed to sign in");
188
+ const password = await ask("Password: ", { hidden: true });
189
+ if (!password) die("a password is needed to sign in");
190
+
191
+ const label = `${hostname()} cli`;
192
+ let who = await api.signIn(email, password, { label }).catch((e) => e);
193
+ if (who instanceof Error) {
194
+ // 401 is "no such account, or the wrong password", said the same way on purpose so the API
195
+ // does not confirm which addresses exist. The CLI has to ask rather than guess, because
196
+ // creating an account for a mistyped address is worse than being told to try again.
197
+ if (who.status !== 401) die(who.message);
198
+ const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
199
+ if (yes !== "y" && yes !== "yes")
200
+ die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
201
+ const again = await ask("Choose a password: ", { hidden: true });
202
+ if (again !== password) die("those did not match");
203
+ who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
204
+ }
205
+
206
+ await keepLogin(who.token);
207
+ console.error("Attach other machines with:");
208
+ console.error(` passalong login ${who.token}`);
209
+ console.error("(keep it private — it is a credential, not a password)");
210
+ console.error("Give teammates a name to address you by: passalong me --handle <you>");
211
+ }
212
+
97
213
  async function ask(question, { hidden = false } = {}) {
98
214
  const { createInterface } = await import("node:readline");
99
215
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
@@ -276,6 +392,7 @@ const commands = {
276
392
  console.log(id);
277
393
  const target = guide.meta.target_context;
278
394
  console.error(`task "${guide.meta.title}" is in Draft${target ? ` for ${target}` : " (no repo)"}`);
395
+ if (guide.meta.team) console.error(`handed to ${guide.meta.team} (team inbox)`);
279
396
  console.error(`when an agent may take it: passalong ready ${id}`);
280
397
  if (!synced) console.error("not synced (run `passalong login` — agents elsewhere cannot see it)");
281
398
  },
@@ -685,9 +802,7 @@ const commands = {
685
802
  async login(f) {
686
803
  const given = f._[0];
687
804
  if (given) {
688
- store.writeConfig({ token: given, api: api.baseUrl() });
689
- const who = await api.me().catch((e) => die(`token rejected: ${e.message}`));
690
- console.error(`logged in (account ${who.account}) — ${api.baseUrl()}`);
805
+ await keepLogin(given);
691
806
  return;
692
807
  }
693
808
  if (api.loggedIn() && !f.new) {
@@ -695,43 +810,58 @@ const commands = {
695
810
  if (who) {
696
811
  console.error(`already logged in (account ${who.account}). Attach another machine with:`);
697
812
  console.error(` passalong login ${api.token()}`);
813
+ console.error("Add another account to this machine with `passalong login --new`.");
698
814
  return;
699
815
  }
700
816
  }
701
- // An account you cannot sign in to is one that dies with this file. This used to mint one
702
- // silently and then suggest adding a password afterwards; it asks up front instead.
703
- if (!tty)
704
- die(
705
- "no terminal to ask on. Sign in at " +
706
- `${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
707
- );
817
+ // Passwords are for someone who asks: `--email` keeps the prompts. Everyone else is sent to the
818
+ // browser, which works with no terminal at all — an agent can start it, and the person it is
819
+ // working for clicks once.
820
+ if (f.email || f.password) return passwordLogin(f);
821
+ return browserLogin();
822
+ },
708
823
 
709
- const email = f.email || (await ask("Email: "));
710
- if (!email) die("an email is needed to sign in");
711
- const password = await ask("Password: ", { hidden: true });
712
- if (!password) die("a password is needed to sign in");
713
-
714
- const label = `${hostname()} cli`;
715
- let who = await api.signIn(email, password, { label }).catch((e) => e);
716
- if (who instanceof Error) {
717
- // 401 is "no such account, or the wrong password", said the same way on purpose so the API
718
- // does not confirm which addresses exist. The CLI has to ask rather than guess, because
719
- // creating an account for a mistyped address is worse than being told to try again.
720
- if (who.status !== 401) die(who.message);
721
- const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
722
- if (yes !== "y" && yes !== "yes")
723
- die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
724
- const again = await ask("Choose a password: ", { hidden: true });
725
- if (again !== password) die("those did not match");
726
- who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
824
+ /** The accounts signed in here, with the default marked. */
825
+ async accounts() {
826
+ const cfg = store.readConfig();
827
+ const all = store.readAccounts(cfg);
828
+ const names = Object.keys(all);
829
+ if (!names.length) {
830
+ console.log(
831
+ cfg.token
832
+ ? "one account is signed in here (a login from before accounts had names). `passalong login --new` adds another."
833
+ : "no account is signed in here — passalong login",
834
+ );
835
+ return;
836
+ }
837
+ for (const n of names) {
838
+ const a = all[n];
839
+ const who = [a.handle ? `@${a.handle}` : "", a.email].filter(Boolean).join(" ");
840
+ console.log(`${n === cfg.active ? "*" : " "} ${n}${who ? ` ${who}` : ""} ${a.api}`);
727
841
  }
842
+ if (names.length > 1)
843
+ console.log("\n* is the default for a person at a terminal. Agents are asked which to use each session.");
844
+ },
728
845
 
729
- store.writeConfig({ token: who.token, api: api.baseUrl() });
730
- console.error(`signed in as ${email} (account ${who.account}) on ${api.baseUrl()}. Sync is on.`);
731
- console.error("Attach other machines with:");
732
- console.error(` passalong login ${who.token}`);
733
- console.error("(keep it private — it is a credential, not a password)");
734
- console.error("Give teammates a name to address you by: passalong me --handle <you>");
846
+ /** Make one of them the default. This changes what a terminal gets, never what a session of an agent is told. */
847
+ async use(f) {
848
+ const name = f._[0] || die("usage: passalong use <name> (see `passalong accounts`)");
849
+ if (!store.readAccounts()[name])
850
+ die(`no account called "${name}" here. Signed in: ${Object.keys(store.readAccounts()).join(", ") || "none"}`);
851
+ store.setActive(name);
852
+ console.error(`the default account is now ${name}`);
853
+ },
854
+
855
+ /** Forget one of them on this machine. The account itself is untouched: only its token here goes. */
856
+ async logout(f) {
857
+ const name = f._[0] || die("usage: passalong logout <name> (see `passalong accounts`)");
858
+ const r = store.removeAccount(name);
859
+ if (!r.removed) die(`no account called "${name}" here`);
860
+ console.error(
861
+ r.active
862
+ ? `signed out of ${name}; the default is now ${r.active}`
863
+ : `signed out of ${name}; no account is signed in here now`,
864
+ );
735
865
  },
736
866
 
737
867
  async me(f) {
@@ -753,7 +883,7 @@ const commands = {
753
883
  ? " (no plan — nothing is syncing)"
754
884
  : " (no limit on your plan)";
755
885
  console.log(`guides ${who.guides} synced${ceiling}`);
756
- const current = store.readConfig().team;
886
+ const current = api.currentTeam();
757
887
  console.log(
758
888
  `teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
759
889
  );
@@ -761,7 +891,7 @@ const commands = {
761
891
 
762
892
  async team(f) {
763
893
  const [sub, ...rest] = f._;
764
- const cfg = store.readConfig();
894
+ const cfg = { team: api.currentTeam() };
765
895
  if (!sub) {
766
896
  if (!cfg.team) {
767
897
  const { teams } = await api.teams().catch((e) => die(e.message));
@@ -777,7 +907,7 @@ const commands = {
777
907
  if (sub === "create") {
778
908
  const name = rest.join(" ") || die("usage: passalong team create <name>");
779
909
  const t = await api.createTeam(name).catch((e) => die(e.message));
780
- store.writeConfig({ team: t.slug });
910
+ api.setTeam(t.slug);
781
911
  console.error(`team ${t.name} created as "${t.slug}" — now current. Invite people: passalong team invite [email]`);
782
912
  return;
783
913
  }
@@ -798,7 +928,7 @@ const commands = {
798
928
  const ref = rest[0] || die("usage: passalong team join <link|code>");
799
929
  const code = ref.includes("/") ? ref.split("/").filter(Boolean).pop() : ref;
800
930
  const r = await api.join(code).catch((e) => die(e.message));
801
- store.writeConfig({ team: r.slug });
931
+ api.setTeam(r.slug);
802
932
  console.error(`${r.joined ? "joined" : "already in"} ${r.name} (${r.slug}) — now current`);
803
933
  const who = await api.me().catch(() => null);
804
934
  if (who && !who.handle) console.error("teammates address you by handle: passalong me --handle <you>");
@@ -807,7 +937,7 @@ const commands = {
807
937
  if (sub === "use") {
808
938
  const slug = rest[0] || die("usage: passalong team use <slug>");
809
939
  await api.team(slug).catch((e) => die(e.message));
810
- store.writeConfig({ team: slug });
940
+ api.setTeam(slug);
811
941
  console.error(`current team: ${slug}`);
812
942
  return;
813
943
  }
@@ -889,6 +1019,19 @@ const commands = {
889
1019
  input = JSON.parse(raw || "{}");
890
1020
  }
891
1021
  if (!api.loggedIn()) return;
1022
+ // Two or more accounts and nothing saying which: the one thing worth telling the agent at the
1023
+ // start of a session, because every call after it would be refused until somebody chooses.
1024
+ if (f.hook === "session" && api.needsChoice()) {
1025
+ console.log(
1026
+ JSON.stringify({
1027
+ hookSpecificOutput: {
1028
+ hookEventName: "SessionStart",
1029
+ additionalContext: `Passalong: ${api.choiceMessage()}`,
1030
+ },
1031
+ }),
1032
+ );
1033
+ return;
1034
+ }
892
1035
  // The timeout is cleared as soon as the answer is in. Left running it holds the process open
893
1036
  // for the full five seconds after the hook has printed, and a session start or a stop that
894
1037
  // waits five seconds for nothing is the hook getting in the way, which it must never do.
@@ -938,6 +1081,15 @@ const commands = {
938
1081
 
939
1082
  const argv = flags(process.argv.slice(2));
940
1083
  const cmd = argv._.shift() || "help";
1084
+ // `--as <name>` says which of the accounts on this machine this one command is for. It exists for an
1085
+ // agent that was told which by the person, since it has no terminal to be asked at.
1086
+ if (typeof argv.as === "string") {
1087
+ try {
1088
+ api.chooseAccount(argv.as);
1089
+ } catch (e) {
1090
+ die(e.message);
1091
+ }
1092
+ }
941
1093
  // The first thing anyone runs when something is off, and until now it printed the help page. The
942
1094
  // parser only knows `--x`, so `--version` arrives as a flag on an empty command and `-v` as a word;
943
1095
  // both are caught here, with `version`, before dispatch. Only the number goes to stdout, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
5
5
  "mcpName": "io.github.codellyson/passalong",
6
6
  "keywords": [
@@ -14,7 +14,7 @@
14
14
  "mcp-server",
15
15
  "coding-agents",
16
16
  "task-management",
17
- "cursor"
17
+ "codex"
18
18
  ],
19
19
  "homepage": "https://passalong.dev",
20
20
  "repository": {
package/src/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Client for the hosted sync API (apps/api). Everything here is optional: with no token the
2
2
  // CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
3
3
  import { readFileSync } from "node:fs";
4
- import { readConfig } from "./store.js";
4
+ import { nameFor, readAccounts, readConfig, saveTeam } from "./store.js";
5
5
 
6
6
  /**
7
7
  * This build's version, sent on every authenticated call as `x-passalong-version`. The server
@@ -22,21 +22,133 @@ export class ApiError extends Error {
22
22
  }
23
23
  }
24
24
 
25
+ // ---- which account -----------------------------------------------------------------------------
26
+ //
27
+ // A machine may be signed in to several (store.js). Who a call is made as comes from, in order:
28
+ // PASSALONG_TOKEN, an account named by PASSALONG_ACCOUNT, `--as` or `use_account` (this process
29
+ // only), then the machine's default. With two or more logins and nobody having said which, a
30
+ // person at a terminal gets the default and an agent — a process with no terminal to ask on — is
31
+ // refused with the names, so it asks the person rather than choosing. That refusal is the prompt:
32
+ // an agent cannot be asked a question, but it can be told to put one to somebody.
33
+
34
+ let chosen = "";
35
+
36
+ const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
37
+
38
+ /** Names of the logins on this machine, each with what tells them apart. */
39
+ export function accountList() {
40
+ return Object.entries(readAccounts()).map(([name, a]) => ({
41
+ name,
42
+ handle: a.handle || "",
43
+ email: a.email || "",
44
+ api: a.api || "",
45
+ }));
46
+ }
47
+
48
+ /** Name an account for this process only, the way a person confirming one for a session does. */
49
+ export function chooseAccount(name) {
50
+ const all = readAccounts();
51
+ if (!all[name]) {
52
+ const names = Object.keys(all).join(", ");
53
+ throw new ApiError(
54
+ 0,
55
+ `no account called "${name}" on this machine${names ? `. Signed in: ${names}` : ""}`,
56
+ );
57
+ }
58
+ chosen = name;
59
+ return { name, ...all[name] };
60
+ }
61
+
62
+ /** Take back a choice made with chooseAccount: the session is undecided again. */
63
+ export function forgetChoice() {
64
+ chosen = "";
65
+ }
66
+
67
+ /** The account this process was told to use, or null when nobody said. */
68
+ function picked() {
69
+ const want = chosen || process.env.PASSALONG_ACCOUNT || "";
70
+ if (!want) return null;
71
+ const a = readAccounts()[want];
72
+ if (!a) {
73
+ const names = Object.keys(readAccounts()).join(", ");
74
+ throw new ApiError(0, `no account called "${want}" on this machine. Signed in: ${names}`);
75
+ }
76
+ return { name: want, ...a };
77
+ }
78
+
79
+ /** The name of the account in use, if there is one: for saying who a command is about to act as. */
80
+ export function accountInUse() {
81
+ if (process.env.PASSALONG_TOKEN) return "";
82
+ const p = picked();
83
+ if (p) return p.name;
84
+ return readConfig().active || "";
85
+ }
86
+
87
+ /** True when a call would have to guess which of several accounts is meant. */
88
+ export function needsChoice() {
89
+ if (process.env.PASSALONG_TOKEN || chosen || process.env.PASSALONG_ACCOUNT) return false;
90
+ return Object.keys(readAccounts()).length >= 2 && !interactive();
91
+ }
92
+
93
+ /** What an agent is told when it would have to guess. It is written to be passed on to a person. */
94
+ export function choiceMessage() {
95
+ const list = accountList()
96
+ .map((a) => {
97
+ const who = a.email || (a.handle ? `@${a.handle}` : "");
98
+ return who && who !== a.name ? `${a.name} (${who})` : a.name;
99
+ })
100
+ .join(", ");
101
+ return (
102
+ `More than one Passalong account is signed in on this machine (${list}) and nothing says which ` +
103
+ "this session is for. Ask the person which one to use, then call use_account with its name " +
104
+ "(or run the command with --as <name>). Do not choose one yourself."
105
+ );
106
+ }
107
+
25
108
  export function baseUrl() {
26
- return (process.env.PASSALONG_API || readConfig().api || DEFAULT_API).replace(/\/$/, "");
109
+ const base = process.env.PASSALONG_API || picked()?.api || readConfig().api || DEFAULT_API;
110
+ return base.replace(/\/$/, "");
27
111
  }
28
112
 
29
113
  export function token() {
30
- return process.env.PASSALONG_TOKEN || readConfig().token || null;
114
+ return process.env.PASSALONG_TOKEN || picked()?.token || readConfig().token || null;
31
115
  }
32
116
 
33
117
  export function loggedIn() {
34
118
  return Boolean(token());
35
119
  }
36
120
 
121
+ /** The team this account works in. It belongs to the account, not the machine. */
122
+ export function currentTeam() {
123
+ const p = picked();
124
+ return (p ? p.team : readConfig().team) || "";
125
+ }
126
+
127
+ export function setTeam(slug) {
128
+ saveTeam(picked()?.name || readConfig().active || "", slug);
129
+ }
130
+
131
+ /** Who a token is, without it being the one this process uses: how a login is named before it is kept. */
132
+ export async function meWith(tokenValue, base = baseUrl()) {
133
+ let res;
134
+ try {
135
+ res = await fetch(`${base}/v1/me`, {
136
+ headers: { ...CLIENT, authorization: `Bearer ${tokenValue}` },
137
+ });
138
+ } catch (err) {
139
+ throw new ApiError(0, `could not reach ${base} (${err.message})`);
140
+ }
141
+ const said = await res.json().catch(() => ({}));
142
+ if (!res.ok) throw new ApiError(res.status, said.message || res.statusText);
143
+ return said;
144
+ }
145
+
146
+ export { nameFor };
147
+
37
148
  async function call(path, { method = "GET", body, auth = true, raw = false } = {}) {
38
149
  const headers = { ...CLIENT };
39
150
  if (auth) {
151
+ if (needsChoice()) throw new ApiError(409, choiceMessage());
40
152
  const t = token();
41
153
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
42
154
  headers.authorization = `Bearer ${t}`;
@@ -126,6 +238,32 @@ export async function signIn(email, password, { make = false, label = "" } = {})
126
238
  * still live and still right for the invite page, which mints before it claims, and because
127
239
  * removing a published export is a breaking change for anyone who imported it.
128
240
  */
241
+ /**
242
+ * Sign this machine in from the browser. `challenge` is the sha256, in hex, of a secret kept here;
243
+ * what comes back is a short code to show, the page to open, and how often to ask whether it was
244
+ * approved. Nothing in the answer is a credential.
245
+ */
246
+ export const deviceStart = (challenge, label) =>
247
+ call("/v1/device/start", { method: "POST", auth: false, body: { challenge, label } });
248
+
249
+ /** `null` while nobody has said yes; the account and its token once somebody has. */
250
+ export async function devicePoll(id, verifier) {
251
+ let res;
252
+ try {
253
+ res = await fetch(`${baseUrl()}/v1/device/poll`, {
254
+ method: "POST",
255
+ headers: { ...CLIENT, "content-type": "application/json" },
256
+ body: JSON.stringify({ id, verifier }),
257
+ });
258
+ } catch (err) {
259
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
260
+ }
261
+ if (res.status === 202) return null;
262
+ const said = await res.json().catch(() => ({}));
263
+ if (!res.ok) throw new ApiError(res.status, said.message || res.statusText);
264
+ return said;
265
+ }
266
+
129
267
  export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
130
268
  export const me = () => call("/v1/me");
131
269
  export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
@@ -238,6 +376,7 @@ export const ack = (id, taken, note = "") =>
238
376
  * percent-encoded because a header cannot hold what a filename can.
239
377
  */
240
378
  export async function uploadFile(bytes, type, name = "") {
379
+ if (needsChoice()) throw new ApiError(409, choiceMessage());
241
380
  const t = token();
242
381
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
243
382
  const headers = {
package/src/guide.js CHANGED
@@ -709,3 +709,101 @@ function taskTemplate(meta = {}) {
709
709
  export function stripPlaceholders(body) {
710
710
  return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();
711
711
  }
712
+
713
+ // ---- reading a guide in parts, and how big one may be -----------------------------------------
714
+ // Mirrors apps/api/src/sections.ts — apps/api/test/sections.test.mjs holds the two to the same
715
+ // answers. Most of what is stored is a few very large guides (six of 123 held 72% of the text), and
716
+ // an agent that wants only the Acceptance of a long task should not read the rest first.
717
+
718
+ /** Over this a guide is large: its readers are offered the outline first, and its author a warning. */
719
+ export const GUIDE_WARN = 20_000;
720
+ /** Over this a new guide is refused by the server. A guide stored before it existed may keep its size. */
721
+ export const GUIDE_MAX = 60_000;
722
+
723
+ /** Where each heading starts, outside the frontmatter and outside code fences. */
724
+ function headingLines(markdown) {
725
+ const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(markdown);
726
+ const bodyStart = fm ? fm[0].length : 0;
727
+ const lines = [];
728
+ let fence = "";
729
+ let at = bodyStart;
730
+ for (const raw of markdown.slice(bodyStart).split("\n")) {
731
+ const mark = /^\s*(```|~~~)/.exec(raw)?.[1];
732
+ if (mark) fence = fence ? (fence === mark ? "" : fence) : mark;
733
+ const m = !fence && !mark ? /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw) : null;
734
+ if (m) lines.push({ heading: m[2].trim(), level: m[1].length, start: at });
735
+ at += raw.length + 1;
736
+ }
737
+ return lines;
738
+ }
739
+
740
+ /** The end of the section that begins at lines[i]: the next heading at its level or above. */
741
+ function sectionEnd(lines, i, total) {
742
+ for (let j = i + 1; j < lines.length; j++)
743
+ if (lines[j].level <= lines[i].level) return lines[j].start;
744
+ return total;
745
+ }
746
+
747
+ /** Every heading, with the characters in its section (nested ones included). */
748
+ export function outline(markdown) {
749
+ const lines = headingLines(markdown);
750
+ return lines.map((l, i) => ({
751
+ heading: l.heading,
752
+ level: l.level,
753
+ chars: sectionEnd(lines, i, markdown.length) - l.start,
754
+ }));
755
+ }
756
+
757
+ /** One section by its heading — exact, then by its start, then by what it contains. */
758
+ export function section(markdown, name) {
759
+ const lines = headingLines(markdown);
760
+ const want = String(name ?? "")
761
+ .trim()
762
+ .toLowerCase();
763
+ if (!want) return null;
764
+ const at = (test) => lines.findIndex((l) => test(l.heading.toLowerCase()));
765
+ const i = [
766
+ at((h) => h === want),
767
+ at((h) => h.startsWith(want)),
768
+ at((h) => h.includes(want)),
769
+ ].find((n) => n >= 0);
770
+ if (i === undefined) return null;
771
+ return {
772
+ heading: lines[i].heading,
773
+ text: markdown.slice(lines[i].start, sectionEnd(lines, i, markdown.length)).trimEnd(),
774
+ };
775
+ }
776
+
777
+ /** What a guide's author is told when it is on the large side. */
778
+ export function sizeWarning(chars) {
779
+ return chars > GUIDE_WARN
780
+ ? `this guide is ${chars.toLocaleString("en")} characters, and over ${GUIDE_WARN.toLocaleString("en")} ` +
781
+ "it is read by outline first. Put logs, dumps and long output in a file (attach_file) and " +
782
+ "link it; keep the guide to what the next agent has to act on."
783
+ : null;
784
+ }
785
+
786
+ /**
787
+ * What an agent is shown instead of a large guide: who it is, what it is, and where its parts are.
788
+ * `path` is where the whole of it was written, for a search rather than a read.
789
+ */
790
+ export function outlineText(markdown, { id = "", path = "" } = {}) {
791
+ const { meta } = parse(markdown);
792
+ const rows = outline(markdown)
793
+ .filter((h) => h.level <= 3)
794
+ .map(
795
+ (h) => `${" ".repeat(h.level - 1)}- ${h.heading} (${h.chars.toLocaleString("en")} chars)`,
796
+ );
797
+ return [
798
+ `${meta.title || id} — ${meta.kind || "guide"}, ${markdown.length.toLocaleString("en")} characters`,
799
+ meta.summary ? `Summary: ${meta.summary}` : "",
800
+ "",
801
+ "Sections:",
802
+ ...rows,
803
+ "",
804
+ `Read one with get_guide ref=${id} section="<heading>", or the whole thing with full=true.` +
805
+ (path ? ` The full text is also on disk at ${path}, if you only need to search it.` : ""),
806
+ ]
807
+ .filter((l, i, a) => l !== "" || a[i - 1] !== "")
808
+ .join("\n");
809
+ }
package/src/mcp.js CHANGED
@@ -9,7 +9,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
9
  import { z } from "zod";
10
10
  import * as api from "./api.js";
11
11
  import { refusal, runChecks } from "./checks.js";
12
- import { AREAS, BUG_SECTIONS, parse, SECTIONS, TASK_SECTIONS, template } from "./guide.js";
12
+ import {
13
+ AREAS,
14
+ BUG_SECTIONS,
15
+ GUIDE_WARN,
16
+ outlineText,
17
+ parse,
18
+ SECTIONS,
19
+ section as sectionOf,
20
+ sizeWarning,
21
+ TASK_SECTIONS,
22
+ template,
23
+ } from "./guide.js";
13
24
  import * as passalong from "./passalong.js";
14
25
 
15
26
  /**
@@ -396,7 +407,7 @@ export function buildServer() {
396
407
  if (h)
397
408
  return fail({
398
409
  message:
399
- `${err.message}. Somebody else is on it here${h.note ? ` (last said: "${h.note}")` : ""}. ` +
410
+ `${err.message}. Somebody else is on it here${h.agent ? ` (agent ${h.agent}${h.host ? ` on ${h.host}` : ""})` : ""}${h.note ? ` (last said: "${h.note}")` : ""}. ` +
400
411
  "Tell the user before doing this work too; get_guide reads it without taking it.",
401
412
  });
402
413
  return failWith(err);
@@ -787,36 +798,44 @@ export function buildServer() {
787
798
  ),
788
799
  checks: z
789
800
  .array(
790
- z.object({
791
- check: z
792
- .string()
793
- .describe(
794
- "the line this answers, in the guide's own words: an Acceptance line on a task, " +
795
- "a Verification line on a handoff or a bug",
796
- ),
797
- says: z
798
- .string()
799
- .optional()
800
- .describe(
801
- 'what happened, in one plain sentence for a PERSON: "Swagger lists all six ' +
802
- 'endpoints, each with request and response schemas." It is the row they read ' +
803
- "in the review table; the evidence stays folded behind it. Strongly " +
804
- "recommended: a check without one shows only the line it answers",
805
- ),
806
- ran: z
807
- .string()
808
- .optional()
809
- .describe("what you ran for it, and what came back; not needed when `cmd` is set"),
810
- cmd: z
811
- .string()
812
- .optional()
813
- .describe(
814
- "the shell command that proves this line, run here before the hand-in lands — " +
815
- "its exit code decides the check and its output is recorded as the evidence. " +
816
- "Non-zero refuses the hand-in. Leave it out for a check nobody can run, like " +
817
- '"the badge reads 3" — then SHOW it with attach_screenshot and put the line it returns in `ran`; a check is run or shown, and describing what you saw is refused',
818
- ),
819
- }),
801
+ // A bare string is a check line with nothing behind it. Let it through to the server's
802
+ // refusal, which says what to send, rather than a schema error that says only "expected
803
+ // object".
804
+ z.preprocess(
805
+ (v) => (typeof v === "string" ? { check: v } : v),
806
+ z.object({
807
+ check: z
808
+ .string()
809
+ .describe(
810
+ "the line this answers, in the guide's own words: an Acceptance line on a task, " +
811
+ "a Verification line on a handoff or a bug",
812
+ ),
813
+ says: z
814
+ .string()
815
+ .optional()
816
+ .describe(
817
+ 'what happened, in one plain sentence for a PERSON: "Swagger lists all six ' +
818
+ 'endpoints, each with request and response schemas." It is the row they read ' +
819
+ "in the review table; the evidence stays folded behind it. Strongly " +
820
+ "recommended: a check without one shows only the line it answers",
821
+ ),
822
+ ran: z
823
+ .string()
824
+ .optional()
825
+ .describe(
826
+ "what you ran for it, and what came back; not needed when `cmd` is set",
827
+ ),
828
+ cmd: z
829
+ .string()
830
+ .optional()
831
+ .describe(
832
+ "the shell command that proves this line, run here before the hand-in lands — " +
833
+ "its exit code decides the check and its output is recorded as the evidence. " +
834
+ "Non-zero refuses the hand-in. Leave it out for a check nobody can run, like " +
835
+ '"the badge reads 3" — then SHOW it with attach_screenshot and put the line it returns in `ran`; a check is run or shown, and describing what you saw is refused',
836
+ ),
837
+ }),
838
+ ),
820
839
  )
821
840
  .optional()
822
841
  .describe(
@@ -928,7 +947,9 @@ export function buildServer() {
928
947
  title: "Get guide",
929
948
  annotations: READS,
930
949
  description:
931
- "READ a transfer guide by passalong id or share link and return its full markdown. Also " +
950
+ "READ a transfer guide by passalong id or share link and return its markdown. A guide over " +
951
+ "20,000 characters comes back as an outline (its headings and their sizes) unless you ask " +
952
+ "for `section`, for one part, or `full`, for all of it. Also " +
932
953
  "writes it to .passalong/<id>.md in the working directory so it survives the session. " +
933
954
  "Pulling a teammate's guide tells them the transfer landed. Use this to look at a guide " +
934
955
  "you have not committed to. If you are about to DO the work, call take instead: " +
@@ -940,12 +961,38 @@ export function buildServer() {
940
961
  .string()
941
962
  .optional()
942
963
  .describe("directory to write .passalong/<id>.md into; default is the server's cwd"),
964
+ section: z
965
+ .string()
966
+ .optional()
967
+ .describe("read only the part under this heading, e.g. Acceptance; see the outline"),
968
+ outline: z
969
+ .boolean()
970
+ .optional()
971
+ .describe("just the headings and their sizes, whatever the guide's length"),
972
+ full: z
973
+ .boolean()
974
+ .optional()
975
+ .describe("the whole document, even one over 20,000 characters"),
943
976
  },
944
977
  },
945
- async ({ ref, cwd }) => {
978
+ async ({ ref, cwd, section, outline, full }) => {
946
979
  try {
947
980
  const { markdown, path, from } = await passalong.pull(ref, { cwd: cwd || process.cwd() });
948
981
  const meta = parse(markdown).meta;
982
+ if (section) {
983
+ const part = sectionOf(markdown, section);
984
+ if (!part)
985
+ return text(
986
+ `No heading matching "${section}".\n\n${outlineText(markdown, { id: meta.id || ref, path })}`,
987
+ );
988
+ return text(
989
+ `${part.text}\n\n<!-- passalong: ${meta.id || ref}, "${part.heading}" only; the whole guide is at ${path} -->`,
990
+ );
991
+ }
992
+ if (outline || (!full && markdown.length > GUIDE_WARN))
993
+ return text(
994
+ `${outlineText(markdown, { id: meta.id || ref, path })}\n\n<!-- passalong: ${from}; written to ${path} -->`,
995
+ );
949
996
  const lead = leadFor(meta);
950
997
  const siblings = await related(meta);
951
998
  // Follow-ups are more context for this guide, so they come with it — after the document,
@@ -1144,6 +1191,7 @@ export function buildServer() {
1144
1191
  parent: guide.meta.parent || "",
1145
1192
  notified,
1146
1193
  path,
1194
+ ...(sizeWarning(markdown.length) ? { warning: sizeWarning(markdown.length) } : {}),
1147
1195
  // A task does not go live on publish, and an agent that reports "queued it" has told
1148
1196
  // the user something untrue. Said in the result so it is said to them.
1149
1197
  ...(guide.meta.kind === "task" && guide.meta.status === "draft"
@@ -1274,6 +1322,59 @@ export function buildServer() {
1274
1322
  async ({ kind }) => text(template(kind ? { kind } : {})),
1275
1323
  );
1276
1324
 
1325
+ server.registerTool(
1326
+ "accounts",
1327
+ {
1328
+ title: "Accounts on this machine",
1329
+ annotations: READS,
1330
+ description:
1331
+ "The Passalong accounts signed in on this machine, which one this session is using, and " +
1332
+ "whether it still has to be chosen. With two or more and nothing chosen, every call that " +
1333
+ "needs an account is refused until use_account says which. Read-only.",
1334
+ inputSchema: {},
1335
+ },
1336
+ async () => {
1337
+ const list = api.accountList();
1338
+ return json({
1339
+ accounts: list,
1340
+ in_use: api.accountInUse(),
1341
+ must_choose: api.needsChoice(),
1342
+ ...(api.needsChoice() ? { next: api.choiceMessage() } : {}),
1343
+ });
1344
+ },
1345
+ );
1346
+
1347
+ server.registerTool(
1348
+ "use_account",
1349
+ {
1350
+ title: "Use an account for this session",
1351
+ annotations: { ...ADDS, idempotentHint: true },
1352
+ description:
1353
+ "Say which signed-in account this session acts as. Call it only with a name the PERSON " +
1354
+ "gave you: when a machine has more than one account and none is chosen, ask them which, " +
1355
+ "in plain words, then pass their answer. It lasts until this session ends and changes " +
1356
+ "nothing on disk, so it never moves the machine's default.",
1357
+ inputSchema: {
1358
+ name: z.string().describe("an account name from the accounts tool, as the person said it"),
1359
+ },
1360
+ },
1361
+ async ({ name }) => {
1362
+ try {
1363
+ api.chooseAccount(name);
1364
+ const who = await api.me();
1365
+ return json({
1366
+ using: name,
1367
+ account: who.account,
1368
+ handle: who.handle || "",
1369
+ email: who.email || "",
1370
+ teams: (who.teams || []).map((t) => t.slug),
1371
+ });
1372
+ } catch (err) {
1373
+ return fail(err);
1374
+ }
1375
+ },
1376
+ );
1377
+
1277
1378
  server.registerTool(
1278
1379
  "set_guide_status",
1279
1380
  {
package/src/passalong.js CHANGED
@@ -55,6 +55,14 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
55
55
  const target = parseTarget(to);
56
56
  if (target.team) guide.meta.team = target.team;
57
57
  if (target.to) guide.meta.to = target.to;
58
+ if (
59
+ !guide.meta.team &&
60
+ (guide.meta.kind === "bug" || guide.meta.kind === "task") &&
61
+ api.loggedIn()
62
+ ) {
63
+ const team = api.currentTeam();
64
+ if (team) guide.meta.team = team;
65
+ }
58
66
  // `follows` names the guide this one came out of: an id, a share link, or a pulled .md file. An id
59
67
  // is used as it is; a link or a file is read for the id in its own frontmatter. `validate()` below
60
68
  // is what refuses a guide that names itself.
@@ -111,10 +119,11 @@ export async function fileBugs(
111
119
  if (!api.loggedIn())
112
120
  throw new PassalongError("filing a report needs sync — run `passalong login` first");
113
121
  const target = parseTarget(to);
122
+ const team = target.team || api.currentTeam();
114
123
  const { report } = await api.createReport({
115
124
  title,
116
125
  environment,
117
- team: target.team || "",
126
+ team,
118
127
  to: target.to || "",
119
128
  });
120
129
 
@@ -139,7 +148,7 @@ export async function fileBugs(
139
148
  .join(", ")}) before failing: ${err.message}`,
140
149
  );
141
150
  }
142
- return { report: { id: report.id, title, environment }, issues: filed };
151
+ return { report: { id: report.id, title, environment, team }, issues: filed };
143
152
  }
144
153
 
145
154
  /** Resolve a reference (id, share URL, or local file path) to a guide. */
package/src/store.js CHANGED
@@ -44,6 +44,102 @@ export function writeConfig(patch) {
44
44
  return next;
45
45
  }
46
46
 
47
+ // ---- accounts on this machine -----------------------------------------------------------------
48
+ //
49
+ // One machine used to hold one login: a single `token` and `api` in the config, so every repo and
50
+ // every agent acted as whoever signed in last, and a second `login` overwrote the first. They are
51
+ // now kept by name under `accounts`, with `active` naming the one a person at a terminal gets by
52
+ // default. `token`, `api` and `team` stay at the top of the file as a copy of the active account,
53
+ // because that is where every older reader — and every older CLI sharing this directory — looks.
54
+
55
+ /** Every login on this machine by name. Empty on a config from before there were several. */
56
+ export function readAccounts(cfg = readConfig()) {
57
+ return cfg.accounts && typeof cfg.accounts === "object" ? cfg.accounts : {};
58
+ }
59
+
60
+ const slug = (s) =>
61
+ String(s ?? "")
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9._-]+/g, "-")
64
+ .replace(/^-+|-+$/g, "")
65
+ .slice(0, 32);
66
+
67
+ /** What a login is called when its owner has not said: their @handle, else the start of their email. */
68
+ export function nameFor({ handle, email, account }) {
69
+ return slug(handle) || slug(String(email ?? "").split("@")[0]) || slug(account) || "account";
70
+ }
71
+
72
+ /** Make `name` the default: what a person at a terminal gets, and what older readers see. */
73
+ export function setActive(name) {
74
+ const entry = readAccounts()[name];
75
+ if (!entry) throw new Error(`no account called "${name}" on this machine`);
76
+ return writeConfig({
77
+ active: name,
78
+ token: entry.token,
79
+ api: entry.api,
80
+ team: entry.team || "",
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Keep a login under a name. A login of an account already here is updated in place rather than
86
+ * added twice; a new one takes `nameFor`, with a number on the end if that is taken. The first
87
+ * login on a machine becomes the default, and so does any that `activate` asks for.
88
+ */
89
+ export function saveAccount(entry, { activate = false } = {}) {
90
+ const cfg = readConfig();
91
+ const all = { ...readAccounts(cfg) };
92
+ const same = Object.entries(all).find(
93
+ ([, a]) => entry.account && a.account === entry.account && a.api === entry.api,
94
+ );
95
+ let name = same?.[0];
96
+ if (!name) {
97
+ const base = nameFor(entry);
98
+ name = base;
99
+ for (let i = 2; all[name]; i++) name = `${base}-${i}`;
100
+ }
101
+ all[name] = { ...(same?.[1] ?? {}), ...entry };
102
+ writeConfig({ accounts: all });
103
+ if (activate || !cfg.active || !readAccounts(cfg)[cfg.active]) setActive(name);
104
+ else if (cfg.active === name) setActive(name);
105
+ return name;
106
+ }
107
+
108
+ /** Set the team an account works in, and mirror it when that account is the default. */
109
+ export function saveTeam(name, team) {
110
+ const cfg = readConfig();
111
+ const all = readAccounts(cfg);
112
+ if (name && all[name]) {
113
+ writeConfig({ accounts: { ...all, [name]: { ...all[name], team } } });
114
+ if (cfg.active === name) writeConfig({ team });
115
+ } else writeConfig({ team });
116
+ }
117
+
118
+ /**
119
+ * Forget a login. If it was the default, the first one left takes over — said to the person by the
120
+ * caller, because a default that changes silently is the thing this exists to stop.
121
+ */
122
+ export function removeAccount(name) {
123
+ const cfg = readConfig();
124
+ const all = { ...readAccounts(cfg) };
125
+ if (!all[name]) return { removed: false, active: cfg.active || "" };
126
+ delete all[name];
127
+ writeConfig({ accounts: all });
128
+ if (cfg.active !== name) return { removed: true, active: cfg.active || "" };
129
+ const next = Object.keys(all)[0];
130
+ if (next) {
131
+ setActive(next);
132
+ return { removed: true, active: next };
133
+ }
134
+ const bare = readConfig();
135
+ delete bare.active;
136
+ delete bare.token;
137
+ delete bare.team;
138
+ delete bare.accounts;
139
+ writeFileSync(CONFIG, `${JSON.stringify(bare, null, 2)}\n`, { mode: 0o600 });
140
+ return { removed: true, active: "" };
141
+ }
142
+
47
143
  export function guidePath(id) {
48
144
  return join(GUIDES, `${id}.md`);
49
145
  }