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 +2 -2
- package/bin/passalong +288 -20
- package/package.json +1 -1
- package/skill/SKILL.md +28 -1
- package/src/api.js +75 -1
- package/src/checks.js +127 -0
- package/src/guide.js +27 -5
- package/src/hooks.js +52 -5
- package/src/mcp.js +256 -135
- package/src/passalong.js +181 -20
- package/src/update.js +116 -0
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
|
|
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]
|
|
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
|
|
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]
|
|
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
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
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
|
|
455
|
-
const id = f._[0] || die("usage: passalong
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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" ||
|
|
679
|
-
|
|
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
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.
|
|
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
|
-
/**
|
|
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 });
|