passalong 0.5.0 → 0.8.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
@@ -35,11 +35,11 @@ passalong open <id> [--print] view a guide in the browser (or the terminal)
35
35
  passalong hub open your synced guides in the browser
36
36
  passalong works <id> you tried it and it holds up
37
37
  passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
38
- passalong done <id> deprecated: mark consumed
38
+ passalong archive <id> off your board and out of the free tier's count (was `done`)
39
39
  passalong promote <id> deprecated: mark promoted
40
40
  passalong rm <id> delete a guide locally and from sync
41
41
  passalong export [dir] dump every guide as plain markdown
42
- passalong login [token] create an account, or attach this machine with a token from your hub
42
+ passalong login [token] sign in with your email and password, or attach this machine with a token
43
43
  passalong me [--handle H] [--name N] [--email E] who you are to teammates
44
44
  passalong team current team and its members
45
45
  passalong team create <name> start a team (you become its owner)
package/bin/passalong CHANGED
@@ -25,12 +25,20 @@
25
25
  // passalong hub open your synced guides in the browser
26
26
  // passalong take <id> you are doing it — said before the work, so the sender stops guessing
27
27
  // passalong pass <id> <why> it is not yours — hands it back to its author with your reason
28
+ // passalong assign <id> <to> give it to @handle, a #group, or "team" — yours, or assigned to you
29
+ // passalong admin create <email> make an account that runs Passalong (owner only)
30
+ // passalong admin list who runs it
31
+ // passalong admin grant|revoke <@handle> make an existing account an admin, or stop it being one
32
+ // passalong gift <who> --until <date> [--why "..."] [--seats N]
33
+ // give Solo to @handle, or Team to team/slug, until a date (operator only)
34
+ // passalong gifts what has been given away, and what is still running
35
+ // passalong ungift <gift id> take one back now
28
36
  // passalong works <id> you tried it and it holds up
29
37
  // passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
30
- // passalong done <id> archive it: off your board and out of the free tier's count
38
+ // passalong archive <id> off your board and out of the free tier's count (was `done`)
31
39
  // passalong rm <id> delete a guide locally and from sync
32
40
  // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
33
- // passalong login [token] create an account, or attach this machine with a token from your hub
41
+ // passalong login [token] sign in with your email and password, or attach this machine with a token
34
42
  // passalong me [--handle H] [--name N] [--email E] who you are to teammates
35
43
  // passalong team current team and its members
36
44
  // passalong team create <name> start a team (you become its owner)
@@ -41,9 +49,10 @@
41
49
  // passalong now what this worktree holds and what is waiting (the hooks call --hook)
42
50
  // passalong mcp run the MCP server over stdio
43
51
  // passalong version print the installed version (also -v, --version)
44
- import { execFileSync, spawnSync } from "node:child_process";
52
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
53
+ import { createHash } from "node:crypto";
45
54
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
46
- import { homedir } from "node:os";
55
+ import { homedir, hostname } from "node:os";
47
56
  import { dirname, join, resolve } from "node:path";
48
57
  import { fileURLToPath } from "node:url";
49
58
  import * as api from "../src/api.js";
