toolroll 0.8.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.2 — 2026-09-30
6
+
7
+ - **Task checkouts stop using space nobody wants.** A finished task's clean
8
+ checkout now goes when its task is complete or cancelled (Settings →
9
+ Storage or `toolroll storage cleanup` changes that to 2 days, a week or
10
+ never); its branch always stays. Toolroll's own progress file no longer
11
+ keeps a checkout forever. `toolroll storage clean` and the Clean up button
12
+ preview what goes, what it frees and what stays and why, then remove it;
13
+ a checkout kept for its changes can be discarded on purpose. Every removal
14
+ is in the ledger.
15
+ - **Safer recovery.** A failed update no longer moves a healthy database aside
16
+ (only one that can't be read), checks the backup and free space first, and
17
+ puts the database back if the restore fails. `toolroll mcp` exits when its
18
+ database is replaced, so an agent's writes never go to a stale file.
19
+ Cancelling re-reads the update under its lock, `toolroll onboard` removes
20
+ only guide copies it wrote, more agents are recognised as agents, and the
21
+ console never waits on npm.
22
+
23
+ ## 0.8.1 — 2026-09-30
24
+
25
+ - **Safer `toolroll update`.** A release is accepted only when its signing
26
+ certificate names ap9000/toolroll, its publish workflow and GitHub
27
+ Actions, checked with the Sigstore verifier npm already ships (found from
28
+ npm itself, so a custom npm global folder works too). A failed
29
+ update puts the commands back before stopping the service, stops and
30
+ restarts per-project watch workers with it, restores even when the live
31
+ database can't be copied aside, and keeps the last good update so
32
+ `--rollback` still works after a refused attempt, including for the update
33
+ that brought 0.8.1. The console's update job
34
+ finds pnpm, bun and npm where they are installed; the desktop app is never
35
+ offered npm.
36
+ - **Safer `toolroll onboard`.** It adds the main checkout rather than a
37
+ temporary worktree, never adds your home folder, and asks before adding a
38
+ project. Run by an agent, `toolroll up` never prints the password. Login
39
+ advice matches how the install actually signs in, and uses the port `up`
40
+ serves on. A skill you have edited is left alone.
41
+ - **Chat opens instantly.** First-run suggestions and the agent sign-in check
42
+ refresh in the background, so a slow network never holds up the page.
43
+ Tapping a suggestion adds to what you typed instead of replacing it.
44
+
5
45
  ## 0.8.0 — 2026-09-30
6
46
 
7
47
  - **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,38 @@
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;
33
+ /** Every SKILL.md a release has generated at this path (skillFingerprint): the operator skill, and `skills install
34
+ * --claude-code`'s. Kept, never replaced, when the words change: a skill an earlier release wrote is still ours. A
35
+ * test fails until this build's own fingerprints are listed. */
36
+ export declare const RELEASED_SKILLS: readonly string[];
28
37
  export type SkillStep = {
29
38
  agent: OnboardAgent;
30
39
  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";
40
+ /** create/replace/remove change the file; current and absent change nothing; not-ours and edited refuse. */
41
+ action: "create" | "replace" | "current" | "remove" | "absent" | "not-ours" | "edited";
33
42
  /** Old guide copies of ours that go with it. */
34
43
  alsoRemove: string[];
35
44
  };
@@ -45,23 +54,24 @@ export declare function applyOperatorSkill(steps: readonly SkillStep[], version:
45
54
  wrote: string[];
46
55
  removed: string[];
47
56
  };
57
+ /** How to sign in: the account, and the saved login file when one still works. Never the password. */
58
+ export type HandoffLogin = {
59
+ account: string | null;
60
+ file: string | null;
61
+ };
48
62
  export type Handoff = {
49
63
  console: string;
50
64
  /** How to start the console when it is not running. */
51
65
  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;
66
+ /** null while there is no account yet. */
67
+ login: HandoffLogin | null;
57
68
  phone: string;
58
69
  next: readonly string[];
59
70
  };
60
71
  export declare const NEXT_THINGS: readonly ["queue these bugs overnight", "what needs me?", "open the result"];
61
72
  export declare function buildHandoff(input: {
62
73
  url: string;
63
- loginFile: string | null;
64
- account: string | null;
74
+ login: HandoffLogin | null;
65
75
  }): Handoff;
66
76
  export declare function handoffLines(handoff: Handoff): string[];
67
77
  /** The account name from a saved login file, never its password. */
@@ -82,16 +92,24 @@ export type OnboardIo = {
82
92
  /** --agent, as typed: a comma list of claude and codex. */
83
93
  agentFlag: string | undefined;
84
94
  url: string;
85
- loginFile: string;
95
+ login: HandoffLogin | null;
86
96
  home: string;
97
+ /** Where Toolroll leases its own worktrees: never a project. */
98
+ worktrees: string;
87
99
  env: Record<string, string | undefined>;
88
100
  version: string;
89
101
  cwd: string;
90
102
  /** A person at a terminal who can be asked y/N. */
91
103
  interactive: boolean;
92
104
  confirm: (question: string) => Promise<boolean>;
93
- /** The repository top folder containing cwd, or null outside one. */
94
- findRepo: (cwd: string) => Promise<string | null>;
105
+ /** The repository containing cwd: its top folder, and its main checkout (the same folder unless cwd is in a linked
106
+ * worktree). null outside one. */
107
+ findRepo: (cwd: string) => Promise<{
108
+ top: string;
109
+ main: string;
110
+ } | null>;
111
+ /** Whether a repository is already a project. */
112
+ enrolled: (repo: string) => Promise<boolean>;
95
113
  /** Add a repository as a project, as Projects → add does. */
96
114
  enroll: (repo: string) => Promise<{
97
115
  ok: true;
@@ -102,5 +120,14 @@ export type OnboardIo = {
102
120
  }>;
103
121
  checkConnection: (agent: OnboardAgent) => Promise<ProviderConnection>;
104
122
  };
123
+ /** Why a found repository cannot be the project, or null when it can. */
124
+ export declare function projectRefusal(found: {
125
+ top: string;
126
+ main: string;
127
+ }, where: {
128
+ cwd: string;
129
+ home: string;
130
+ worktrees: string;
131
+ }): string | null;
105
132
  /** Returns the exit code: 0 done, 2 usage, 3 a refusal to answer. */
106
133
  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, claudeCodeGuideContent, 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,20 +72,46 @@ 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 a release has generated at this path (skillFingerprint): the operator skill, and `skills install
82
+ * --claude-code`'s. Kept, never replaced, when the words change: a skill an earlier release wrote is still ours. A
83
+ * test fails until this build's own fingerprints are listed. */
84
+ export const RELEASED_SKILLS = [
85
+ "6ff9b4458f332636b6c5968c416bee4770564ef0190d62ec8640e84051fb7f60", // skills install --claude-code
86
+ "3fb6741eec6d394a0a41604cae321da05d85c74f440424b740151c131f125742", // the operator skill
87
+ ];
88
+ const KNOWN_SKILLS = new Set([...RELEASED_SKILLS, ...[operatorSkillContent("0.0.0"), claudeCodeSkillContent()].map(skillFingerprint)]);
89
+ /** SHA-256 of every guide copy (console.md, operating.md, runner.md) an earlier `skills install --claude-code` wrote
90
+ * beside SKILL.md, and of this build's. Only an exact one of these is removed: an edited copy is the person's. */
91
+ const RELEASED_GUIDES = [
92
+ "a5aa20f93d803fd49bbac2908e46353a4f90c95c2bb57993ee724770c28adb0b", "65fc1774c4d6dd99e71a7cfb245a61148ac74889765477ddd5066703cd28f55c", "01604f373b92de737ae7f75804c7f7f09dd690ed96a3e676be75f71358819f23",
93
+ "1e1ee5c46c84b65c931e86c005802478415ec026b6240367e7f12a8d66d608eb", "e5270e70eb09f202980a236a0ae36ac2e1c3df91840c24d40a6a8bd4c0d47fe8", "969dd5e97b63f27dc559889a551f4392bcf289f2b47ce802338868f06e05f1c9",
94
+ "bea301cb7b7d7711afb165b72c355ff829e6341abb75c59431c4a0a7c129b6cd",
95
+ ];
96
+ const guideHash = (content) => createHash("sha256").update(content).digest("hex");
97
+ const KNOWN_GUIDES = new Set([...RELEASED_GUIDES, ...CLAUDE_CODE_GUIDES.map(name => guideHash(claudeCodeGuideContent(name)))]);
98
+ /** ours: exactly a version Toolroll wrote. edited: ours, with the person's changes. foreign: not ours at all. */
99
+ function skillOwnership(path) {
69
100
  const entry = lstatSync(path);
70
101
  if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 2_000_000)
71
- return false;
102
+ return "foreign";
72
103
  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));
