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