toolroll 0.8.0 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.1 — 2026-09-30
6
+
7
+ - **Safer `toolroll update`.** A release is accepted only when its signing
8
+ certificate names ap9000/toolroll, its publish workflow and GitHub
9
+ Actions, checked with the Sigstore verifier npm already ships (found from
10
+ npm itself, so a custom npm global folder works too). A failed
11
+ update puts the commands back before stopping the service, stops and
12
+ restarts per-project watch workers with it, restores even when the live
13
+ database can't be copied aside, and keeps the last good update so
14
+ `--rollback` still works after a refused attempt, including for the update
15
+ that brought 0.8.1. The console's update job
16
+ finds pnpm, bun and npm where they are installed; the desktop app is never
17
+ offered npm.
18
+ - **Safer `toolroll onboard`.** It adds the main checkout rather than a
19
+ temporary worktree, never adds your home folder, and asks before adding a
20
+ project. Run by an agent, `toolroll up` never prints the password. Login
21
+ advice matches how the install actually signs in, and uses the port `up`
22
+ serves on. A skill you have edited is left alone.
23
+ - **Chat opens instantly.** First-run suggestions and the agent sign-in check
24
+ refresh in the background, so a slow network never holds up the page.
25
+ Tapping a suggestion adds to what you typed instead of replacing it.
26
+
5
27
  ## 0.8.0 — 2026-09-30
6
28
 
7
29
  - **Install with your agent.** `toolroll onboard`, run by the agent that
package/README.md CHANGED
@@ -53,13 +53,13 @@ Paste this into Claude Code or Codex:
53
53
 
54
54
  > Install Toolroll with `npm install -g toolroll` and start `toolroll up` in
55
55
  > the background; it keeps running. Then run `toolroll onboard` in this
56
- > repository, and run it again with `--yes` to install Toolroll's skill for
57
- > you. Tell me the console address, where my login is saved (not the
56
+ > repository, and run it again with `--yes` to add it as a project and install
57
+ > Toolroll's skill for you. Tell me the console address, where my login is saved (not the
58
58
  > password), how to pair my phone, and what I can ask you next.
59
59
 
60
- `toolroll onboard` adds the repository as a project, says which agent CLIs are
61
- signed in, and prints the line that adds Toolroll as tools
62
- (`claude mcp add toolroll -- toolroll mcp`) without running it.
60
+ `toolroll onboard --yes` adds the repository (its main checkout) as a project,
61
+ says which agent CLIs are signed in, and prints the line that adds Toolroll as
62
+ tools (`claude mcp add --scope user toolroll -- toolroll mcp`) without running it.
63
63
  `toolroll onboard --remove --yes` takes the skill out again.
64
64
 
65
65
  Then read [Getting started](docs/guide/getting-started.md), [Flows](docs/guide/flows.md)
@@ -7,29 +7,34 @@
7
7
  * a thin operator skill for the person's own agent, prints the MCP line
8
8
  * without running it, and ends with a handoff the agent can relay.
9
9
  *