104
+ const marked = content.startsWith("---\nname: toolroll\n") && [OPERATOR_SKILL_MARK, CLAUDE_CODE_MANAGED_MARK, MANAGED_MARK].some(mark => content.includes(mark));
105
+ if (!marked)
106
+ return "foreign";
107
+ return KNOWN_SKILLS.has(skillFingerprint(content)) ? "ours" : "edited";
74
108
  }
75
- /** Guide copies an older `skills install --claude-code` left beside SKILL.md. */
109
+ /** Guide copies an older `skills install --claude-code` left beside SKILL.md, exactly as it wrote them. */
76
110
  function oldGuideCopies(folder) {
77
111
  return CLAUDE_CODE_GUIDES.map(name => join(folder, `${name}.md`)).filter(path => {
78
112
  try {
79
113
  const entry = lstatSync(path);
80
- return !entry.isSymbolicLink() && entry.isFile() && entry.size <= 2_000_000 && readFileSync(path, "utf8").startsWith(`${CLAUDE_CODE_MANAGED_MARK}\n\n`);
114
+ return !entry.isSymbolicLink() && entry.isFile() && entry.size <= 2_000_000 && KNOWN_GUIDES.has(guideHash(readFileSync(path, "utf8")));
81
115
  }
82
116
  catch {
83
117
  return false;
@@ -103,8 +137,9 @@ export function planOperatorSkill(agents, options) {
103
137
  if (!lstatExists(path)) {
104
138
  return { agent, path, action: options.remove ? (alsoRemove.length > 0 ? "remove" : "absent") : "create", alsoRemove };
105
139
  }
106
- if (!ownSkill(path))
107
- return { agent, path, action: "not-ours", alsoRemove: [] };
140
+ const ownership = skillOwnership(path);
141
+ if (ownership !== "ours")
142
+ return { agent, path, action: ownership === "edited" ? "edited" : "not-ours", alsoRemove: [] };
108
143
  if (options.remove)
109
144
  return { agent, path, action: "remove", alsoRemove };
110
145
  const current = readFileSync(path, "utf8") === operatorSkillContent(options.version);
@@ -147,15 +182,17 @@ export function buildHandoff(input) {
147
182
  return {
148
183
  console: input.url,
149
184
  start: "toolroll up",
150
- login: input.loginFile === null ? null : { account: input.account, file: input.loginFile },
185
+ login: input.login,
151
186
  phone: "Open the console on your phone over your tailnet, or pair Telegram in Settings → Telegram with the /pair code it shows.",
152
187
  next: NEXT_THINGS,
153
188
  };
154
189
  }
155
190
  export function handoffLines(handoff) {
156
191
  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}`;
192
+ ? "no account yet; `toolroll up` creates one"
193
+ : handoff.login.file === null
194
+ ? `${handoff.login.account ?? "your account"} — sign in with your password`
195
+ : `${handoff.login.account ?? "the account"} — the password is in ${handoff.login.file}`;
159
196
  return [
160
197
  "Toolroll is ready.",
161
198
  ` console ${handoff.console}`,
@@ -178,6 +215,27 @@ export function loginAccount(file) {
178
215
  export function agentReport(agent, connection) {
179
216
  return { agent, name: AGENT_NAMES[agent], state: connection.state, words: connectionWords(connection), ...(connection.plan === undefined ? {} : { plan: connection.plan }) };
180
217
  }
218
+ const real = (path) => {
219
+ try {
220
+ return realpathSync(path);
221
+ }
222
+ catch {
223
+ return resolve(path);
224
+ }
225
+ };
226
+ const within = (path, folder) => path === folder || path.startsWith(folder.endsWith(sep) ? folder : `${folder}${sep}`);
227
+ /** Why a found repository cannot be the project, or null when it can. */
228
+ export function projectRefusal(found, where) {
229
+ const worktrees = real(where.worktrees);
230
+ if ([where.cwd, found.top, found.main].some(path => within(real(path), worktrees))) {
231
+ return `${found.top} is one of Toolroll's own worktrees, not a project: run onboard inside your own checkout`;
232
+ }
233
+ const home = real(where.home);
234
+ if (real(found.main) === home || real(found.top) === home) {
235
+ return `${home} is your home folder, not a project: run onboard inside the repository you want to hand off`;
236
+ }
237
+ return null;
238
+ }
181
239
  /** Returns the exit code: 0 done, 2 usage, 3 a refusal to answer. */
182
240
  export async function runOnboard(io) {
183
241
  const command = "onboard";
@@ -196,70 +254,101 @@ export async function runOnboard(io) {
196
254
  else {
197
255
  agents = detectAgents(io.home, io.env);
198
256
  }
199
- // The skill: validate every file first, then ask once, then write.
257
+ // The skill: validate every file first.
200
258
  const steps = planOperatorSkill(agents, { home: io.home, env: io.env, version: io.version, remove: io.remove });
201
259
  const changes = steps.filter(step => step.action === "create" || step.action === "replace" || step.action === "remove");
202
- const foreign = steps.filter(step => step.action === "not-ours");
260
+ const foreign = steps.filter(step => step.action === "not-ours" || step.action === "edited");
261
+ const skillChanges = foreign.length === 0 && changes.length > 0;
262
+ // The project: the main checkout of the repository onboard runs in, never --remove's business.
263
+ let project = { state: "none" };
264
+ if (!io.remove) {
265
+ const found = await io.findRepo(io.cwd);
266
+ if (found !== null) {
267
+ const refusal = projectRefusal(found, { cwd: io.cwd, home: io.home, worktrees: io.worktrees });
268
+ if (refusal !== null)
269
+ project = { state: "refused", path: found.main, message: refusal };
270
+ else
271
+ project = { state: (await io.enrolled(found.main)) ? "already" : "needs-yes", path: found.main };
272
+ }
273
+ }
274
+ const projectChange = project.state === "needs-yes";
275
+ // Then ask once for everything that writes, then write.
276
+ let consent = false;
277
+ if (skillChanges || projectChange) {
278
+ const parts = [
279
+ ...(projectChange ? [`add ${project.path} as a project`] : []),
280
+ ...(skillChanges ? [`${io.remove ? "remove" : "write"} ${changes.map(step => step.path).join(" and ")}`] : []),
281
+ ];
282
+ const question = `${parts.join(" and ").replace(/^./, first => first.toUpperCase())}? [y/N]`;
283
+ consent = io.yes || (io.interactive && !io.json && (await io.confirm(question)));
284
+ }
285
+ const unconfirmed = io.interactive && !io.json && !io.yes ? "declined" : "needs-yes";
203
286
  let skill;
204
287
  if (foreign.length > 0) {
205
- skill = { state: "not-ours", files: steps, wrote: [], removed: [] };
288
+ skill = { state: foreign.some(step => step.action === "not-ours") ? "not-ours" : "edited", files: steps, wrote: [], removed: [] };
206
289
  }
207
- else if (changes.length === 0) {
290
+ else if (!skillChanges) {
208
291
  skill = { state: io.remove ? "absent" : "current", files: steps, wrote: [], removed: [] };
209
292
  }
293
+ else if (!consent) {
294
+ skill = { state: unconfirmed, files: steps, wrote: [], removed: [] };
295
+ }
210
296
  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
- }
297
+ const done = applyOperatorSkill(steps, io.version);
298
+ skill = { state: io.remove ? "removed" : "written", files: steps, ...done };
220
299
  }
221
300
  const skillLine = () => {
222
301
  const where = (list) => list.map(step => step.path).join(", ");
302
+ const nothing = io.remove ? "removed" : "written";
223
303
  switch (skill.state) {
224
304
  case "written": return `skill wrote ${skill.wrote.join(", ")}`;
225
305
  case "removed": return `skill removed ${skill.removed.join(", ")}`;
226
306
  case "current": return `skill up to date: ${where(steps)}`;
227
307
  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)}`;
308
+ case "not-ours": return `skill ${where(foreign)} ${foreign.length === 1 ? "is" : "are"} not Toolroll's, so nothing was ${nothing}`;
309
+ case "edited": return `skill ${where(foreign)} ${foreign.length === 1 ? "has your edits, so it was" : "have your edits, so they were"} left as is`;
310
+ case "declined": return `skill nothing ${nothing}`;
311
+ case "needs-yes": return `skill not ${nothing} yet: run again with --yes to ${io.remove ? "remove" : "write"} ${where(changes)}`;
231
312
  }
232
313
  };
233
314
  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
315
  if (io.remove) {
235
- if (skill.state === "not-ours")
236
- return refuse("not-ours", skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
316
+ if (skill.state === "not-ours" || skill.state === "edited")
317
+ return refuse(skill.state, skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
237
318
  if (skill.state === "needs-yes" || skill.state === "declined") {
238
319
  return refuse("unconfirmed", skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
239
320
  }
240
321
  io.write(io.json ? envelopeJson({ ok: true, command, skill: skillData }) : skillLine());
241
322
  return 0;
242
323
  }
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;
324
+ if (project.state === "needs-yes") {
325
+ if (!consent)
326
+ project = { state: unconfirmed, path: project.path };
327
+ else {
328
+ const enrolled = await io.enroll(project.path);
329
+ project = enrolled.ok ? { state: enrolled.added ? "added" : "already", path: project.path } : { state: "failed", path: project.path, message: enrolled.message };
330
+ }
253
331
  }
254
332
  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) });
333
+ const handoff = buildHandoff({ url: io.url, login: io.login });
256
334
  const mcp = agents.map(agent => ({ agent, command: mcpLine(agent) }));
335
+ const projectData = project.state === "added" || project.state === "already" ? { path: project.path, added: project.state === "added" } : null;
336
+ const projectProblem = (() => {
337
+ switch (project.state) {
338
+ case "none": return "not inside a git repository";
339
+ case "refused": return project.message;
340
+ case "failed": return `${project.path} could not be added — ${project.message}`;
341
+ case "needs-yes": return `not added yet: run again with --yes to add ${project.path}`;
342
+ case "declined": return `${project.path} not added`;
343
+ default: return null;
344
+ }
345
+ })();
257
346
  if (io.json) {
258
347
  io.write(envelopeJson({
259
348
  ok: true,
260
349
  command,
261
- project,
262
- ...(repo === null ? { projectProblem: "not inside a git repository" } : projectProblem === null ? {} : { projectProblem }),
350
+ project: projectData,
351
+ ...(projectProblem === null ? {} : { projectProblem }),
263
352
  agents: reports,
264
353
  skill: skillData,
265
354
  mcp,
@@ -267,9 +356,9 @@ export async function runOnboard(io) {
267
356
  }));
268
357
  return 0;
269
358
  }
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}`;
359
+ const projectLine = projectData !== null
360
+ ? `project ${projectData.path} — ${projectData.added ? "added" : "already added"}`
361
+ : project.state === "none" ? "project none added: run onboard inside the repository you want to hand off" : `project ${projectProblem}`;
273
362
  io.write([
274
363
  projectLine,
275
364
  `agents ${reports.map(one => `${one.name}: ${[one.words, one.plan].filter(Boolean).join(" · ")}`).join("; ")}`,