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 +40 -0
- package/README.md +5 -5
- package/dist/agent-onboard.d.ts +45 -18
- package/dist/agent-onboard.js +146 -57
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +14 -13
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/daemon.d.ts +6 -2
- package/dist/daemon.js +6 -5
- package/dist/first-run.d.ts +8 -2
- package/dist/first-run.js +12 -8
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +4 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +2 -4
- package/dist/operate.js +167 -30
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +41 -0
- package/dist/serve.js +103 -28
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +6 -6
- package/dist/store.js +23 -20
- package/dist/toolroll-update-cli.js +7 -3
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +7 -6
- package/dist/toolroll-update.d.ts +69 -15
- package/dist/toolroll-update.js +572 -110
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
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
|
|
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,
|
|
61
|
-
signed in, and prints the line that adds Toolroll as
|
|
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)
|
package/dist/agent-onboard.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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>;
|
package/dist/agent-onboard.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
102
|
+
return "foreign";
|
|
72
103
|
const content = readFileSync(path, "utf8");
|
|
73
|
-
|
|
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")
|
|
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
|
-
|
|
107
|
-
|
|
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.
|
|
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
|
|
158
|
-
:
|
|
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
|
|
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 (
|
|
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
|
|
212
|
-
|
|
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 ${
|
|
229
|
-
case "
|
|
230
|
-
case "
|
|
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(
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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,
|
|
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
|
-
...(
|
|
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 =
|
|
271
|
-
? `project ${
|
|
272
|
-
:
|
|
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("; ")}`,
|