toolroll 0.6.0 → 0.8.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/CHANGELOG.md +413 -0
- package/README.md +15 -0
- package/dist/agent-onboard.d.ts +106 -0
- package/dist/agent-onboard.js +282 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +23 -23
- package/dist/browser-shell.js +5 -3
- package/dist/browser-workspace.d.ts +45 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +8 -1
- package/dist/control-setup.d.ts +4 -4
- package/dist/daemon.d.ts +4 -0
- package/dist/daemon.js +28 -4
- package/dist/desktop-host.js +2 -2
- package/dist/desktop-update.d.ts +7 -1
- package/dist/desktop-update.js +39 -22
- package/dist/evidence.d.ts +24 -0
- package/dist/evidence.js +103 -5
- package/dist/first-run.d.ts +54 -0
- package/dist/first-run.js +101 -0
- package/dist/install-method.d.ts +17 -0
- package/dist/install-method.js +65 -0
- package/dist/operate.d.ts +14 -0
- package/dist/operate.js +114 -18
- package/dist/provider-auth.d.ts +33 -8
- package/dist/provider-auth.js +54 -13
- package/dist/releases.d.ts +85 -0
- package/dist/releases.js +238 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +237 -81
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +3 -1
- package/dist/store.d.ts +3 -3
- package/dist/store.js +9 -4
- package/dist/surface.js +2 -0
- package/dist/toolroll-update-cli.d.ts +15 -0
- package/dist/toolroll-update-cli.js +155 -0
- package/dist/toolroll-update-ui.d.ts +38 -0
- package/dist/toolroll-update-ui.js +89 -0
- package/dist/toolroll-update.d.ts +235 -0
- package/dist/toolroll-update.js +929 -0
- package/dist/update-notice.d.ts +5 -0
- package/dist/update-notice.js +4 -0
- package/dist/work-index.js +5 -3
- package/package.json +2 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `toolroll onboard`: the agent that installed Toolroll becomes its lead.
|
|
3
|
+
*
|
|
4
|
+
* Run inside a repository, it adds that repository as a project (the same
|
|
5
|
+
* registry and project row Projects → add writes), reports which agent
|
|
6
|
+
* CLIs are signed in (the checks Settings → AI providers makes), installs
|
|
7
|
+
* a thin operator skill for the person's own agent, prints the MCP line
|
|
8
|
+
* without running it, and ends with a handoff the agent can relay.
|
|
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.
|
|
14
|
+
*/
|
|
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";
|
|
18
|
+
import { connectionWords } from "./control-ui.js";
|
|
19
|
+
import { envelopeJson } from "./envelope.js";
|
|
20
|
+
export const ONBOARD_AGENTS = ["claude", "codex"];
|
|
21
|
+
export const AGENT_NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
22
|
+
/** Each agent's user-level home: where its skills folder lives. */
|
|
23
|
+
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");
|
|
26
|
+
return join(home, ".claude");
|
|
27
|
+
}
|
|
28
|
+
export function operatorSkillPath(agent, home, env) {
|
|
29
|
+
return join(agentHome(agent, home, env), "skills", "toolroll", "SKILL.md");
|
|
30
|
+
}
|
|
31
|
+
/** The agents this person uses: those whose home folder exists, else Claude Code. */
|
|
32
|
+
export function detectAgents(home, env) {
|
|
33
|
+
const found = ONBOARD_AGENTS.filter(agent => existsSync(agentHome(agent, home, env)));
|
|
34
|
+
return found.length > 0 ? found : ["claude"];
|
|
35
|
+
}
|
|
36
|
+
/** The line that adds Toolroll as tools. Printed, never run. */
|
|
37
|
+
export function mcpLine(agent) {
|
|
38
|
+
return `${agent} mcp add toolroll -- toolroll mcp`;
|
|
39
|
+
}
|
|
40
|
+
/** The thin skill: when to reach for Toolroll, and where the real guides are. */
|
|
41
|
+
export function operatorSkillContent(version) {
|
|
42
|
+
return `---
|
|
43
|
+
name: toolroll
|
|
44
|
+
description: Hand coding work to this machine's Toolroll and see it through. Use when the person wants work done unattended ("queue these bugs overnight"), asks what needs them, wants to wait on or review a result, or wants finished work released.
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
${OPERATOR_SKILL_MARK} · written by toolroll ${version}. \`toolroll onboard --yes\` replaces this file; \`toolroll onboard --remove --yes\` deletes it. -->
|
|
48
|
+
|
|
49
|
+
# Toolroll
|
|
50
|
+
|
|
51
|
+
You lead this person's Toolroll. Its agents build; the person approves.
|
|
52
|
+
|
|
53
|
+
- **Hand off** work that can run without anyone watching: a batch of bugs, a refactor, anything overnight.
|
|
54
|
+
- **Wait** on handed-off work with Toolroll instead of checking by hand.
|
|
55
|
+
- **Review**: say what needs the person, and open finished results.
|
|
56
|
+
- **Release** a result the person accepted through Toolroll's own publishing, never by pushing it yourself.
|
|
57
|
+
|
|
58
|
+
Read the guides from the installed binary. They match its version, so do not copy them:
|
|
59
|
+
|
|
60
|
+
- \`toolroll skills get operating\`: the commands, their JSON answers, and what you must never do.
|
|
61
|
+
- \`toolroll skills get console\`: which screen does what, for the person.
|
|
62
|
+
- \`toolroll skills list\`: every other guide.
|
|
63
|
+
|
|
64
|
+
Approving work and answering decisions belong to the person. Never read out or repeat their password.
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
/** A regular file of ours: our frontmatter and one of our marks. */
|
|
68
|
+
function ownSkill(path) {
|
|
69
|
+
const entry = lstatSync(path);
|
|
70
|
+
if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 2_000_000)
|
|
71
|
+
return false;
|
|
72
|
+
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));
|
|
74
|
+
}
|
|
75
|
+
/** Guide copies an older `skills install --claude-code` left beside SKILL.md. */
|
|
76
|
+
function oldGuideCopies(folder) {
|
|
77
|
+
return CLAUDE_CODE_GUIDES.map(name => join(folder, `${name}.md`)).filter(path => {
|
|
78
|
+
try {
|
|
79
|
+
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`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** What install (or --remove) would do, without writing anything. */
|
|
88
|
+
export function planOperatorSkill(agents, options) {
|
|
89
|
+
return agents.map(agent => {
|
|
90
|
+
const path = operatorSkillPath(agent, options.home, options.env);
|
|
91
|
+
const folder = join(path, "..");
|
|
92
|
+
let folderIsReal = true;
|
|
93
|
+
try {
|
|
94
|
+
const entry = lstatSync(folder);
|
|
95
|
+
folderIsReal = entry.isDirectory() && !entry.isSymbolicLink();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { agent, path, action: options.remove ? "absent" : "create", alsoRemove: [] };
|
|
99
|
+
}
|
|
100
|
+
if (!folderIsReal)
|
|
101
|
+
return { agent, path, action: "not-ours", alsoRemove: [] };
|
|
102
|
+
const alsoRemove = oldGuideCopies(folder);
|
|
103
|
+
if (!lstatExists(path)) {
|
|
104
|
+
return { agent, path, action: options.remove ? (alsoRemove.length > 0 ? "remove" : "absent") : "create", alsoRemove };
|
|
105
|
+
}
|
|
106
|
+
if (!ownSkill(path))
|
|
107
|
+
return { agent, path, action: "not-ours", alsoRemove: [] };
|
|
108
|
+
if (options.remove)
|
|
109
|
+
return { agent, path, action: "remove", alsoRemove };
|
|
110
|
+
const current = readFileSync(path, "utf8") === operatorSkillContent(options.version);
|
|
111
|
+
return { agent, path, action: current && alsoRemove.length === 0 ? "current" : "replace", alsoRemove };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function lstatExists(path) {
|
|
115
|
+
return lstatSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
116
|
+
}
|
|
117
|
+
/** Carry out a plan. Steps that are not ours, current, or absent change nothing. */
|
|
118
|
+
export function applyOperatorSkill(steps, version) {
|
|
119
|
+
const wrote = [];
|
|
120
|
+
const removed = [];
|
|
121
|
+
for (const step of steps) {
|
|
122
|
+
const folder = join(step.path, "..");
|
|
123
|
+
if (step.action === "create" || step.action === "replace") {
|
|
124
|
+
mkdirSync(folder, { recursive: true });
|
|
125
|
+
writeFileSync(step.path, operatorSkillContent(version));
|
|
126
|
+
wrote.push(step.path);
|
|
127
|
+
}
|
|
128
|
+
if (step.action === "create" || step.action === "replace" || step.action === "remove") {
|
|
129
|
+
for (const path of step.alsoRemove) {
|
|
130
|
+
unlinkSync(path);
|
|
131
|
+
removed.push(path);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (step.action === "remove") {
|
|
135
|
+
if (lstatExists(step.path)) {
|
|
136
|
+
unlinkSync(step.path);
|
|
137
|
+
removed.push(step.path);
|
|
138
|
+
}
|
|
139
|
+
if (readdirSync(folder).length === 0)
|
|
140
|
+
rmdirSync(folder);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { wrote, removed };
|
|
144
|
+
}
|
|
145
|
+
export const NEXT_THINGS = ["queue these bugs overnight", "what needs me?", "open the result"];
|
|
146
|
+
export function buildHandoff(input) {
|
|
147
|
+
return {
|
|
148
|
+
console: input.url,
|
|
149
|
+
start: "toolroll up",
|
|
150
|
+
login: input.loginFile === null ? null : { account: input.account, file: input.loginFile },
|
|
151
|
+
phone: "Open the console on your phone over your tailnet, or pair Telegram in Settings → Telegram with the /pair code it shows.",
|
|
152
|
+
next: NEXT_THINGS,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function handoffLines(handoff) {
|
|
156
|
+
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}`;
|
|
159
|
+
return [
|
|
160
|
+
"Toolroll is ready.",
|
|
161
|
+
` console ${handoff.console}`,
|
|
162
|
+
` login ${login}`,
|
|
163
|
+
` phone ${handoff.phone}`,
|
|
164
|
+
` say next ${handoff.next.map(one => `"${one}"`).join(" · ")}`,
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
/** The account name from a saved login file, never its password. */
|
|
168
|
+
export function loginAccount(file) {
|
|
169
|
+
try {
|
|
170
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
171
|
+
const cut = raw.indexOf(" ");
|
|
172
|
+
return cut > 0 ? raw.slice(0, cut) : null;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export function agentReport(agent, connection) {
|
|
179
|
+
return { agent, name: AGENT_NAMES[agent], state: connection.state, words: connectionWords(connection), ...(connection.plan === undefined ? {} : { plan: connection.plan }) };
|
|
180
|
+
}
|
|
181
|
+
/** Returns the exit code: 0 done, 2 usage, 3 a refusal to answer. */
|
|
182
|
+
export async function runOnboard(io) {
|
|
183
|
+
const command = "onboard";
|
|
184
|
+
const refuse = (reason, message, code, extra = {}) => {
|
|
185
|
+
io.write(io.json ? envelopeJson({ ok: false, command, reason, message, ...extra }) : message);
|
|
186
|
+
return code;
|
|
187
|
+
};
|
|
188
|
+
let agents;
|
|
189
|
+
if (io.agentFlag !== undefined) {
|
|
190
|
+
const asked = io.agentFlag.split(",").map(one => one.trim()).filter(one => one !== "");
|
|
191
|
+
const unknown = asked.filter(one => !ONBOARD_AGENTS.includes(one));
|
|
192
|
+
if (asked.length === 0 || unknown.length > 0)
|
|
193
|
+
return refuse("usage", "--agent takes claude, codex, or claude,codex", 2);
|
|
194
|
+
agents = [...new Set(asked)];
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
agents = detectAgents(io.home, io.env);
|
|
198
|
+
}
|
|
199
|
+
// The skill: validate every file first, then ask once, then write.
|
|
200
|
+
const steps = planOperatorSkill(agents, { home: io.home, env: io.env, version: io.version, remove: io.remove });
|
|
201
|
+
const changes = steps.filter(step => step.action === "create" || step.action === "replace" || step.action === "remove");
|
|
202
|
+
const foreign = steps.filter(step => step.action === "not-ours");
|
|
203
|
+
let skill;
|
|
204
|
+
if (foreign.length > 0) {
|
|
205
|
+
skill = { state: "not-ours", files: steps, wrote: [], removed: [] };
|
|
206
|
+
}
|
|
207
|
+
else if (changes.length === 0) {
|
|
208
|
+
skill = { state: io.remove ? "absent" : "current", files: steps, wrote: [], removed: [] };
|
|
209
|
+
}
|
|
210
|
+
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
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const skillLine = () => {
|
|
222
|
+
const where = (list) => list.map(step => step.path).join(", ");
|
|
223
|
+
switch (skill.state) {
|
|
224
|
+
case "written": return `skill wrote ${skill.wrote.join(", ")}`;
|
|
225
|
+
case "removed": return `skill removed ${skill.removed.join(", ")}`;
|
|
226
|
+
case "current": return `skill up to date: ${where(steps)}`;
|
|
227
|
+
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)}`;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
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
|
+
if (io.remove) {
|
|
235
|
+
if (skill.state === "not-ours")
|
|
236
|
+
return refuse("not-ours", skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
|
|
237
|
+
if (skill.state === "needs-yes" || skill.state === "declined") {
|
|
238
|
+
return refuse("unconfirmed", skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
|
|
239
|
+
}
|
|
240
|
+
io.write(io.json ? envelopeJson({ ok: true, command, skill: skillData }) : skillLine());
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
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;
|
|
253
|
+
}
|
|
254
|
+
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) });
|
|
256
|
+
const mcp = agents.map(agent => ({ agent, command: mcpLine(agent) }));
|
|
257
|
+
if (io.json) {
|
|
258
|
+
io.write(envelopeJson({
|
|
259
|
+
ok: true,
|
|
260
|
+
command,
|
|
261
|
+
project,
|
|
262
|
+
...(repo === null ? { projectProblem: "not inside a git repository" } : projectProblem === null ? {} : { projectProblem }),
|
|
263
|
+
agents: reports,
|
|
264
|
+
skill: skillData,
|
|
265
|
+
mcp,
|
|
266
|
+
handoff,
|
|
267
|
+
}));
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
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}`;
|
|
273
|
+
io.write([
|
|
274
|
+
projectLine,
|
|
275
|
+
`agents ${reports.map(one => `${one.name}: ${[one.words, one.plan].filter(Boolean).join(" · ")}`).join("; ")}`,
|
|
276
|
+
skillLine(),
|
|
277
|
+
...mcp.map((one, index) => `${index === 0 ? "tools " : " "} ${one.command} (add Toolroll as tools; not run)`),
|
|
278
|
+
"",
|
|
279
|
+
...handoffLines(handoff),
|
|
280
|
+
].join("\n"));
|
|
281
|
+
return 0;
|
|
282
|
+
}
|