passalong 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -5
- package/bin/passalong +194 -19
- package/package.json +2 -2
- package/skill/SKILL.md +15 -6
- package/src/api.js +29 -3
- package/src/guide.js +2 -0
- package/src/mcp.js +133 -22
- package/src/passalong.js +81 -19
package/README.md
CHANGED
|
@@ -25,25 +25,72 @@ passalong login # optional: sync guides across machines and get share links
|
|
|
25
25
|
## Commands
|
|
26
26
|
|
|
27
27
|
```
|
|
28
|
-
passalong share [file]
|
|
29
|
-
passalong pull <id|link> fetch a guide into ./.passalong/ and print it
|
|
30
|
-
passalong
|
|
28
|
+
passalong share [file] [--to team[/handle]] publish a guide → id + link; --to hands it to a team or teammate
|
|
29
|
+
passalong pull <id|link> fetch a guide into ./.passalong/ (git-ignored) and print it
|
|
30
|
+
passalong inbox guides handed to you that you have not pulled yet
|
|
31
|
+
passalong board waiting on you, in flight, landed, worth keeping
|
|
32
|
+
passalong activity [--all] what happened while you were away; clears unless --all
|
|
33
|
+
passalong list [query] your guides and your teams', local and synced
|
|
31
34
|
passalong open <id> [--print] view a guide in the browser (or the terminal)
|
|
32
35
|
passalong hub open your synced guides in the browser
|
|
36
|
+
passalong works <id> you tried it and it holds up
|
|
37
|
+
passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
33
38
|
passalong done <id> mark consumed: implemented on the receiving side
|
|
34
39
|
passalong promote <id> mark promoted: graduated into a reusable reference
|
|
35
40
|
passalong rm <id> delete a guide locally and from sync
|
|
36
41
|
passalong export [dir] dump every guide as plain markdown
|
|
37
|
-
passalong login [token] create an account, or attach this machine
|
|
42
|
+
passalong login [token] create an account, or attach this machine with a token from your hub
|
|
43
|
+
passalong me [--handle H] [--name N] [--email E] who you are to teammates
|
|
44
|
+
passalong team current team and its members
|
|
45
|
+
passalong team create <name> start a team (you become its owner)
|
|
46
|
+
passalong team invite [email] make an invite link (mailed when an email is given)
|
|
47
|
+
passalong team join <link> accept an invite
|
|
48
|
+
passalong team use <slug> switch the current team
|
|
38
49
|
passalong setup install the Claude Code capture skill + register the MCP server
|
|
39
50
|
passalong mcp run the MCP server over stdio
|
|
40
51
|
```
|
|
41
52
|
|
|
53
|
+
## Teams
|
|
54
|
+
|
|
55
|
+
A team is the unit of sharing. Members can find, pull, and mark consumed every guide shared to
|
|
56
|
+
the team; a guide can also be handed to one person, who sees it in their inbox (and by email
|
|
57
|
+
when the server has mail configured).
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
passalong me --handle lukman # how teammates address you
|
|
61
|
+
passalong team create Khaime # you become owner; "khaime" is now current
|
|
62
|
+
passalong team invite ada@example.com # or no email: prints a link to send yourself
|
|
63
|
+
passalong team join <link> # on Ada's machine — or she just opens the link
|
|
64
|
+
passalong share --to khaime/ada # hand this guide to Ada
|
|
65
|
+
passalong inbox # on Ada's side: what was handed to you
|
|
66
|
+
passalong activity # back on your side: it landed, and who pulled it
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A receiver who tries the work reports back with `passalong works <id>` or `passalong broken <id> <why>`.
|
|
70
|
+
That is separate from `done`, which means *implemented*: a verdict means it actually runs, a failing
|
|
71
|
+
one has to say why, and the author sees it on their board and in their inbox.
|
|
72
|
+
|
|
73
|
+
Sign in at [the hub](https://passalong.dev/hub) with an email and password, and mint
|
|
74
|
+
an API token there for the CLI and MCP servers — named, revocable, and shown once. Your password
|
|
75
|
+
never goes near the terminal. `passalong login` with no arguments still makes an account without
|
|
76
|
+
any of that; add an email and password later to be able to sign in from a browser and recover it.
|
|
77
|
+
|
|
78
|
+
Not everyone who receives a guide implements it. Every guide page has a **Verify** view
|
|
79
|
+
(`?view=verify`) that leads with Problem, Verification and Gotchas and folds the implementation
|
|
80
|
+
away — for a tester or anyone checking the work rather than doing it. Invites are accepted in the
|
|
81
|
+
browser, so a teammate needs no terminal to be part of a team.
|
|
82
|
+
|
|
83
|
+
`passalong activity` is the other half of the transfer: it tells you when someone pulled a guide
|
|
84
|
+
you handed over, when they marked it consumed, and when an invite was taken up. A handoff
|
|
85
|
+
addressed to a person is also emailed; a team-wide share is not, because mail nobody is
|
|
86
|
+
named in is the first thing people filter out. Guides that keep getting pulled get a nudge to
|
|
87
|
+
promote them into the team's small set of maintained references.
|
|
88
|
+
|
|
42
89
|
## MCP
|
|
43
90
|
|
|
44
91
|
`passalong setup` registers the server with Claude Code. For other clients, run `passalong mcp` over stdio.
|
|
45
92
|
|
|
46
|
-
Tools: `search_guides`, `get_guide`, `publish_guide
|
|
93
|
+
Tools: `search_guides`, `inbox`, `get_guide`, `publish_guide` (with `to`), `guide_template`, `set_guide_status`.
|
|
47
94
|
|
|
48
95
|
## Guides are files
|
|
49
96
|
|
package/bin/passalong
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Passalong CLI. Few flags on purpose: the defaults are the product.
|
|
3
3
|
//
|
|
4
|
-
// passalong share [file]
|
|
4
|
+
// passalong share [file] [--to team[/handle]] publish a guide → id + link; --to hands it to a team or teammate
|
|
5
5
|
// passalong pull <id|link> fetch a guide into ./.passalong/ and print it
|
|
6
|
-
// passalong
|
|
6
|
+
// passalong inbox guides handed to you that you have not pulled yet
|
|
7
|
+
// passalong board waiting on you, in flight, landed, worth keeping
|
|
8
|
+
// passalong activity [--all] what happened while you were away; clears unless --all
|
|
9
|
+
// passalong list [query] your guides and your teams', local and synced
|
|
7
10
|
// passalong open <id> [--print] view a guide in the browser (or the terminal)
|
|
8
11
|
// passalong hub open your synced guides in the browser
|
|
12
|
+
// passalong works <id> you tried it and it holds up
|
|
13
|
+
// passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
9
14
|
// passalong done <id> mark consumed: implemented on the receiving side
|
|
10
15
|
// passalong promote <id> mark promoted: graduated into a reusable reference
|
|
11
16
|
// passalong rm <id> delete a guide locally and from sync
|
|
12
17
|
// passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
|
|
13
|
-
// passalong login [token] create an account, or attach this machine
|
|
18
|
+
// passalong login [token] create an account, or attach this machine with a token from your hub
|
|
19
|
+
// passalong me [--handle H] [--name N] [--email E] who you are to teammates
|
|
20
|
+
// passalong team current team and its members
|
|
21
|
+
// passalong team create <name> start a team (you become its owner)
|
|
22
|
+
// passalong team invite [email] make an invite link (mailed when an email is given)
|
|
23
|
+
// passalong team join <link|code> accept an invite
|
|
24
|
+
// passalong team use <slug> switch the current team
|
|
14
25
|
// passalong setup install the Claude Code capture skill + register the MCP server
|
|
15
26
|
// passalong mcp run the MCP server over stdio
|
|
16
27
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -53,18 +64,37 @@ function edit(path) {
|
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
function openInBrowser(url) {
|
|
56
|
-
const cmd =
|
|
67
|
+
const cmd =
|
|
68
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
57
69
|
spawnSync(cmd, [url], { stdio: "ignore", shell: process.platform === "win32" });
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
function relDate(iso) {
|
|
61
73
|
if (!iso) return "";
|
|
62
74
|
const d = (Date.now() - new Date(iso).getTime()) / 864e5;
|
|
63
|
-
if (d < 1) return "
|
|
75
|
+
if (d < 1 / 24) return "just now";
|
|
76
|
+
if (d < 1) return `${Math.floor(d * 24)}h ago`;
|
|
64
77
|
if (d < 30) return `${Math.floor(d)}d ago`;
|
|
65
78
|
return iso.slice(0, 10);
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
function guideLine(r) {
|
|
82
|
+
const who = r.team ? ` ${r.team}${r.to ? `/@${r.to}` : ""}${r.mine ? "" : ` from @${r.from || "?"}`}` : "";
|
|
83
|
+
const tags = r.tags?.length ? ` #${r.tags.join(" #")}` : "";
|
|
84
|
+
let pulled = "";
|
|
85
|
+
if (r.mine && r.pulled_by?.length) {
|
|
86
|
+
const p = r.pulled_by[0];
|
|
87
|
+
pulled = ` ← ${p.handle ? `@${p.handle}` : "link"} ${relDate(p.at)}${r.pulled_by.length > 1 ? ` +${r.pulled_by.length - 1}` : ""}`;
|
|
88
|
+
}
|
|
89
|
+
const nudge = r.mine && r.status === "published" && r.pulls >= 3 ? " ★ promote?" : "";
|
|
90
|
+
return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}${pulled}${nudge}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function activityLine(n) {
|
|
94
|
+
const dot = n.read ? " " : "•";
|
|
95
|
+
return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
68
98
|
const commands = {
|
|
69
99
|
async share(f) {
|
|
70
100
|
let path = f._[0];
|
|
@@ -79,15 +109,23 @@ const commands = {
|
|
|
79
109
|
path = store.draftPath(new Date().toISOString().replace(/[:.]/g, "-"));
|
|
80
110
|
writeFileSync(path, scaffold());
|
|
81
111
|
fromDrafts = true;
|
|
82
|
-
if (!tty)
|
|
112
|
+
if (!tty)
|
|
113
|
+
die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
|
|
83
114
|
}
|
|
84
115
|
}
|
|
85
116
|
if (!existsSync(path)) die(`no such file: ${path}`);
|
|
86
117
|
if (tty && !f["no-edit"]) edit(path);
|
|
87
|
-
const
|
|
118
|
+
const to = typeof f.to === "string" ? f.to : undefined;
|
|
119
|
+
const { guide, url, synced, notified, path: saved } = await passalong
|
|
120
|
+
.share(readFileSync(path, "utf8"), { to })
|
|
121
|
+
.catch((e) => die(e.message));
|
|
88
122
|
if (fromDrafts) store.removeDraft(path);
|
|
89
123
|
console.log(guide.meta.id);
|
|
90
124
|
console.error(`shared "${guide.meta.title}" → ${saved}`);
|
|
125
|
+
if (guide.meta.team)
|
|
126
|
+
console.error(
|
|
127
|
+
`handed to ${guide.meta.team}${guide.meta.to ? `/@${guide.meta.to}${notified ? " (emailed)" : " (in their inbox)"}` : " (team inbox)"}`,
|
|
128
|
+
);
|
|
91
129
|
if (synced) console.error(`link: ${url}`);
|
|
92
130
|
else console.error("not synced (run `passalong login` to sync across machines)");
|
|
93
131
|
},
|
|
@@ -99,14 +137,56 @@ const commands = {
|
|
|
99
137
|
console.error(`\npulled from ${from} → ${path}`);
|
|
100
138
|
},
|
|
101
139
|
|
|
140
|
+
async inbox() {
|
|
141
|
+
const rows = await passalong.inbox().catch((e) => die(e.message));
|
|
142
|
+
if (!rows.length) return console.error("inbox empty — nothing handed to you that you haven't pulled");
|
|
143
|
+
for (const r of rows) console.log(guideLine(r));
|
|
144
|
+
console.error(`\n${rows.length} waiting. Pull one with: passalong pull <id>`);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async board() {
|
|
148
|
+
const b = await passalong.board().catch((e) => die(e.message));
|
|
149
|
+
const queue = (title, note, rows, line) => {
|
|
150
|
+
if (!rows.length) return;
|
|
151
|
+
console.log(`\n${title} · ${rows.length} ${note}`);
|
|
152
|
+
for (const g of rows) console.log(` ${g.id} ${relDate(g.created).padEnd(10)} ${line(g)}`);
|
|
153
|
+
};
|
|
154
|
+
queue("Waiting on you", "handed to you, not pulled yet", b.waiting, (g) => `${g.title} from @${g.from || "?"}`);
|
|
155
|
+
queue("Not working", "someone tried it and it does not hold up", b.failing, (g) =>
|
|
156
|
+
`${g.title} ${g.verdict?.by ? `@${g.verdict.by}` : "someone"}: ${g.verdict?.note || "no reason given"}`);
|
|
157
|
+
queue("In flight", "handed over, nobody has taken it", b.in_flight, (g) =>
|
|
158
|
+
`${g.title} to ${g.team}${g.to ? `/@${g.to}` : ""}${g.stale ? " ← not picked up" : ""}`);
|
|
159
|
+
queue("Landed", "someone has it and hasn't said it shipped", b.landed, (g) => `${g.title} ${g.pulls} pull${g.pulls === 1 ? "" : "s"}`);
|
|
160
|
+
queue("Worth keeping", "pulled enough to graduate into a reference", b.promote, (g) => `${g.title} ${g.pulls} pulls`);
|
|
161
|
+
const total =
|
|
162
|
+
b.waiting.length + b.failing.length + b.in_flight.length + b.landed.length + b.promote.length;
|
|
163
|
+
if (!total) console.error("nothing in flight — every transfer is closed");
|
|
164
|
+
else if (b.unread) console.error(`\n${b.unread} new: passalong activity`);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async activity(f) {
|
|
168
|
+
const { notifications, unread } = await passalong.activity({ all: f.all }).catch((e) => die(e.message));
|
|
169
|
+
if (!notifications.length)
|
|
170
|
+
return console.error(
|
|
171
|
+
f.all ? "nothing has happened yet" : "nothing new — your guides and handoffs are quiet",
|
|
172
|
+
);
|
|
173
|
+
for (const n of notifications) console.log(activityLine(n));
|
|
174
|
+
const waiting = notifications.filter((n) => n.kind === "handoff" && !n.read);
|
|
175
|
+
if (waiting.length) console.error(`\nPull one with: passalong pull ${waiting[0].guide}`);
|
|
176
|
+
// Reading the feed is what marks it read: an unread count you have to clear by hand is a
|
|
177
|
+
// chore, and `--all` is there for when you want to look without clearing.
|
|
178
|
+
if (!f.all && unread) await passalong.seen().catch(() => {});
|
|
179
|
+
},
|
|
180
|
+
|
|
102
181
|
async list(f) {
|
|
103
|
-
const
|
|
182
|
+
const scope = typeof f.team === "string" ? f.team : f.mine ? "mine" : "";
|
|
183
|
+
const rows = await passalong.list(f._.join(" "), { scope });
|
|
104
184
|
if (rows.warning) console.error(`warning: ${rows.warning}`);
|
|
105
|
-
if (!rows.length)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
185
|
+
if (!rows.length)
|
|
186
|
+
return console.error(
|
|
187
|
+
"no guides yet — run `passalong share` after your next finished piece of work",
|
|
188
|
+
);
|
|
189
|
+
for (const r of rows) console.log(guideLine(r));
|
|
110
190
|
},
|
|
111
191
|
|
|
112
192
|
async open(f) {
|
|
@@ -122,7 +202,8 @@ const commands = {
|
|
|
122
202
|
},
|
|
123
203
|
|
|
124
204
|
async hub() {
|
|
125
|
-
if (!api.loggedIn())
|
|
205
|
+
if (!api.loggedIn())
|
|
206
|
+
die("not logged in — run `passalong login` first (the hub shows synced guides)");
|
|
126
207
|
// The token rides in the URL fragment: the browser never sends it to the server, and the
|
|
127
208
|
// page moves it into localStorage and scrubs the address bar on load.
|
|
128
209
|
const url = `${api.baseUrl()}/hub#token=${encodeURIComponent(api.token())}`;
|
|
@@ -132,6 +213,20 @@ const commands = {
|
|
|
132
213
|
} else console.log(url);
|
|
133
214
|
},
|
|
134
215
|
|
|
216
|
+
async works(f) {
|
|
217
|
+
const id = f._[0] || die("usage: passalong works <id>");
|
|
218
|
+
const r = await passalong.verdict(id, true, f._.slice(1).join(" ")).catch((e) => die(e.message));
|
|
219
|
+
console.error(`${r.id} verified — the author has been told`);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async broken(f) {
|
|
223
|
+
const id = f._[0] || die("usage: passalong broken <id> <what happened>");
|
|
224
|
+
const why = f._.slice(1).join(" ");
|
|
225
|
+
if (!why) die("say what went wrong: passalong broken <id> <what happened>");
|
|
226
|
+
const r = await passalong.verdict(id, false, why).catch((e) => die(e.message));
|
|
227
|
+
console.error(`${r.id} reported as not working — the author has been told: "${r.note}"`);
|
|
228
|
+
},
|
|
229
|
+
|
|
135
230
|
async done(f) {
|
|
136
231
|
const id = f._[0] || die("usage: passalong done <id>");
|
|
137
232
|
const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
|
|
@@ -177,7 +272,81 @@ const commands = {
|
|
|
177
272
|
console.error(`account ${account} created on ${api.baseUrl()}. Sync is on.`);
|
|
178
273
|
console.error("Attach other machines with:");
|
|
179
274
|
console.error(` passalong login ${token}`);
|
|
180
|
-
console.error("(
|
|
275
|
+
console.error("(keep it private — it is a credential, not a password)");
|
|
276
|
+
console.error("Give teammates a name to address you by: passalong me --handle <you>");
|
|
277
|
+
console.error(`Add an email and password so you can sign in and recover it: ${api.baseUrl()}/hub`);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async me(f) {
|
|
281
|
+
const patch = {};
|
|
282
|
+
for (const k of ["handle", "name", "email"]) if (typeof f[k] === "string") patch[k] = f[k];
|
|
283
|
+
const who = Object.keys(patch).length
|
|
284
|
+
? await api.updateMe(patch).then(api.me).catch((e) => die(e.message))
|
|
285
|
+
: await api.me().catch((e) => die(e.message));
|
|
286
|
+
console.log(`account ${who.account}`);
|
|
287
|
+
console.log(`handle ${who.handle ? `@${who.handle}` : "(none — set one: passalong me --handle <you>)"}`);
|
|
288
|
+
if (who.name) console.log(`name ${who.name}`);
|
|
289
|
+
if (who.email) console.log(`email ${who.email}`);
|
|
290
|
+
console.log(`guides ${who.guides} synced (${who.limit} active on the free tier)`);
|
|
291
|
+
const current = store.readConfig().team;
|
|
292
|
+
console.log(
|
|
293
|
+
`teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
async team(f) {
|
|
298
|
+
const [sub, ...rest] = f._;
|
|
299
|
+
const cfg = store.readConfig();
|
|
300
|
+
if (!sub) {
|
|
301
|
+
if (!cfg.team) {
|
|
302
|
+
const { teams } = await api.teams().catch((e) => die(e.message));
|
|
303
|
+
if (!teams.length) return console.error("no team yet — passalong team create <name>");
|
|
304
|
+
return console.error(`you are in ${teams.map((t) => t.slug).join(", ")}; pick one: passalong team use <slug>`);
|
|
305
|
+
}
|
|
306
|
+
const t = await api.team(cfg.team).catch((e) => die(e.message));
|
|
307
|
+
console.log(`${t.name} (${t.slug}) — ${t.members.length} member${t.members.length === 1 ? "" : "s"}, ${t.guides} guide${t.guides === 1 ? "" : "s"}`);
|
|
308
|
+
for (const m of t.members)
|
|
309
|
+
console.log(` ${m.handle ? `@${m.handle}` : "(no handle)"}${m.name ? ` ${m.name}` : ""} ${m.role} joined ${relDate(m.joined)}`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (sub === "create") {
|
|
313
|
+
const name = rest.join(" ") || die("usage: passalong team create <name>");
|
|
314
|
+
const t = await api.createTeam(name).catch((e) => die(e.message));
|
|
315
|
+
store.writeConfig({ team: t.slug });
|
|
316
|
+
console.error(`team ${t.name} created as "${t.slug}" — now current. Invite people: passalong team invite [email]`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (sub === "invite") {
|
|
320
|
+
const slug = cfg.team || die("no current team — passalong team use <slug>");
|
|
321
|
+
const inv = await api.invite(slug, rest[0] || "").catch((e) => die(e.message));
|
|
322
|
+
console.log(inv.url);
|
|
323
|
+
console.error(
|
|
324
|
+
inv.email
|
|
325
|
+
? inv.mailed
|
|
326
|
+
? `emailed ${inv.email}`
|
|
327
|
+
: `not emailed (mail is not configured on the server) — send ${inv.email} the link above`
|
|
328
|
+
: "send this link; they run: passalong team join <link>",
|
|
329
|
+
);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (sub === "join") {
|
|
333
|
+
const ref = rest[0] || die("usage: passalong team join <link|code>");
|
|
334
|
+
const code = ref.includes("/") ? ref.split("/").filter(Boolean).pop() : ref;
|
|
335
|
+
const r = await api.join(code).catch((e) => die(e.message));
|
|
336
|
+
store.writeConfig({ team: r.slug });
|
|
337
|
+
console.error(`${r.joined ? "joined" : "already in"} ${r.name} (${r.slug}) — now current`);
|
|
338
|
+
const who = await api.me().catch(() => null);
|
|
339
|
+
if (who && !who.handle) console.error("teammates address you by handle: passalong me --handle <you>");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (sub === "use") {
|
|
343
|
+
const slug = rest[0] || die("usage: passalong team use <slug>");
|
|
344
|
+
await api.team(slug).catch((e) => die(e.message));
|
|
345
|
+
store.writeConfig({ team: slug });
|
|
346
|
+
console.error(`current team: ${slug}`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
die("usage: passalong team [create <name> | invite [email] | join <link> | use <slug>]");
|
|
181
350
|
},
|
|
182
351
|
|
|
183
352
|
async setup() {
|
|
@@ -186,15 +355,21 @@ const commands = {
|
|
|
186
355
|
cpSync(join(PKG, "skill"), skillDir, { recursive: true });
|
|
187
356
|
console.error(`installed Claude Code skill → ${skillDir}`);
|
|
188
357
|
try {
|
|
189
|
-
execFileSync("claude", ["mcp", "add", "--scope", "user", "passalong", "--", "passalong", "mcp"], {
|
|
358
|
+
execFileSync("claude", ["mcp", "add", "--scope", "user", "passalong", "--", "passalong", "mcp"], {
|
|
359
|
+
stdio: "pipe",
|
|
360
|
+
});
|
|
190
361
|
console.error("registered MCP server: claude mcp add passalong -- passalong mcp");
|
|
191
362
|
} catch (err) {
|
|
192
363
|
const msg = String(err.stderr || err.message);
|
|
193
364
|
if (/already exists/i.test(msg)) console.error("MCP server already registered");
|
|
194
|
-
else
|
|
365
|
+
else
|
|
366
|
+
console.error(
|
|
367
|
+
`could not register MCP server automatically (${msg.trim()}).\n run: claude mcp add passalong -- passalong mcp`,
|
|
368
|
+
);
|
|
195
369
|
}
|
|
196
|
-
if (!api.loggedIn())
|
|
197
|
-
|
|
370
|
+
if (!api.loggedIn())
|
|
371
|
+
console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
|
|
372
|
+
console.error('done. In Claude Code, say "pass this along" at the end of a task, or run /passalong-capture.');
|
|
198
373
|
},
|
|
199
374
|
|
|
200
375
|
async mcp() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passalong",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"cli",
|
|
12
12
|
"markdown"
|
|
13
13
|
],
|
|
14
|
-
"homepage": "https://passalong.
|
|
14
|
+
"homepage": "https://passalong.dev",
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
17
17
|
"url": "https://github.com/codellyson/passalong.git",
|
package/skill/SKILL.md
CHANGED
|
@@ -11,22 +11,29 @@ session's history. Write for execution, not for permanence.
|
|
|
11
11
|
|
|
12
12
|
## Procedure
|
|
13
13
|
|
|
14
|
-
1. **
|
|
14
|
+
1. **Pick the one thing to transfer.** If the user named it (`/passalong-capture <what>` or
|
|
15
|
+
"pass along the webhook fix"), that is the scope. Otherwise take the most recent completed
|
|
16
|
+
piece of work. A long session usually holds several; do not merge them, and if it is
|
|
17
|
+
genuinely unclear which one the user means, ask before writing.
|
|
18
|
+
2. **Reconstruct the story from the session.** What was the problem, what was tried, what failed,
|
|
15
19
|
what was decided and why, what finally worked, and how it was verified. Read the diff
|
|
16
20
|
(`git diff`, `git log`) only to confirm details; the reasoning is in the transcript.
|
|
17
|
-
|
|
21
|
+
3. **Write the guide** with the structure below. Be concrete: file paths, commands, exact error
|
|
18
22
|
strings, version numbers. Prefer a short guide with every section filled over a long one.
|
|
19
|
-
|
|
23
|
+
4. **Mark context-specific parts** so the receiver can adapt rather than copy. Put
|
|
20
24
|
`ASSUMES: <thing>. If <alternative>, <what changes>.` at the start of any step that depends on
|
|
21
25
|
this repo's stack, layout, or conventions.
|
|
22
|
-
|
|
26
|
+
5. **Save the draft** to `~/.passalong/drafts/<slug>.md` (create the directory if needed), then run:
|
|
23
27
|
|
|
24
28
|
```bash
|
|
25
29
|
passalong share ~/.passalong/drafts/<slug>.md --no-edit
|
|
26
30
|
```
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
If the user named a person or team ("share this with Ada", "hand this to the platform
|
|
33
|
+
team"), add `--to <team>` or `--to <team>/<handle>`; `passalong team` lists the current
|
|
34
|
+
team's members and their handles. It prints the guide id on stdout and the share link (and
|
|
35
|
+
who it was handed to) on stderr.
|
|
36
|
+
6. **Report** the id (and link if synced) and one line on how to use it on the other side:
|
|
30
37
|
`passalong pull <id>` in the target repo, or "pull passalong <id>" to an agent with the Passalong MCP server.
|
|
31
38
|
|
|
32
39
|
If the `passalong` command is missing, tell the user to run `npm i -g passalong && passalong setup` and
|
|
@@ -70,6 +77,8 @@ Commands to run and what they should print. Test cases. What "done" looks like.
|
|
|
70
77
|
wrong without a specific decision. Never leave it empty.
|
|
71
78
|
- **Verification must be runnable.** A command and an expected output, not "make sure it works".
|
|
72
79
|
- **Every ASSUMES has an alternative.** The point is adaptation on the other side.
|
|
80
|
+
- **One problem per guide.** A lesson from the same session that is not part of this fix goes in
|
|
81
|
+
its own guide (write and share it separately), not in this one's Gotchas.
|
|
73
82
|
- **No session narration.** "First I looked at..." is noise. State the finding.
|
|
74
83
|
- **No secrets.** Redact tokens, keys, and internal hostnames that would not apply elsewhere.
|
|
75
84
|
- Title, Problem, and Steps are required; the guide will not publish without them.
|
package/src/api.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Client for the hosted sync API (apps/api). Everything here is optional: with no token the
|
|
2
|
-
// CLI is a purely local tool, and every function throws
|
|
2
|
+
// CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
|
|
3
3
|
import { readConfig } from "./store.js";
|
|
4
4
|
|
|
5
|
-
export const DEFAULT_API = "https://passalong.
|
|
5
|
+
export const DEFAULT_API = "https://passalong.dev";
|
|
6
6
|
|
|
7
7
|
export class ApiError extends Error {
|
|
8
8
|
constructor(status, message) {
|
|
@@ -54,16 +54,42 @@ async function call(path, { method = "GET", body, auth = true, raw = false } = {
|
|
|
54
54
|
return raw ? res.text() : res.json();
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const q = (params) => {
|
|
58
|
+
const s = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
|
|
59
|
+
return s ? `?${s}` : "";
|
|
60
|
+
};
|
|
61
|
+
|
|
57
62
|
/** Mint a fresh account and return its token. No email, no password: the token is the account. */
|
|
58
63
|
export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
|
|
59
64
|
export const me = () => call("/v1/me");
|
|
65
|
+
export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
|
|
66
|
+
|
|
67
|
+
export const teams = () => call("/v1/teams");
|
|
68
|
+
export const team = (slug) => call(`/v1/teams/${encodeURIComponent(slug)}`);
|
|
69
|
+
export const createTeam = (name) => call("/v1/teams", { method: "POST", body: { name } });
|
|
70
|
+
export const invite = (slug, email = "") =>
|
|
71
|
+
call(`/v1/teams/${encodeURIComponent(slug)}/invites`, { method: "POST", body: { email } });
|
|
72
|
+
export const join = (code) =>
|
|
73
|
+
call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
|
|
74
|
+
|
|
75
|
+
export const board = () => call("/v1/board");
|
|
76
|
+
export const notifications = ({ unread = true, limit = 0 } = {}) =>
|
|
77
|
+
call(`/v1/notifications${q({ unread: unread ? "1" : "", limit: limit || "" })}`);
|
|
78
|
+
/** No ids means "everything unread". */
|
|
79
|
+
export const markRead = (ids = []) =>
|
|
80
|
+
call("/v1/notifications/read", { method: "POST", body: { ids } });
|
|
81
|
+
|
|
60
82
|
export const publish = (id, markdown) =>
|
|
61
83
|
call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
|
|
62
|
-
export const list = (
|
|
84
|
+
export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
|
|
85
|
+
export const inbox = () => call("/v1/inbox");
|
|
63
86
|
export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
|
|
64
87
|
export const setStatus = (id, status) =>
|
|
65
88
|
call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
|
|
66
89
|
export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
|
|
90
|
+
/** Does it actually work? `note` is required when it does not. */
|
|
91
|
+
export const verdict = (id, ok, note = "") =>
|
|
92
|
+
call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
|
|
67
93
|
|
|
68
94
|
/** Fetch a guide by its share link (no account needed). Accepts the web URL or the .md URL. */
|
|
69
95
|
export async function fetchShared(url) {
|
package/src/guide.js
CHANGED
package/src/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// The Passalong MCP server (stdio). This is the rail that makes Passalong tool-agnostic: any
|
|
2
|
-
// agent can search, pull, and publish guides without leaving its session.
|
|
1
|
+
// The Passalong MCP server (stdio). This is the rail that makes Passalong tool-agnostic: any
|
|
2
|
+
// MCP-capable agent can search, pull, and publish guides without leaving its session. With a
|
|
3
|
+
// team, the same tools see the team's guides and the user's inbox.
|
|
3
4
|
//
|
|
4
5
|
// claude mcp add passalong -- passalong mcp
|
|
5
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -14,16 +15,20 @@ const fail = (err) => ({ content: [{ type: "text", text: err.message }], isError
|
|
|
14
15
|
|
|
15
16
|
export async function serve() {
|
|
16
17
|
const server = new McpServer(
|
|
17
|
-
{ name: "passalong", version: "0.
|
|
18
|
+
{ name: "passalong", version: "0.2.0" },
|
|
18
19
|
{
|
|
19
20
|
instructions:
|
|
20
|
-
"Passalong hands finished implementations between contexts as transfer guides: markdown
|
|
21
|
-
`frontmatter and the sections ${SECTIONS.join(", ")}. ` +
|
|
22
|
-
"When the user references a passalong id or link, call get_guide and follow its Steps,
|
|
23
|
-
"anything marked ASSUMES to this codebase; run its Verification before declaring
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
21
|
+
"Passalong hands finished implementations between contexts as transfer guides: markdown " +
|
|
22
|
+
`with frontmatter and the sections ${SECTIONS.join(", ")}. ` +
|
|
23
|
+
"When the user references a passalong id or link, call get_guide and follow its Steps, " +
|
|
24
|
+
"adapting anything marked ASSUMES to this codebase; run its Verification before declaring " +
|
|
25
|
+
"done, then set_guide_status consumed and verify_guide with the result. " +
|
|
26
|
+
"When the user asks to pass along, hand off, or " +
|
|
27
|
+
"share what was just done, distill the session into a guide (guide_template shows the " +
|
|
28
|
+
"shape) and call publish_guide, with `to` as team or team/handle when it is for a teammate. " +
|
|
29
|
+
"At the start of work, inbox shows guides teammates have handed to this user, and activity " +
|
|
30
|
+
"shows whether the guides they handed off have landed. Gotchas are " +
|
|
31
|
+
"the highest-value section: record what failed and why.",
|
|
27
32
|
},
|
|
28
33
|
);
|
|
29
34
|
|
|
@@ -32,13 +37,16 @@ export async function serve() {
|
|
|
32
37
|
{
|
|
33
38
|
title: "Search guides",
|
|
34
39
|
description:
|
|
35
|
-
"Search
|
|
36
|
-
"
|
|
37
|
-
inputSchema: {
|
|
40
|
+
"Search transfer guides by words in the title, tags, stack, or body: the user's own " +
|
|
41
|
+
"(local and synced) plus every team they belong to. Empty query lists everything, newest first.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
query: z.string().default(""),
|
|
44
|
+
scope: z.string().optional().describe('"mine", "all" (default), or a team slug'),
|
|
45
|
+
},
|
|
38
46
|
},
|
|
39
|
-
async ({ query }) => {
|
|
47
|
+
async ({ query, scope }) => {
|
|
40
48
|
try {
|
|
41
|
-
const rows = await passalong.list(query);
|
|
49
|
+
const rows = await passalong.list(query, { scope: scope || "" });
|
|
42
50
|
return json({ guides: rows, warning: rows.warning });
|
|
43
51
|
} catch (err) {
|
|
44
52
|
return fail(err);
|
|
@@ -46,13 +54,103 @@ export async function serve() {
|
|
|
46
54
|
},
|
|
47
55
|
);
|
|
48
56
|
|
|
57
|
+
server.registerTool(
|
|
58
|
+
"inbox",
|
|
59
|
+
{
|
|
60
|
+
title: "Inbox",
|
|
61
|
+
description:
|
|
62
|
+
"Guides handed to this user (or to their teams) that they have not pulled yet. Call it " +
|
|
63
|
+
"when starting work so handoffs are not missed. Needs sync (passalong login).",
|
|
64
|
+
inputSchema: {},
|
|
65
|
+
},
|
|
66
|
+
async () => {
|
|
67
|
+
try {
|
|
68
|
+
return json({ guides: await passalong.inbox() });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return fail(err);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
server.registerTool(
|
|
76
|
+
"board",
|
|
77
|
+
{
|
|
78
|
+
title: "Board",
|
|
79
|
+
description:
|
|
80
|
+
"The state of this user's transfers as queues: waiting on you (handed to you, not " +
|
|
81
|
+
"pulled), not working (someone gave it a failing verdict — the most urgent), in flight " +
|
|
82
|
+
"(handed over, nobody has taken it — `stale` means it has sat over a week), landed " +
|
|
83
|
+
"(someone has it and has not marked it consumed), and worth keeping (pulled enough to " +
|
|
84
|
+
"promote into a reference). Use it to answer 'what is outstanding?'.",
|
|
85
|
+
inputSchema: {},
|
|
86
|
+
},
|
|
87
|
+
async () => {
|
|
88
|
+
try {
|
|
89
|
+
return json(await passalong.board());
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return fail(err);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
server.registerTool(
|
|
97
|
+
"verify_guide",
|
|
98
|
+
{
|
|
99
|
+
title: "Report whether a guide works",
|
|
100
|
+
description:
|
|
101
|
+
"After following a guide's Verification section, report the result. This is the only way " +
|
|
102
|
+
"the author learns their handoff did not land — `set_guide_status consumed` says it was " +
|
|
103
|
+
"implemented, this says it actually works. A failing verdict must say what went wrong.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
id: z.string().describe("passalong id"),
|
|
106
|
+
ok: z.boolean().describe("true if the Verification steps passed"),
|
|
107
|
+
note: z
|
|
108
|
+
.string()
|
|
109
|
+
.default("")
|
|
110
|
+
.describe("what went wrong (required when ok is false); one line, max 280 chars"),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
async ({ id, ok, note }) => {
|
|
114
|
+
try {
|
|
115
|
+
return json(await passalong.verdict(id, ok, note || ""));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return fail(err);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
"activity",
|
|
124
|
+
{
|
|
125
|
+
title: "Activity",
|
|
126
|
+
description:
|
|
127
|
+
"What has happened to this user's guides and handoffs: who pulled one, who marked one " +
|
|
128
|
+
"consumed, who was handed what, who joined a team. Each item has a ready-made `text` " +
|
|
129
|
+
"line. Read-only by default — it does not clear the user's unread feed unless asked.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
all: z.boolean().default(false).describe("include what the user has already seen"),
|
|
132
|
+
mark_read: z.boolean().default(false).describe("clear the unread feed after reading"),
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
async ({ all, mark_read }) => {
|
|
136
|
+
try {
|
|
137
|
+
const res = await passalong.activity({ all });
|
|
138
|
+
if (mark_read && res.unread) await passalong.seen();
|
|
139
|
+
return json(res);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return fail(err);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
49
146
|
server.registerTool(
|
|
50
147
|
"get_guide",
|
|
51
148
|
{
|
|
52
149
|
title: "Get guide",
|
|
53
150
|
description:
|
|
54
|
-
"Fetch a transfer guide by passalong id or share link and return its full markdown. Also
|
|
55
|
-
"it to .passalong/<id>.md in the working directory so it survives the session."
|
|
151
|
+
"Fetch a transfer guide by passalong id or share link and return its full markdown. Also " +
|
|
152
|
+
"writes it to .passalong/<id>.md in the working directory so it survives the session. " +
|
|
153
|
+
"Pulling a teammate's guide tells them the transfer landed.",
|
|
56
154
|
inputSchema: {
|
|
57
155
|
ref: z.string().describe("passalong id (e.g. k3mq2xa7) or share URL"),
|
|
58
156
|
cwd: z
|
|
@@ -77,21 +175,33 @@ export async function serve() {
|
|
|
77
175
|
title: "Publish guide",
|
|
78
176
|
description:
|
|
79
177
|
"Publish a transfer guide from markdown (frontmatter + sections). Missing id, created, " +
|
|
80
|
-
|
|
178
|
+
'author, and source_context are filled in. `to` addresses it to a team ("khaime") or a ' +
|
|
179
|
+
'teammate ("khaime/lukman"), who is notified. Returns the id and share link.',
|
|
81
180
|
inputSchema: {
|
|
82
181
|
markdown: z.string().describe("full guide markdown; start from guide_template"),
|
|
182
|
+
to: z.string().optional().describe("team slug, or team/handle for a specific teammate"),
|
|
83
183
|
cwd: z
|
|
84
184
|
.string()
|
|
85
185
|
.optional()
|
|
86
186
|
.describe("directory the work happened in, used to infer source_context"),
|
|
87
187
|
},
|
|
88
188
|
},
|
|
89
|
-
async ({ markdown, cwd }) => {
|
|
189
|
+
async ({ markdown, to, cwd }) => {
|
|
90
190
|
try {
|
|
91
|
-
const { guide, url, synced, path } = await passalong.share(markdown, {
|
|
191
|
+
const { guide, url, synced, notified, path } = await passalong.share(markdown, {
|
|
92
192
|
cwd: cwd || process.cwd(),
|
|
193
|
+
to,
|
|
194
|
+
});
|
|
195
|
+
return json({
|
|
196
|
+
id: guide.meta.id,
|
|
197
|
+
title: guide.meta.title,
|
|
198
|
+
url,
|
|
199
|
+
synced,
|
|
200
|
+
team: guide.meta.team || "",
|
|
201
|
+
to: guide.meta.to || "",
|
|
202
|
+
notified,
|
|
203
|
+
path,
|
|
93
204
|
});
|
|
94
|
-
return json({ id: guide.meta.id, title: guide.meta.title, url, synced, path });
|
|
95
205
|
} catch (err) {
|
|
96
206
|
return fail(err);
|
|
97
207
|
}
|
|
@@ -113,7 +223,8 @@ export async function serve() {
|
|
|
113
223
|
{
|
|
114
224
|
title: "Set guide status",
|
|
115
225
|
description:
|
|
116
|
-
"Mark a guide consumed (implemented on the receiving side) or promoted (a reusable reference)."
|
|
226
|
+
"Mark a guide consumed (implemented on the receiving side) or promoted (a reusable reference). " +
|
|
227
|
+
"Only the author can promote.",
|
|
117
228
|
inputSchema: { id: z.string(), status: z.enum(["published", "consumed", "promoted"]) },
|
|
118
229
|
},
|
|
119
230
|
async ({ id, status }) => {
|
package/src/passalong.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these,
|
|
2
|
-
// anything an agent can do through MCP a human can do from the terminal and vice versa.
|
|
3
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
// The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these,
|
|
2
|
+
// so anything an agent can do through MCP a human can do from the terminal and vice versa.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import * as api from "./api.js";
|
|
6
6
|
import { context } from "./capture.js";
|
|
@@ -9,11 +9,18 @@ import * as store from "./store.js";
|
|
|
9
9
|
|
|
10
10
|
export class PassalongError extends Error {}
|
|
11
11
|
|
|
12
|
+
/** "team" or "team/handle" → { team, to }. */
|
|
13
|
+
export function parseTarget(to) {
|
|
14
|
+
if (!to) return {};
|
|
15
|
+
const [team, handle] = String(to).split("/");
|
|
16
|
+
return { team: team.trim(), to: handle ? handle.trim().replace(/^@/, "") : undefined };
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
/**
|
|
13
20
|
* Publish a guide from markdown: fill in defaults, validate, store locally, and sync when
|
|
14
|
-
* logged in. Returns { guide, path, url, synced }.
|
|
21
|
+
* logged in. `to` addresses it: "team" or "team/handle". Returns { guide, path, url, synced }.
|
|
15
22
|
*/
|
|
16
|
-
export async function share(markdown, { cwd = process.cwd() } = {}) {
|
|
23
|
+
export async function share(markdown, { cwd = process.cwd(), to } = {}) {
|
|
17
24
|
const c = context(cwd);
|
|
18
25
|
const raw = parse(markdown);
|
|
19
26
|
let guide = stamp(
|
|
@@ -23,6 +30,11 @@ export async function share(markdown, { cwd = process.cwd() } = {}) {
|
|
|
23
30
|
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
24
31
|
},
|
|
25
32
|
);
|
|
33
|
+
const target = parseTarget(to);
|
|
34
|
+
if (target.team) guide.meta.team = target.team;
|
|
35
|
+
if (target.to) guide.meta.to = target.to;
|
|
36
|
+
if (guide.meta.to && !guide.meta.team)
|
|
37
|
+
throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
|
|
26
38
|
const errors = validate(guide);
|
|
27
39
|
if (errors.length)
|
|
28
40
|
throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
|
|
@@ -31,14 +43,18 @@ export async function share(markdown, { cwd = process.cwd() } = {}) {
|
|
|
31
43
|
|
|
32
44
|
let url = guide.meta.url || null;
|
|
33
45
|
let synced = false;
|
|
46
|
+
let notified = false;
|
|
34
47
|
if (api.loggedIn()) {
|
|
35
48
|
const res = await api.publish(guide.meta.id, serialize(guide));
|
|
36
49
|
url = res.url;
|
|
37
50
|
synced = true;
|
|
51
|
+
notified = Boolean(res.notified);
|
|
38
52
|
guide = { ...guide, meta: { ...guide.meta, url } };
|
|
53
|
+
} else if (guide.meta.team) {
|
|
54
|
+
throw new PassalongError("sharing to a team needs sync — run `passalong login` first");
|
|
39
55
|
}
|
|
40
56
|
const path = store.save(guide);
|
|
41
|
-
return { guide, path, url, synced };
|
|
57
|
+
return { guide, path, url, synced, notified };
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
/** Resolve a reference (id, share URL, or local file path) to a guide. */
|
|
@@ -48,7 +64,6 @@ export async function resolve(ref) {
|
|
|
48
64
|
return { guide: parse(md), from: "link" };
|
|
49
65
|
}
|
|
50
66
|
if (ref.endsWith(".md") && existsSync(ref)) {
|
|
51
|
-
const { readFileSync } = await import("node:fs");
|
|
52
67
|
return { guide: parse(readFileSync(ref, "utf8")), from: "file" };
|
|
53
68
|
}
|
|
54
69
|
if (!ID_RE.test(ref))
|
|
@@ -71,15 +86,25 @@ export async function resolve(ref) {
|
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
74
|
-
* Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide
|
|
75
|
-
* the caller can put the text straight into an agent's context.
|
|
89
|
+
* Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide
|
|
90
|
+
* so the caller can put the text straight into an agent's context. A pull of someone else's
|
|
91
|
+
* guide is also recorded server-side, which is how the sender sees the transfer landed.
|
|
76
92
|
*/
|
|
77
93
|
export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
|
|
78
94
|
const { guide, from } = await resolve(ref);
|
|
95
|
+
// A locally cached copy of a teammate's guide still counts as a pull for them.
|
|
96
|
+
if (from === "local" && api.loggedIn() && guide.meta.team) {
|
|
97
|
+
api.get(guide.meta.id).catch(() => {});
|
|
98
|
+
}
|
|
79
99
|
let path = null;
|
|
80
100
|
if (write) {
|
|
81
101
|
const dir = join(cwd, ".passalong");
|
|
82
102
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
103
|
+
// A pulled guide carries its own share URL in the frontmatter, and that URL needs no account
|
|
104
|
+
// to read. Committing this directory would publish the guide to anyone who can see the repo,
|
|
105
|
+
// so the directory ignores itself. Delete this file if you do want guides in the repo.
|
|
106
|
+
const ignore = join(dir, ".gitignore");
|
|
107
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
|
|
83
108
|
path = join(dir, `${guide.meta.id}.md`);
|
|
84
109
|
writeFileSync(path, serialize(guide));
|
|
85
110
|
}
|
|
@@ -87,20 +112,17 @@ export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
|
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
/** Local guides merged with synced ones (by id), newest first, optionally filtered. */
|
|
90
|
-
export async function list(query = "", { remote = true } = {}) {
|
|
91
|
-
const local = store.search(query);
|
|
115
|
+
export async function list(query = "", { remote = true, scope = "" } = {}) {
|
|
116
|
+
const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
|
|
92
117
|
const seen = new Set(local.map((g) => g.meta.id));
|
|
93
118
|
const rows = local.map((g) => ({ ...summary(g), where: "local" }));
|
|
94
119
|
if (remote && api.loggedIn()) {
|
|
95
120
|
try {
|
|
96
|
-
const res = await api.list(query);
|
|
121
|
+
const res = await api.list(query, scope);
|
|
97
122
|
for (const r of res.guides) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
row.url = r.url;
|
|
102
|
-
row.pulls = r.pulls;
|
|
103
|
-
} else rows.push({ ...r, where: "synced" });
|
|
123
|
+
const row = seen.has(r.id) ? rows.find((x) => x.id === r.id) : null;
|
|
124
|
+
if (row) Object.assign(row, r, { where: "synced" });
|
|
125
|
+
else rows.push({ ...r, where: "synced" });
|
|
104
126
|
}
|
|
105
127
|
} catch (err) {
|
|
106
128
|
rows.warning = `sync unavailable: ${err.message}`;
|
|
@@ -109,6 +131,42 @@ export async function list(query = "", { remote = true } = {}) {
|
|
|
109
131
|
return rows.sort((a, b) => (b.created || "").localeCompare(a.created || ""));
|
|
110
132
|
}
|
|
111
133
|
|
|
134
|
+
/** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
|
|
135
|
+
export async function inbox() {
|
|
136
|
+
const res = await api.inbox();
|
|
137
|
+
return res.guides;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The state of your transfers as four queues: waiting on you, in flight (handed over, untouched),
|
|
142
|
+
* landed (someone has it, not closed), and worth keeping (pulled enough to promote). Needs sync.
|
|
143
|
+
*/
|
|
144
|
+
export const board = () => api.board();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What happened while you were away: your guides being pulled and shipped, guides handed to you,
|
|
148
|
+
* invites taken up. Each item carries a rendered `text` line so every surface says the same thing.
|
|
149
|
+
*/
|
|
150
|
+
export async function activity({ all = false, limit = 50 } = {}) {
|
|
151
|
+
return api.notifications({ unread: !all, limit });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Report whether a guide actually works. This is the reader's answer, not the author's status:
|
|
156
|
+
* `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
|
|
157
|
+
* A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
|
|
158
|
+
*/
|
|
159
|
+
export async function verdict(id, ok, note = "") {
|
|
160
|
+
if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
|
|
161
|
+
if (!ok && !note.trim())
|
|
162
|
+
throw new PassalongError("say what went wrong: passalong failed <id> <what happened>");
|
|
163
|
+
const { guide } = await resolve(id);
|
|
164
|
+
return api.verdict(guide.meta.id, ok, note.trim());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Mark notifications seen. No ids means everything unread. */
|
|
168
|
+
export const seen = (ids = []) => api.markRead(ids);
|
|
169
|
+
|
|
112
170
|
export function summary(g) {
|
|
113
171
|
return {
|
|
114
172
|
id: g.meta.id,
|
|
@@ -119,6 +177,9 @@ export function summary(g) {
|
|
|
119
177
|
tags: g.meta.tags || [],
|
|
120
178
|
stack_assumptions: g.meta.stack_assumptions || [],
|
|
121
179
|
url: g.meta.url || null,
|
|
180
|
+
team: g.meta.team || "",
|
|
181
|
+
to: g.meta.to || "",
|
|
182
|
+
mine: true,
|
|
122
183
|
};
|
|
123
184
|
}
|
|
124
185
|
|
|
@@ -134,7 +195,7 @@ export async function setStatus(id, status) {
|
|
|
134
195
|
}
|
|
135
196
|
|
|
136
197
|
export async function remove(id) {
|
|
137
|
-
|
|
198
|
+
// Server first: if it refuses (not the author), the local copy must survive too.
|
|
138
199
|
if (api.loggedIn()) {
|
|
139
200
|
try {
|
|
140
201
|
await api.remove(id);
|
|
@@ -142,6 +203,7 @@ export async function remove(id) {
|
|
|
142
203
|
if (err.status !== 404) throw err;
|
|
143
204
|
}
|
|
144
205
|
}
|
|
206
|
+
store.remove(id);
|
|
145
207
|
}
|
|
146
208
|
|
|
147
209
|
/** Write every local guide to a directory as plain markdown. Returns the paths written. */
|