10
- * The skill is the only write outside Toolroll's own state, so it needs
11
- * --yes or a yes typed at a terminal. It is marked as Toolroll's, replaced
12
- * by the next onboard, and deleted by `onboard --remove`; a file of the
13
- * same name that is not ours is never touched.
10
+ * Adding the project and writing the skill both need --yes or one yes
11
+ * typed at a terminal. The project is the main checkout (never a linked
12
+ * worktree, the home folder, or one of Toolroll's own worktrees). The skill
13
+ * is replaced by the next onboard and deleted by `onboard --remove` only
14
+ * while it is exactly a version Toolroll wrote; a file of the same name
15
+ * that is not ours, or ours with the person's edits, is never touched.
14
16
  */
15
17
  import type { ProviderConnection } from "./provider-connection.js";
16
18
  export type OnboardAgent = "claude" | "codex";
17
19
  export declare const ONBOARD_AGENTS: readonly OnboardAgent[];
18
20
  export declare const AGENT_NAMES: Record<OnboardAgent, string>;
19
- /** Each agent's user-level home: where its skills folder lives. */
21
+ /** Each agent's user-level home: where its skills folder lives. A relative CODEX_HOME is under the home folder, never
22
+ * wherever onboard happens to run. */
20
23
  export declare function agentHome(agent: OnboardAgent, home: string, env: Record<string, string | undefined>): string;
21
24
  export declare function operatorSkillPath(agent: OnboardAgent, home: string, env: Record<string, string | undefined>): string;
22
25
  /** The agents this person uses: those whose home folder exists, else Claude Code. */
23
26
  export declare function detectAgents(home: string, env: Record<string, string | undefined>): OnboardAgent[];
24
- /** The line that adds Toolroll as tools. Printed, never run. */
27
+ /** The line that adds Toolroll as tools, for every project. Printed, never run. */
25
28
  export declare function mcpLine(agent: OnboardAgent): string;
26
29
  /** The thin skill: when to reach for Toolroll, and where the real guides are. */
27
30
  export declare function operatorSkillContent(version: string): string;
31
+ /** A skill's fingerprint with the version it names set aside: every release writes the same words. */
32
+ export declare function skillFingerprint(content: string): string;
28
33
  export type SkillStep = {
29
34
  agent: OnboardAgent;
30
35
  path: string;
31
- /** create/replace/remove change the file; current and absent change nothing; not-ours refuses. */
32
- action: "create" | "replace" | "current" | "remove" | "absent" | "not-ours";
36
+ /** create/replace/remove change the file; current and absent change nothing; not-ours and edited refuse. */
37
+ action: "create" | "replace" | "current" | "remove" | "absent" | "not-ours" | "edited";
33
38
  /** Old guide copies of ours that go with it. */
34
39
  alsoRemove: string[];
35
40
  };
@@ -45,23 +50,24 @@ export declare function applyOperatorSkill(steps: readonly SkillStep[], version:
45
50
  wrote: string[];
46
51
  removed: string[];
47
52
  };
53
+ /** How to sign in: the account, and the saved login file when one still works. Never the password. */
54
+ export type HandoffLogin = {
55
+ account: string | null;
56
+ file: string | null;
57
+ };
48
58
  export type Handoff = {
49
59
  console: string;
50
60
  /** How to start the console when it is not running. */
51
61
  start: string;
52
- /** The saved login: the account and the file. Never the password. */
53
- login: {
54
- account: string | null;
55
- file: string;
56
- } | null;
62
+ /** null while there is no account yet. */
63
+ login: HandoffLogin | null;
57
64
  phone: string;
58
65
  next: readonly string[];
59
66
  };
60
67
  export declare const NEXT_THINGS: readonly ["queue these bugs overnight", "what needs me?", "open the result"];
61
68
  export declare function buildHandoff(input: {
62
69
  url: string;
63
- loginFile: string | null;
64
- account: string | null;
70
+ login: HandoffLogin | null;
65
71
  }): Handoff;
66
72
  export declare function handoffLines(handoff: Handoff): string[];
67
73
  /** The account name from a saved login file, never its password. */
@@ -82,16 +88,24 @@ export type OnboardIo = {
82
88
  /** --agent, as typed: a comma list of claude and codex. */
83
89
  agentFlag: string | undefined;
84
90
  url: string;
85
- loginFile: string;
91
+ login: HandoffLogin | null;
86
92
  home: string;
93
+ /** Where Toolroll leases its own worktrees: never a project. */
94
+ worktrees: string;
87
95
  env: Record<string, string | undefined>;
88
96
  version: string;
89
97
  cwd: string;
90
98
  /** A person at a terminal who can be asked y/N. */
91
99
  interactive: boolean;
92
100
  confirm: (question: string) => Promise<boolean>;
93
- /** The repository top folder containing cwd, or null outside one. */
94
- findRepo: (cwd: string) => Promise<string | null>;
101
+ /** The repository containing cwd: its top folder, and its main checkout (the same folder unless cwd is in a linked
102
+ * worktree). null outside one. */
103
+ findRepo: (cwd: string) => Promise<{
104
+ top: string;
105
+ main: string;
106
+ } | null>;
107
+ /** Whether a repository is already a project. */
108
+ enrolled: (repo: string) => Promise<boolean>;
95
109
  /** Add a repository as a project, as Projects → add does. */
96
110
  enroll: (repo: string) => Promise<{
97
111
  ok: true;
@@ -102,5 +116,14 @@ export type OnboardIo = {
102
116
  }>;
103
117
  checkConnection: (agent: OnboardAgent) => Promise<ProviderConnection>;
104
118
  };
119
+ /** Why a found repository cannot be the project, or null when it can. */
120
+ export declare function projectRefusal(found: {
121
+ top: string;
122
+ main: string;
123
+ }, where: {
124
+ cwd: string;
125
+ home: string;
126
+ worktrees: string;
127
+ }): string | null;
105
128
  /** Returns the exit code: 0 done, 2 usage, 3 a refusal to answer. */
106
129
  export declare function runOnboard(io: OnboardIo): Promise<number>;
@@ -7,22 +7,30 @@
7
7
  * a thin operator skill for the person's own agent, prints the MCP line
8
8
  * without running it, and ends with a handoff the agent can relay.
9
9
  *
10
- * The skill is the only write outside Toolroll's own state, so it needs
11
- * --yes or a yes typed at a terminal. It is marked as Toolroll's, replaced
12
- * by the next onboard, and deleted by `onboard --remove`; a file of the
13
- * same name that is not ours is never touched.
10
+ * Adding the project and writing the skill both need --yes or one yes
11
+ * typed at a terminal. The project is the main checkout (never a linked
12
+ * worktree, the home folder, or one of Toolroll's own worktrees). The skill
13
+ * is replaced by the next onboard and deleted by `onboard --remove` only
14
+ * while it is exactly a version Toolroll wrote; a file of the same name
15
+ * that is not ours, or ours with the person's edits, is never touched.
14
16
  */
15
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
16
- import { join } from "node:path";
17
- import { CLAUDE_CODE_GUIDES, CLAUDE_CODE_MANAGED_MARK, MANAGED_MARK, OPERATOR_SKILL_MARK } from "./skills.js";
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
19
+ import { isAbsolute, join, resolve, sep } from "node:path";
20
+ import { CLAUDE_CODE_GUIDES, CLAUDE_CODE_MANAGED_MARK, MANAGED_MARK, OPERATOR_SKILL_MARK, claudeCodeSkillContent } from "./skills.js";
18
21
  import { connectionWords } from "./control-ui.js";
19
22
  import { envelopeJson } from "./envelope.js";
20
23
  export const ONBOARD_AGENTS = ["claude", "codex"];
21
24
  export const AGENT_NAMES = { claude: "Claude Code", codex: "Codex" };
22
- /** Each agent's user-level home: where its skills folder lives. */
25
+ /** Each agent's user-level home: where its skills folder lives. A relative CODEX_HOME is under the home folder, never
26
+ * wherever onboard happens to run. */
23
27
  export function agentHome(agent, home, env) {
24
- if (agent === "codex")
25
- return env["CODEX_HOME"] !== undefined && env["CODEX_HOME"] !== "" ? env["CODEX_HOME"] : join(home, ".codex");
28
+ if (agent === "codex") {
29
+ const codexHome = env["CODEX_HOME"];
30
+ if (codexHome === undefined || codexHome === "")
31
+ return join(home, ".codex");
32
+ return isAbsolute(codexHome) ? codexHome : resolve(home, codexHome);
33
+ }
26
34
  return join(home, ".claude");
27
35
  }
28
36
  export function operatorSkillPath(agent, home, env) {
@@ -33,9 +41,9 @@ export function detectAgents(home, env) {
33
41
  const found = ONBOARD_AGENTS.filter(agent => existsSync(agentHome(agent, home, env)));
34
42
  return found.length > 0 ? found : ["claude"];
35
43
  }
36
- /** The line that adds Toolroll as tools. Printed, never run. */
44
+ /** The line that adds Toolroll as tools, for every project. Printed, never run. */
37
45
  export function mcpLine(agent) {
38
- return `${agent} mcp add toolroll -- toolroll mcp`;
46
+ return agent === "claude" ? "claude mcp add --scope user toolroll -- toolroll mcp" : `${agent} mcp add toolroll -- toolroll mcp`;
39
47
  }
40
48
  /** The thin skill: when to reach for Toolroll, and where the real guides are. */
41
49
  export function operatorSkillContent(version) {
@@ -64,13 +72,24 @@ Read the guides from the installed binary. They match its version, so do not cop
64
72
  Approving work and answering decisions belong to the person. Never read out or repeat their password.
65
73
  `;
66
74
  }
67
- /** A regular file of ours: our frontmatter and one of our marks. */
68
- function ownSkill(path) {
75
+ const escapedMark = OPERATOR_SKILL_MARK.replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
76
+ const WRITTEN_BY = new RegExp(`^(${escapedMark} · written by toolroll )(\\S+?)(\\. \`toolroll onboard)`, "m");
77
+ /** A skill's fingerprint with the version it names set aside: every release writes the same words. */
78
+ export function skillFingerprint(content) {
79
+ return createHash("sha256").update(content.replace(WRITTEN_BY, "$1*$3")).digest("hex");
80
+ }
81
+ /** Every SKILL.md Toolroll has generated at this path: the operator skill, and `skills install --claude-code`'s. */
82
+ const KNOWN_SKILLS = new Set([operatorSkillContent("0.0.0"), claudeCodeSkillContent()].map(skillFingerprint));
83
+ /** ours: exactly a version Toolroll wrote. edited: ours, with the person's changes. foreign: not ours at all. */
84
+ function skillOwnership(path) {
69
85
  const entry = lstatSync(path);
70
86
  if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 2_000_000)
71
- return false;
87
+ return "foreign";
72
88
  const content = readFileSync(path, "utf8");
73
- return content.startsWith("---\nname: toolroll\n") && [OPERATOR_SKILL_MARK, CLAUDE_CODE_MANAGED_MARK, MANAGED_MARK].some(mark => content.includes(mark));
89
+ const marked = content.startsWith("---\nname: toolroll\n") && [OPERATOR_SKILL_MARK, CLAUDE_CODE_MANAGED_MARK, MANAGED_MARK].some(mark => content.includes(mark));
90
+ if (!marked)
91
+ return "foreign";
92
+ return KNOWN_SKILLS.has(skillFingerprint(content)) ? "ours" : "edited";
74
93
  }
75
94
  /** Guide copies an older `skills install --claude-code` left beside SKILL.md. */
76
95
  function oldGuideCopies(folder) {
@@ -103,8 +122,9 @@ export function planOperatorSkill(agents, options) {
103
122
  if (!lstatExists(path)) {
104
123
  return { agent, path, action: options.remove ? (alsoRemove.length > 0 ? "remove" : "absent") : "create", alsoRemove };
105
124
  }
106
- if (!ownSkill(path))
107
- return { agent, path, action: "not-ours", alsoRemove: [] };
125
+ const ownership = skillOwnership(path);
126
+ if (ownership !== "ours")
127
+ return { agent, path, action: ownership === "edited" ? "edited" : "not-ours", alsoRemove: [] };
108
128
  if (options.remove)
109
129
  return { agent, path, action: "remove", alsoRemove };
110
130
  const current = readFileSync(path, "utf8") === operatorSkillContent(options.version);
@@ -147,15 +167,17 @@ export function buildHandoff(input) {
147
167
  return {
148
168
  console: input.url,
149
169
  start: "toolroll up",
150
- login: input.loginFile === null ? null : { account: input.account, file: input.loginFile },
170
+ login: input.login,
151
171
  phone: "Open the console on your phone over your tailnet, or pair Telegram in Settings → Telegram with the /pair code it shows.",
152
172
  next: NEXT_THINGS,
153
173
  };
154
174
  }
155
175
  export function handoffLines(handoff) {
156
176
  const login = handoff.login === null
157
- ? "no saved login yet; `toolroll up` creates one"
158
- : `${handoff.login.account === null ? "the account" : handoff.login.account} — the password is in ${handoff.login.file}`;
177
+ ? "no account yet; `toolroll up` creates one"
178
+ : handoff.login.file === null
179
+ ? `${handoff.login.account ?? "your account"} — sign in with your password`
180
+ : `${handoff.login.account ?? "the account"} — the password is in ${handoff.login.file}`;
159
181
  return [
160
182
  "Toolroll is ready.",
161
183
  ` console ${handoff.console}`,
@@ -178,6 +200,27 @@ export function loginAccount(file) {
178
200
  export function agentReport(agent, connection) {
179
201
  return { agent, name: AGENT_NAMES[agent], state: connection.state, words: connectionWords(connection), ...(connection.plan === undefined ? {} : { plan: connection.plan }) };
180
202
  }
203
+ const real = (path) => {
204
+ try {
205
+ return realpathSync(path);
206
+ }
207
+ catch {
208
+ return resolve(path);
209
+ }
210
+ };
211
+ const within = (path, folder) => path === folder || path.startsWith(folder.endsWith(sep) ? folder : `${folder}${sep}`);
212
+ /** Why a found repository cannot be the project, or null when it can. */
213
+ export function projectRefusal(found, where) {
214
+ const worktrees = real(where.worktrees);
215
+ if ([where.cwd, found.top, found.main].some(path => within(real(path), worktrees))) {
216
+ return `${found.top} is one of Toolroll's own worktrees, not a project: run onboard inside your own checkout`;
217
+ }
218
+ const home = real(where.home);
219
+ if (real(found.main) === home || real(found.top) === home) {
220
+ return `${home} is your home folder, not a project: run onboard inside the repository you want to hand off`;
221
+ }
222
+ return null;
223
+ }
181
224
  /** Returns the exit code: 0 done, 2 usage, 3 a refusal to answer. */
182
225
  export async function runOnboard(io) {
183
226
  const command = "onboard";
@@ -196,70 +239,101 @@ export async function runOnboard(io) {
196
239
  else {
197
240
  agents = detectAgents(io.home, io.env);
198
241
  }
199
- // The skill: validate every file first, then ask once, then write.
242
+ // The skill: validate every file first.
200
243
  const steps = planOperatorSkill(agents, { home: io.home, env: io.env, version: io.version, remove: io.remove });
201
244
  const changes = steps.filter(step => step.action === "create" || step.action === "replace" || step.action === "remove");
202
- const foreign = steps.filter(step => step.action === "not-ours");
245
+ const foreign = steps.filter(step => step.action === "not-ours" || step.action === "edited");
246
+ const skillChanges = foreign.length === 0 && changes.length > 0;
247
+ // The project: the main checkout of the repository onboard runs in, never --remove's business.
248
+ let project = { state: "none" };
249
+ if (!io.remove) {
250
+ const found = await io.findRepo(io.cwd);
251
+ if (found !== null) {
252
+ const refusal = projectRefusal(found, { cwd: io.cwd, home: io.home, worktrees: io.worktrees });
253
+ if (refusal !== null)
254
+ project = { state: "refused", path: found.main, message: refusal };
255
+ else
256
+ project = { state: (await io.enrolled(found.main)) ? "already" : "needs-yes", path: found.main };
257
+ }
258
+ }
259
+ const projectChange = project.state === "needs-yes";
260
+ // Then ask once for everything that writes, then write.
261
+ let consent = false;
262
+ if (skillChanges || projectChange) {
263
+ const parts = [
264
+ ...(projectChange ? [`add ${project.path} as a project`] : []),
265
+ ...(skillChanges ? [`${io.remove ? "remove" : "write"} ${changes.map(step => step.path).join(" and ")}`] : []),
266
+ ];
267
+ const question = `${parts.join(" and ").replace(/^./, first => first.toUpperCase())}? [y/N]`;
268
+ consent = io.yes || (io.interactive && !io.json && (await io.confirm(question)));
269
+ }
270
+ const unconfirmed = io.interactive && !io.json && !io.yes ? "declined" : "needs-yes";
203
271
  let skill;
204
272
  if (foreign.length > 0) {
205
- skill = { state: "not-ours", files: steps, wrote: [], removed: [] };
273
+ skill = { state: foreign.some(step => step.action === "not-ours") ? "not-ours" : "edited", files: steps, wrote: [], removed: [] };
206
274
  }
207
- else if (changes.length === 0) {
275
+ else if (!skillChanges) {
208
276
  skill = { state: io.remove ? "absent" : "current", files: steps, wrote: [], removed: [] };
209
277
  }
278
+ else if (!consent) {
279
+ skill = { state: unconfirmed, files: steps, wrote: [], removed: [] };
280
+ }
210
281
  else {
211
- const paths = changes.map(step => step.path);
212
- const consent = io.yes || (io.interactive && !io.json && (await io.confirm(`${io.remove ? "Remove" : "Write"} ${paths.join(" and ")}? [y/N]`)));
213
- if (!consent) {
214
- skill = { state: io.interactive && !io.json && !io.yes ? "declined" : "needs-yes", files: steps, wrote: [], removed: [] };
215
- }
216
- else {
217
- const done = applyOperatorSkill(steps, io.version);
218
- skill = { state: io.remove ? "removed" : "written", files: steps, ...done };
219
- }
282
+ const done = applyOperatorSkill(steps, io.version);
283
+ skill = { state: io.remove ? "removed" : "written", files: steps, ...done };
220
284
  }
221
285
  const skillLine = () => {
222
286
  const where = (list) => list.map(step => step.path).join(", ");
287
+ const nothing = io.remove ? "removed" : "written";
223
288
  switch (skill.state) {
224
289
  case "written": return `skill wrote ${skill.wrote.join(", ")}`;
225
290
  case "removed": return `skill removed ${skill.removed.join(", ")}`;
226
291
  case "current": return `skill up to date: ${where(steps)}`;
227
292
  case "absent": return "skill not installed; nothing to remove";
228
- case "not-ours": return `skill ${where(foreign)} ${foreign.length === 1 ? "is" : "are"} not Toolroll's, so nothing was ${io.remove ? "removed" : "written"}`;
229
- case "declined": return `skill nothing ${io.remove ? "removed" : "written"}`;
230
- case "needs-yes": return `skill not ${io.remove ? "removed" : "written"} yet: run again with --yes to ${io.remove ? "remove" : "write"} ${where(changes)}`;
293
+ case "not-ours": return `skill ${where(foreign)} ${foreign.length === 1 ? "is" : "are"} not Toolroll's, so nothing was ${nothing}`;
294
+ case "edited": return `skill ${where(foreign)} ${foreign.length === 1 ? "has your edits, so it was" : "have your edits, so they were"} left as is`;
295
+ case "declined": return `skill nothing ${nothing}`;
296
+ case "needs-yes": return `skill not ${nothing} yet: run again with --yes to ${io.remove ? "remove" : "write"} ${where(changes)}`;
231
297
  }
232
298
  };
233
299
  const skillData = { state: skill.state, files: skill.files.map(step => ({ agent: step.agent, path: step.path, action: step.action })), wrote: skill.wrote, removed: skill.removed };
234
300
  if (io.remove) {
235
- if (skill.state === "not-ours")
236
- return refuse("not-ours", skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
301
+ if (skill.state === "not-ours" || skill.state === "edited")
302
+ return refuse(skill.state, skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
237
303
  if (skill.state === "needs-yes" || skill.state === "declined") {
238
304
  return refuse("unconfirmed", skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
239
305
  }
240
306
  io.write(io.json ? envelopeJson({ ok: true, command, skill: skillData }) : skillLine());
241
307
  return 0;
242
308
  }
243
- // The project, then who is signed in.
244
- const repo = await io.findRepo(io.cwd);
245
- let project = null;
246
- let projectProblem = null;
247
- if (repo !== null) {
248
- const enrolled = await io.enroll(repo);
249
- if (enrolled.ok)
250
- project = { path: repo, added: enrolled.added };
251
- else
252
- projectProblem = enrolled.message;
309
+ if (project.state === "needs-yes") {
310
+ if (!consent)
311
+ project = { state: unconfirmed, path: project.path };
312
+ else {
313
+ const enrolled = await io.enroll(project.path);
314
+ project = enrolled.ok ? { state: enrolled.added ? "added" : "already", path: project.path } : { state: "failed", path: project.path, message: enrolled.message };
315
+ }
253
316
  }
254
317
  const reports = await Promise.all(ONBOARD_AGENTS.map(async (agent) => agentReport(agent, await io.checkConnection(agent))));
255
- const handoff = buildHandoff({ url: io.url, loginFile: existsSync(io.loginFile) ? io.loginFile : null, account: loginAccount(io.loginFile) });
318
+ const handoff = buildHandoff({ url: io.url, login: io.login });
256
319
  const mcp = agents.map(agent => ({ agent, command: mcpLine(agent) }));
320
+ const projectData = project.state === "added" || project.state === "already" ? { path: project.path, added: project.state === "added" } : null;
321
+ const projectProblem = (() => {
322
+ switch (project.state) {
323
+ case "none": return "not inside a git repository";
324
+ case "refused": return project.message;
325
+ case "failed": return `${project.path} could not be added — ${project.message}`;
326
+ case "needs-yes": return `not added yet: run again with --yes to add ${project.path}`;
327
+ case "declined": return `${project.path} not added`;
328
+ default: return null;
329
+ }
330
+ })();
257
331
  if (io.json) {
258
332
  io.write(envelopeJson({
259
333
  ok: true,
260
334
  command,
261
- project,
262
- ...(repo === null ? { projectProblem: "not inside a git repository" } : projectProblem === null ? {} : { projectProblem }),
335
+ project: projectData,
336
+ ...(projectProblem === null ? {} : { projectProblem }),
263
337
  agents: reports,
264
338
  skill: skillData,
265
339
  mcp,
@@ -267,9 +341,9 @@ export async function runOnboard(io) {
267
341
  }));
268
342
  return 0;
269
343
  }
270
- const projectLine = project !== null
271
- ? `project ${project.path} — ${project.added ? "added" : "already added"}`
272
- : repo === null ? "project none added: run onboard inside the repository you want to hand off" : `project ${repo} could not be added — ${projectProblem}`;
344
+ const projectLine = projectData !== null
345
+ ? `project ${projectData.path} — ${projectData.added ? "added" : "already added"}`
346
+ : project.state === "none" ? "project none added: run onboard inside the repository you want to hand off" : `project ${projectProblem}`;
273
347
  io.write([
274
348
  projectLine,
275
349
  `agents ${reports.map(one => `${one.name}: ${[one.words, one.plan].filter(Boolean).join(" · ")}`).join("; ")}`,