@@ -74,6 +83,38 @@ function die(msg) {
74
83
 
75
84
  const tty = process.stdout.isTTY && process.stdin.isTTY;
76
85
 
86
+ /**
87
+ * One line typed at the terminal. `hidden` turns the echo off, so a password does not end up in a
88
+ * screen share, a scrollback or somebody's terminal recording.
89
+ *
90
+ * Written against readline rather than a dependency because this package has none beyond the MCP
91
+ * SDK and zod, and asking for a line of text is not where that changes.
92
+ */
93
+ async function ask(question, { hidden = false } = {}) {
94
+ const { createInterface } = await import("node:readline");
95
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
96
+ if (hidden) {
97
+ // readline still writes the prompt itself; everything after it is swallowed. The newline the
98
+ // user's Return would have echoed is put back by hand, or the next line starts mid-prompt.
99
+ rl.output.write(question);
100
+ rl._writeToOutput = () => {};
101
+ }
102
+ try {
103
+ // `close` as well as an answer: stdin reaching EOF part-way through — a pipe, a closed pty, a
104
+ // ^D — never calls the question's callback, and without this the promise is simply never
105
+ // settled and the process exits on a warning about it rather than saying what happened.
106
+ const answer = await new Promise((done) => {
107
+ rl.on("close", () => done(null));
108
+ rl.question(hidden ? "" : question, done);
109
+ });
110
+ if (hidden) process.stdout.write("\n");
111
+ if (answer === null) die("nothing to read from — sign in at the terminal, or pass a token");
112
+ return answer.trim();
113
+ } finally {
114
+ rl.close();
115
+ }
116
+ }
117
+
77
118
  function edit(path) {
78
119
  const editor = process.env.VISUAL || process.env.EDITOR || "vi";
79
120
  const r = spawnSync(editor, [path], { stdio: "inherit", shell: true });
@@ -130,6 +171,53 @@ const MONTHS = "January February March April May June July August September Octo
130
171
  /** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
131
172
  const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
132
173
 
174
+ // ---- the status line cache --------------------------------------------------------------------------
175
+
176
+ /** One small file per working directory, so two sessions in two repos each see their own. */
177
+ const statusPath = (cwd) =>
178
+ join(store.HOME, "statusline", `${createHash("sha1").update(cwd).digest("hex").slice(0, 16)}.json`);
179
+
180
+ function writeStatus(cwd, text) {
181
+ const p = statusPath(cwd);
182
+ mkdirSync(dirname(p), { recursive: true });
183
+ writeFileSync(p, JSON.stringify({ at: Date.now(), text }));
184
+ }
185
+
186
+ /** How old a cached line may be before a refresh is started behind it. */
187
+ const STATUS_TTL_MS = 30_000;
188
+
189
+ /**
190
+ * Print the cached line for this directory at once, and when it is stale start a detached refresh
191
+ * that writes the next one. The first run in a directory prints a placeholder; the refresh lands
192
+ * within a second or two, and the next update shows it. Offline or signed out, it says nothing.
193
+ */
194
+ async function statusLine() {
195
+ let input = {};
196
+ if (!process.stdin.isTTY) {
197
+ let raw = "";
198
+ for await (const chunk of process.stdin) raw += chunk;
199
+ try {
200
+ input = JSON.parse(raw || "{}");
201
+ } catch {}
202
+ }
203
+ if (!api.loggedIn()) return;
204
+ const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
205
+ let cached = null;
206
+ try {
207
+ cached = JSON.parse(readFileSync(statusPath(cwd), "utf8"));
208
+ } catch {}
209
+ process.stdout.write(cached?.text ?? "passalong");
210
+ if (!cached || Date.now() - cached.at > STATUS_TTL_MS) {
211
+ // Marked fresh before the refresh starts, so a burst of updates starts one refresh, not ten.
212
+ writeStatus(cwd, cached?.text ?? "passalong");
213
+ spawn(process.execPath, [fileURLToPath(import.meta.url), "now", "--statusline-refresh", cwd], {
214
+ detached: true,
215
+ stdio: "ignore",
216
+ env: process.env,
217
+ }).unref();
218
+ }
219
+ }
220
+
133
221
  const commands = {
134
222
  async share(f) {
135
223
  let path = f._[0];
@@ -430,6 +518,86 @@ const commands = {
430
518
  console.error(`${r.id} handed back — the author has been told: "${r.note}"`);
431
519
  },
432
520
 
521
+ async assign(f) {
522
+ const id = f._[0] || die('usage: passalong assign <id> <@handle | #group | team>');
523
+ const to = f._[1] || die('say who: @handle for one person, #group, or "team" for everyone');
524
+ const r = await passalong.assign(id, to).catch((e) => die(e.message));
525
+ const back = r.taken_back ? ` — taken back from ${r.taken_back} who had it, and they were told` : "";
526
+ console.error(`${r.id} is now for ${r.to || "everyone in the team"}${back}`);
527
+ },
528
+
529
+ // ---- who runs the product ---------------------------------------------------------------
530
+ // `passalong admin <create|list|grant|revoke>`. An admin signs in like anybody else: the role is
531
+ // a column, so there is no admin password and no admin token — only an account that has it.
532
+ // "admin" is the word everywhere a person reads; `super` stays the stored value.
533
+
534
+ async admin(f) {
535
+ const what = f._[0] || "list";
536
+ if (what === "create") {
537
+ const email = f._[1] || die("usage: passalong admin create <email>");
538
+ const r = await api.makeAdmin(email).catch((e) => die(e.message));
539
+ // Shown once, and only here. The link is a day long because it is handed to somebody
540
+ // rather than clicked on the spot.
541
+ // Three labelled values on stdout, so one can be cut out with awk. "password link", not
542
+ // "password": the value is a URL, and somebody will try to paste it into a password field.
543
+ console.log(`account ${r.account}`);
544
+ console.log(`token ${r.token}`);
545
+ console.log(`password link ${r.password_url}`);
546
+ console.error(
547
+ `\n${r.email} is an admin. The token is for \`passalong login\`, the link sets their ` +
548
+ `password for the hub, and it expires ${r.expires.slice(0, 10)}. Neither is shown again.`,
549
+ );
550
+ return;
551
+ }
552
+ if (what === "grant" || what === "revoke") {
553
+ const who = f._[1] || die(`usage: passalong admin ${what} <@handle | account id>`);
554
+ const call = what === "grant" ? api.grantAdmin : api.revokeAdmin;
555
+ const r = await call(who).catch((e) => die(e.message));
556
+ console.error(
557
+ what === "grant" ? `${r.name} is now an admin` : `${r.name} is no longer an admin`,
558
+ );
559
+ return;
560
+ }
561
+ if (what !== "list") die("usage: passalong admin <create <email> | list | grant | revoke>");
562
+ const { supers = [] } = await api.listAdmins().catch((e) => die(e.message));
563
+ if (!supers.length) return console.error("nobody is an admin yet");
564
+ for (const s of supers)
565
+ console.log(`${s.id} ${s.name}${s.email ? ` ${s.email}` : ""} since ${s.since.slice(0, 10)}`);
566
+ },
567
+
568
+ // ---- giving a plan away -----------------------------------------------------------------
569
+ // Only for whoever the deployment names in ADMIN_ACCOUNTS; for everybody else the routes behind
570
+ // these are 404, and the message says so rather than pretending the command does not exist.
571
+
572
+ async gift(f) {
573
+ const to = f._[0] || die('usage: passalong gift <@handle | team/slug> --until 2027-09-23 [--why "..."]');
574
+ const until = f.until || die("say when it ends: --until 2027-09-23. A gift with no end is a price change");
575
+ const r = await api
576
+ .giveGift({ to, until, why: f.why || "", seats: Number(f.seats || 0) })
577
+ .catch((e) => die(e.message));
578
+ // The seat count is said back whether or not it was asked for: left off, it is the number of
579
+ // members the team has today, and an operator should read which number that turned out to be.
580
+ const seats = r.kind === "team" ? ` for ${r.seats} seat${r.seats === 1 ? "" : "s"}` : "";
581
+ console.error(`${r.to} is on ${r.plan}${seats} until ${r.until.slice(0, 10)} — gift ${r.id}`);
582
+ },
583
+
584
+ async gifts() {
585
+ const { gifts = [] } = await api.listGifts().catch((e) => die(e.message));
586
+ if (!gifts.length) return console.error("no plans given away yet");
587
+ for (const g of gifts) {
588
+ const state = g.live ? `until ${g.until.slice(0, 10)}` : "over";
589
+ console.log(
590
+ `${g.id} ${g.plan.padEnd(5)} ${state.padEnd(16)} ${g.to}${g.why ? ` — ${g.why}` : ""}`,
591
+ );
592
+ }
593
+ },
594
+
595
+ async ungift(f) {
596
+ const id = f._[0] || die("usage: passalong ungift <gift id> (passalong gifts lists them)");
597
+ await api.takeGiftBack(id).catch((e) => die(e.message));
598
+ console.error(`${id} taken back — that plan is lapsed as of now`);
599
+ },
600
+
433
601
  async works(f) {
434
602
  const id = f._[0] || die("usage: passalong works <id>");
435
603
  const r = await passalong.verdict(id, true, f._.slice(1).join(" ")).catch((e) => die(e.message));
@@ -447,16 +615,29 @@ const commands = {
447
615
  /**
448
616
  * Archive a guide: off your board, and out of what the free tier counts.
449
617
  *
450
- * It was deprecated for meaning "I implemented it", which is the reader's judgement and belongs
451
- * to the verdict. As shelf space it has a job nothing else does — it is the only way under the
452
- * sync limit that does not delete the work, and the 402 says so by name.
618
+ * The command was `done`, and the status it sets is still `consumed`, because both once meant "I
619
+ * implemented it". That meaning went to the verdict, where it belongs — it is the reader's
620
+ * judgement, not the author's — and what was left is shelf space, which has a job nothing else
621
+ * does: the only way under the sync limit that does not delete the work, and the 402 says so by
622
+ * name. So the command says what it does now.
623
+ *
624
+ * `consumed` stays on disk. It is a status inside markdown files in other people's
625
+ * repositories, and `validate()` refuses a status it does not know, so renaming the value would
626
+ * make a guide shared a month ago fail to re-share today. The word people read and the token the
627
+ * format stores are allowed to differ; what is not allowed is two words for people.
453
628
  */
454
- async done(f) {
455
- const id = f._[0] || die("usage: passalong done <id>");
629
+ async archive(f) {
630
+ const id = f._[0] || die("usage: passalong archive <id>");
456
631
  const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
457
632
  console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
458
633
  },
459
634
 
635
+ /** What `archive` was called. Kept so a script written against it still runs, and says so. */
636
+ async done(f) {
637
+ console.error("`passalong done` is now `passalong archive` — it archives, it does not mark work done.");
638
+ return commands.archive(f);
639
+ },
640
+
460
641
  async rm(f) {
461
642
  const id = f._[0] || die("usage: passalong rm <id>");
462
643
  await passalong.remove(id).catch((e) => die(e.message));
@@ -485,14 +666,40 @@ const commands = {
485
666
  return;
486
667
  }
487
668
  }
488
- const { token, account } = await api.createAccount().catch((e) => die(e.message));
489
- store.writeConfig({ token, api: api.baseUrl() });
490
- console.error(`account ${account} created on ${api.baseUrl()}. Sync is on.`);
669
+ // An account you cannot sign in to is one that dies with this file. This used to mint one
670
+ // silently and then suggest adding a password afterwards; it asks up front instead.
671
+ if (!tty)
672
+ die(
673
+ "no terminal to ask on. Sign in at " +
674
+ `${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
675
+ );
676
+
677
+ const email = f.email || (await ask("Email: "));
678
+ if (!email) die("an email is needed to sign in");
679
+ const password = await ask("Password: ", { hidden: true });
680
+ if (!password) die("a password is needed to sign in");
681
+
682
+ const label = `${hostname()} cli`;
683
+ let who = await api.signIn(email, password, { label }).catch((e) => e);
684
+ if (who instanceof Error) {
685
+ // 401 is "no such account, or the wrong password", said the same way on purpose so the API
686
+ // does not confirm which addresses exist. The CLI has to ask rather than guess, because
687
+ // creating an account for a mistyped address is worse than being told to try again.
688
+ if (who.status !== 401) die(who.message);
689
+ const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
690
+ if (yes !== "y" && yes !== "yes")
691
+ die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
692
+ const again = await ask("Choose a password: ", { hidden: true });
693
+ if (again !== password) die("those did not match");
694
+ who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
695
+ }
696
+
697
+ store.writeConfig({ token: who.token, api: api.baseUrl() });
698
+ console.error(`signed in as ${email} (account ${who.account}) on ${api.baseUrl()}. Sync is on.`);
491
699
  console.error("Attach other machines with:");
492
- console.error(` passalong login ${token}`);
700
+ console.error(` passalong login ${who.token}`);
493
701
  console.error("(keep it private — it is a credential, not a password)");
494
702
  console.error("Give teammates a name to address you by: passalong me --handle <you>");
495
- console.error(`Add an email and password so you can sign in and recover it: ${api.baseUrl()}/hub`);
496
703
  },
497
704
 
498
705
  async me(f) {
@@ -598,14 +805,18 @@ const commands = {
598
805
  // over them, and a no-op when they are already there.
599
806
  const settingsPath = join(homedir(), ".claude", "settings.json");
600
807
  try {
601
- const { withHooks } = await import("../src/hooks.js");
808
+ const { withHooks, withStatusLine } = await import("../src/hooks.js");
602
809
  const before = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
603
- const after = withHooks(before);
810
+ const after = withStatusLine(withHooks(before));
811
+ if (before.statusLine && before.statusLine.command !== "passalong now --statusline")
812
+ console.error(
813
+ "you already have a status line, so it was left alone. To show Passalong in it, call `passalong now --statusline` from your script",
814
+ );
604
815
  if (JSON.stringify(after) === JSON.stringify(before)) console.error("Claude Code hooks already installed");
605
816
  else {
606
817
  mkdirSync(dirname(settingsPath), { recursive: true });
607
818
  writeFileSync(settingsPath, `${JSON.stringify(after, null, 2)}\n`);
608
- console.error(`installed Claude Code hooks (session start, stop) → ${settingsPath}`);
819
+ console.error(`installed Claude Code hooks (session start, stop) and status line → ${settingsPath}`);
609
820
  }
610
821
  } catch (err) {
611
822
  console.error(`could not install Claude Code hooks (${err.message}); ${settingsPath} left as it was`);
@@ -621,7 +832,18 @@ const commands = {
621
832
  * nothing at all when anything goes wrong — a hook must never get in the way of a session.
622
833
  */
623
834
  async now(f) {
624
- const { nowText, stopVerdict } = await import("../src/hooks.js");
835
+ const { nowText, statusText, stopVerdict } = await import("../src/hooks.js");
836
+ // The status line: printed from a cache, refreshed behind it. Claude Code cancels a status
837
+ // line still running when the next update comes, so this never waits on the network.
838
+ if (f.statusline) return statusLine(f);
839
+ if (f["statusline-refresh"]) {
840
+ const cwd = typeof f["statusline-refresh"] === "string" ? f["statusline-refresh"] : process.cwd();
841
+ try {
842
+ const r = await passalong.now({ cwd });
843
+ writeStatus(cwd, statusText({ held: r.held, needs: r.needs, ready: r.waiting.ready }));
844
+ } catch {}
845
+ return;
846
+ }
625
847
  if (!f.hook) {
626
848
  const r = await passalong.now();
627
849
  console.log(nowText(r) || "Nothing held here, and nothing waiting.");
@@ -659,6 +881,15 @@ const commands = {
659
881
  await serve();
660
882
  },
661
883
 
884
+ /**
885
+ * Ask the registry what the latest version is and write it down. Not in the help, because it is
886
+ * not for a person: `passalong` spawns it, detached, when the answer on file is a day old.
887
+ */
888
+ async ["refresh-update"]() {
889
+ const { refresh } = await import("../src/update.js");
890
+ await refresh();
891
+ },
892
+
662
893
  async help() {
663
894
  const src = readFileSync(fileURLToPath(import.meta.url), "utf8");
664
895
  const lines = src.split("\n").filter((l) => l.startsWith("// passalong "));
@@ -675,5 +906,42 @@ const cmd = argv._.shift() || "help";
675
906
  // `passalong -v` can be read by a script comparing it against the registry.
676
907
  if (cmd === "-v" || cmd === "version" || (cmd === "help" && argv.version === true)) {
677
908
  console.log(JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version);
678
- } else if (cmd === "--help" || cmd === "-h" || !commands[cmd]) commands.help();
679
- else await commands[cmd](argv).catch((e) => die(e.message));
909
+ } else if (cmd === "--help" || cmd === "-h" || cmd === "help") commands.help();
910
+ // A command this build does not have used to print the help page and exit 0, which reads as "that
911
+ // command exists and did nothing" — and the usual cause is an older passalong, where the command is
912
+ // in the docs and not in the binary. So it fails, names what it does not know, and says the version
913
+ // it is, which is the fact that explains it.
914
+ else if (!commands[cmd])
915
+ die(
916
+ `unknown command "${cmd}" — run \`passalong help\` for the list. ` +
917
+ `This is passalong ${JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version}; ` +
918
+ "if you expected this command, `npm i -g passalong` for the newest one.",
919
+ );
920
+ else {
921
+ await commands[cmd](argv).catch((e) => die(e.message));
922
+ // After the command, never before it: a version check is not worth a millisecond of somebody's
923
+ // first impression, and it must never be the reason a command felt slow. `mcp` is a server that
924
+ // does not return, and the hooks are read by an agent rather than a person — a line neither of
925
+ // them asked for is noise in a transcript.
926
+ if (!["mcp", "now", "refresh-update"].includes(cmd)) {
927
+ try {
928
+ const { cachedNotice } = await import("../src/update.js");
929
+ const current = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
930
+ const { notice, stale } = cachedNotice({ current });
931
+ if (notice) console.error(notice);
932
+ // The refresh is a process this one does not wait for: detached, output thrown away,
933
+ // unref'd so the exit is not held open by it. Measuring the alternative is what settled it —
934
+ // the registry took six to ten seconds to answer, so awaiting it meant every command paid
935
+ // that or the check silently never worked.
936
+ if (stale) {
937
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "refresh-update"], {
938
+ detached: true,
939
+ stdio: "ignore",
940
+ });
941
+ child.unref();
942
+ }
943
+ } catch {
944
+ // Nothing about being out of date is worth failing a command that already worked.
945
+ }
946
+ }
947
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
5
  "keywords": [
6
6
  "mcp",
package/skill/SKILL.md CHANGED
@@ -9,6 +9,31 @@ You are turning this session into a **transfer guide**: executable context for a
9
9
  different place. The reader is an AI agent (with a developer watching) that has none of this
10
10
  session's history. Write for execution, not for permanence.
11
11
 
12
+ ## Before you write anything
13
+
14
+ **Check what is already open, and whether a guide is wanted at all.** Run `passalong now` (or
15
+ `take` with no id over MCP) to see what this worktree holds and what was handed to you.
16
+
17
+ - **The session answers something you hold** — hand it in (`passalong hand_in <id> …`) instead.
18
+ Publishing a second guide about it leaves two records of one piece of work. Every hand-in carries
19
+ what you ran and what came back — the command and the lines that decided it, a test summary, a
20
+ link to the change, or a screenshot url. Copy each one out of the session as you go; at the end
21
+ you would be writing from memory, which is what evidence exists to replace. On a **task**, send
22
+ it as `checks`: one entry per Acceptance line, each with that line and the evidence for it. Its
23
+ author reads them line against line, and evidence filed under the check it answers is worth more
24
+ than the same output in one block. On a handoff or a bug, which have no Acceptance lines, send
25
+ `evidence` as one block.
26
+ - **The work never left this repo** — committed and pushed on a branch the team can already see,
27
+ with a pull request to review — then it has crossed no boundary, and a transfer guide is one more
28
+ thing for somebody to read. What is worth publishing from a session like that is what is *still
29
+ open*: file it as a bug (`kind: bug`) or a task, not as a write-up of the fix.
30
+ - **The ask is vague** — "update the passalong", "add this to passalong", "log this" — it may mean
31
+ hand in what you hold, add a follow-up to a guide, or publish something new. Ask which, in one
32
+ line. Publishing the wrong thing costs somebody a review, and the author a guide to delete.
33
+
34
+ A transfer guide earns its place when the work has to cross a boundary: another repo, another
35
+ machine, another agent session, or a teammate without your branch.
36
+
12
37
  ## Procedure
13
38
 
14
39
  1. **Pick the one thing to transfer.** If the user named it (`/passalong-capture <what>` or
@@ -37,7 +62,9 @@ session's history. Write for execution, not for permanence.
37
62
  **If what you are capturing is more context for an existing guide** — a missing detail, a step
38
63
  that needed explaining, what changed since, what you found doing it — add `--follows <that id>`.
39
64
  That publishes it as a follow-up: its own guide, listed under the original, and handed to
40
- whoever opens the original, person or agent. A session that started from a passalong guide
65
+ whoever opens the original, person or agent. It travels the other way too — whoever opens the
66
+ follow-up gets the guide it came out of, and where that guide has got to — so write it as a
67
+ note on that work rather than as a standalone brief. A session that started from a passalong guide
41
68
  (`passalong start` or `pull` on an id, or an agent opened one) usually has context worth adding
42
69
  this way. Whether the guide worked is still its own answer: `passalong works <id>` or
43
70
  `passalong broken <id> <why>`.
package/src/api.js CHANGED
@@ -63,7 +63,58 @@ const q = (params) => {
63
63
  return s ? `?${s}` : "";
64
64
  };
65
65
 
66
- /** Mint a fresh account and return its token. No email, no password: the token is the account. */
66
+ /**
67
+ * Sign in, or make the account, and come back with a token for this machine.
68
+ *
69
+ * Three calls, because the API answers a browser and this is not one: signing in sets a session
70
+ * cookie, and the CLI carries a bearer token instead — one that shows up in the hub's token list
71
+ * with a name on it, and can be revoked there without changing the password. So the cookie is used
72
+ * once, to mint the token, and then dropped.
73
+ *
74
+ * `make` is signup rather than sign-in. It is the caller's decision and not a fallback here: an
75
+ * account created because an address was mistyped is worse than being told there is no such
76
+ * account.
77
+ */
78
+ export async function signIn(email, password, { make = false, label = "" } = {}) {
79
+ const res = await fetch(`${baseUrl()}${make ? "/v1/auth/signup" : "/v1/auth/login"}`, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ email, password }),
83
+ }).catch((err) => {
84
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
85
+ });
86
+ if (!res.ok) {
87
+ const said = await res.json().catch(() => ({}));
88
+ throw new ApiError(res.status, said.message || res.statusText);
89
+ }
90
+ const { account } = await res.json();
91
+ // `getSetCookie` keeps the cookies apart; a joined `set-cookie` header cannot be split safely,
92
+ // because an Expires date has a comma in it.
93
+ const cookie = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
94
+ if (!cookie) throw new ApiError(500, "signed in, but no session came back");
95
+
96
+ const minted = await fetch(`${baseUrl()}/v1/tokens`, {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json", cookie },
99
+ body: JSON.stringify({ name: label || "cli" }),
100
+ });
101
+ if (!minted.ok) {
102
+ const said = await minted.json().catch(() => ({}));
103
+ throw new ApiError(minted.status, said.message || "could not make a token for this machine");
104
+ }
105
+ const { token: made } = await minted.json();
106
+ return { account, token: made };
107
+ }
108
+
109
+ /**
110
+ * Mint an account with no email and no password.
111
+ *
112
+ * Nothing in this package calls it any more — `passalong login` asks for an email and a password,
113
+ * because an account nobody can sign in to is one that dies with the file it is stored in, and the
114
+ * CLI used to make one without ever saying so. It stays exported because the route it calls is
115
+ * still live and still right for the invite page, which mints before it claims, and because
116
+ * removing a published export is a breaking change for anyone who imported it.
117
+ */
67
118
  export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
68
119
  export const me = () => call("/v1/me");
69
120
  export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
@@ -101,6 +152,26 @@ export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
101
152
  */
102
153
  export const children = (id, { markdown = false } = {}) =>
103
154
  call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
155
+ /**
156
+ * The guide this one came out of, with where it has got to. `markdown` adds its content. Like the
157
+ * follow-ups, reading it is not opening it: no pull is recorded on the parent.
158
+ */
159
+ export const parent = (id, { markdown = false } = {}) =>
160
+ call(`/v1/guides/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
161
+ /**
162
+ * Giving a plan away, for whoever the deployment names in `ADMIN_ACCOUNTS`. Every other account
163
+ * gets a 404 from these, so the CLI does not have to know who the operator is either.
164
+ */
165
+ export const makeAdmin = (email) => call("/v1/admin/accounts", { method: "POST", body: { email } });
166
+ export const listAdmins = () => call("/v1/admin/accounts");
167
+ export const grantAdmin = (who) =>
168
+ call(`/v1/admin/accounts/${encodeURIComponent(who)}`, { method: "PUT", body: {} });
169
+ export const revokeAdmin = (who) =>
170
+ call(`/v1/admin/accounts/${encodeURIComponent(who)}`, { method: "DELETE" });
171
+ export const giveGift = (body) => call("/v1/admin/gifts", { method: "POST", body });
172
+ export const listGifts = () => call("/v1/admin/gifts");
173
+ export const takeGiftBack = (id) =>
174
+ call(`/v1/admin/gifts/${encodeURIComponent(id)}`, { method: "DELETE" });
104
175
  export const setStatus = (id, status) =>
105
176
  call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
106
177
  export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
@@ -122,6 +193,9 @@ export const releaseTask = (id) =>
122
193
  call(`/v1/tasks/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
123
194
  /** The four verbs every guide answers to. See docs/V2.md §11. */
124
195
  export const working = () => call("/v1/working");
196
+ export const handedIn = () => call("/v1/handed_in");
197
+ export const assign = (id, to) =>
198
+ call(`/v1/guides/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
125
199
  export const take = (body) => call("/v1/take", { method: "POST", body });
126
200
  export const progress = (id, body) =>
127
201
  call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });