toolroll 0.7.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 +435 -0
- package/README.md +15 -0
- package/dist/agent-onboard.d.ts +129 -0
- package/dist/agent-onboard.js +356 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +24 -23
- package/dist/browser-shell.js +5 -3
- package/dist/browser-workspace.d.ts +11 -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 +10 -2
- package/dist/daemon.js +34 -9
- package/dist/desktop-host.js +2 -2
- package/dist/desktop-update.d.ts +8 -2
- 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 +57 -0
- package/dist/first-run.js +104 -0
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +8 -0
- package/dist/operate.d.ts +11 -0
- package/dist/operate.js +126 -7
- package/dist/prompt.d.ts +6 -0
- package/dist/prompt.js +12 -0
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +14 -1
- package/dist/serve.d.ts +16 -0
- package/dist/serve.js +177 -82
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +3 -1
- package/dist/store.d.ts +4 -4
- package/dist/store.js +12 -6
- package/dist/surface.js +2 -0
- package/dist/toolroll-update-cli.d.ts +15 -0
- package/dist/toolroll-update-cli.js +159 -0
- package/dist/toolroll-update-ui.d.ts +38 -0
- package/dist/toolroll-update-ui.js +90 -0
- package/dist/toolroll-update.d.ts +269 -0
- package/dist/toolroll-update.js +1253 -0
- package/package.json +2 -1
|
@@ -0,0 +1,356 @@
|
|
|
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
|
+
* 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.
|
|
16
|
+
*/
|
|
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";
|
|
21
|
+
import { connectionWords } from "./control-ui.js";
|
|
22
|
+
import { envelopeJson } from "./envelope.js";
|
|
23
|
+
export const ONBOARD_AGENTS = ["claude", "codex"];
|
|
24
|
+
export const AGENT_NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
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. */
|
|
27
|
+
export function agentHome(agent, home, env) {
|
|
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
|
+
}
|
|
34
|
+
return join(home, ".claude");
|
|
35
|
+
}
|
|
36
|
+
export function operatorSkillPath(agent, home, env) {
|
|
37
|
+
return join(agentHome(agent, home, env), "skills", "toolroll", "SKILL.md");
|
|
38
|
+
}
|
|
39
|
+
/** The agents this person uses: those whose home folder exists, else Claude Code. */
|
|
40
|
+
export function detectAgents(home, env) {
|
|
41
|
+
const found = ONBOARD_AGENTS.filter(agent => existsSync(agentHome(agent, home, env)));
|
|
42
|
+
return found.length > 0 ? found : ["claude"];
|
|
43
|
+
}
|
|
44
|
+
/** The line that adds Toolroll as tools, for every project. Printed, never run. */
|
|
45
|
+
export function mcpLine(agent) {
|
|
46
|
+
return agent === "claude" ? "claude mcp add --scope user toolroll -- toolroll mcp" : `${agent} mcp add toolroll -- toolroll mcp`;
|
|
47
|
+
}
|
|
48
|
+
/** The thin skill: when to reach for Toolroll, and where the real guides are. */
|
|
49
|
+
export function operatorSkillContent(version) {
|
|
50
|
+
return `---
|
|
51
|
+
name: toolroll
|
|
52
|
+
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.
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
${OPERATOR_SKILL_MARK} · written by toolroll ${version}. \`toolroll onboard --yes\` replaces this file; \`toolroll onboard --remove --yes\` deletes it. -->
|
|
56
|
+
|
|
57
|
+
# Toolroll
|
|
58
|
+
|
|
59
|
+
You lead this person's Toolroll. Its agents build; the person approves.
|
|
60
|
+
|
|
61
|
+
- **Hand off** work that can run without anyone watching: a batch of bugs, a refactor, anything overnight.
|
|
62
|
+
- **Wait** on handed-off work with Toolroll instead of checking by hand.
|
|
63
|
+
- **Review**: say what needs the person, and open finished results.
|
|
64
|
+
- **Release** a result the person accepted through Toolroll's own publishing, never by pushing it yourself.
|
|
65
|
+
|
|
66
|
+
Read the guides from the installed binary. They match its version, so do not copy them:
|
|
67
|
+
|
|
68
|
+
- \`toolroll skills get operating\`: the commands, their JSON answers, and what you must never do.
|
|
69
|
+
- \`toolroll skills get console\`: which screen does what, for the person.
|
|
70
|
+
- \`toolroll skills list\`: every other guide.
|
|
71
|
+
|
|
72
|
+
Approving work and answering decisions belong to the person. Never read out or repeat their password.
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
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) {
|
|
85
|
+
const entry = lstatSync(path);
|
|
86
|
+
if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 2_000_000)
|
|
87
|
+
return "foreign";
|
|
88
|
+
const content = readFileSync(path, "utf8");
|
|
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";
|
|
93
|
+
}
|
|
94
|
+
/** Guide copies an older `skills install --claude-code` left beside SKILL.md. */
|
|
95
|
+
function oldGuideCopies(folder) {
|
|
96
|
+
return CLAUDE_CODE_GUIDES.map(name => join(folder, `${name}.md`)).filter(path => {
|
|
97
|
+
try {
|
|
98
|
+
const entry = lstatSync(path);
|
|
99
|
+
return !entry.isSymbolicLink() && entry.isFile() && entry.size <= 2_000_000 && readFileSync(path, "utf8").startsWith(`${CLAUDE_CODE_MANAGED_MARK}\n\n`);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** What install (or --remove) would do, without writing anything. */
|
|
107
|
+
export function planOperatorSkill(agents, options) {
|
|
108
|
+
return agents.map(agent => {
|
|
109
|
+
const path = operatorSkillPath(agent, options.home, options.env);
|
|
110
|
+
const folder = join(path, "..");
|
|
111
|
+
let folderIsReal = true;
|
|
112
|
+
try {
|
|
113
|
+
const entry = lstatSync(folder);
|
|
114
|
+
folderIsReal = entry.isDirectory() && !entry.isSymbolicLink();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { agent, path, action: options.remove ? "absent" : "create", alsoRemove: [] };
|
|
118
|
+
}
|
|
119
|
+
if (!folderIsReal)
|
|
120
|
+
return { agent, path, action: "not-ours", alsoRemove: [] };
|
|
121
|
+
const alsoRemove = oldGuideCopies(folder);
|
|
122
|
+
if (!lstatExists(path)) {
|
|
123
|
+
return { agent, path, action: options.remove ? (alsoRemove.length > 0 ? "remove" : "absent") : "create", alsoRemove };
|
|
124
|
+
}
|
|
125
|
+
const ownership = skillOwnership(path);
|
|
126
|
+
if (ownership !== "ours")
|
|
127
|
+
return { agent, path, action: ownership === "edited" ? "edited" : "not-ours", alsoRemove: [] };
|
|
128
|
+
if (options.remove)
|
|
129
|
+
return { agent, path, action: "remove", alsoRemove };
|
|
130
|
+
const current = readFileSync(path, "utf8") === operatorSkillContent(options.version);
|
|
131
|
+
return { agent, path, action: current && alsoRemove.length === 0 ? "current" : "replace", alsoRemove };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function lstatExists(path) {
|
|
135
|
+
return lstatSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
136
|
+
}
|
|
137
|
+
/** Carry out a plan. Steps that are not ours, current, or absent change nothing. */
|
|
138
|
+
export function applyOperatorSkill(steps, version) {
|
|
139
|
+
const wrote = [];
|
|
140
|
+
const removed = [];
|
|
141
|
+
for (const step of steps) {
|
|
142
|
+
const folder = join(step.path, "..");
|
|
143
|
+
if (step.action === "create" || step.action === "replace") {
|
|
144
|
+
mkdirSync(folder, { recursive: true });
|
|
145
|
+
writeFileSync(step.path, operatorSkillContent(version));
|
|
146
|
+
wrote.push(step.path);
|
|
147
|
+
}
|
|
148
|
+
if (step.action === "create" || step.action === "replace" || step.action === "remove") {
|
|
149
|
+
for (const path of step.alsoRemove) {
|
|
150
|
+
unlinkSync(path);
|
|
151
|
+
removed.push(path);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (step.action === "remove") {
|
|
155
|
+
if (lstatExists(step.path)) {
|
|
156
|
+
unlinkSync(step.path);
|
|
157
|
+
removed.push(step.path);
|
|
158
|
+
}
|
|
159
|
+
if (readdirSync(folder).length === 0)
|
|
160
|
+
rmdirSync(folder);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { wrote, removed };
|
|
164
|
+
}
|
|
165
|
+
export const NEXT_THINGS = ["queue these bugs overnight", "what needs me?", "open the result"];
|
|
166
|
+
export function buildHandoff(input) {
|
|
167
|
+
return {
|
|
168
|
+
console: input.url,
|
|
169
|
+
start: "toolroll up",
|
|
170
|
+
login: input.login,
|
|
171
|
+
phone: "Open the console on your phone over your tailnet, or pair Telegram in Settings → Telegram with the /pair code it shows.",
|
|
172
|
+
next: NEXT_THINGS,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export function handoffLines(handoff) {
|
|
176
|
+
const login = handoff.login === null
|
|
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}`;
|
|
181
|
+
return [
|
|
182
|
+
"Toolroll is ready.",
|
|
183
|
+
` console ${handoff.console}`,
|
|
184
|
+
` login ${login}`,
|
|
185
|
+
` phone ${handoff.phone}`,
|
|
186
|
+
` say next ${handoff.next.map(one => `"${one}"`).join(" · ")}`,
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
/** The account name from a saved login file, never its password. */
|
|
190
|
+
export function loginAccount(file) {
|
|
191
|
+
try {
|
|
192
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
193
|
+
const cut = raw.indexOf(" ");
|
|
194
|
+
return cut > 0 ? raw.slice(0, cut) : null;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export function agentReport(agent, connection) {
|
|
201
|
+
return { agent, name: AGENT_NAMES[agent], state: connection.state, words: connectionWords(connection), ...(connection.plan === undefined ? {} : { plan: connection.plan }) };
|
|
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
|
+
}
|
|
224
|
+
/** Returns the exit code: 0 done, 2 usage, 3 a refusal to answer. */
|
|
225
|
+
export async function runOnboard(io) {
|
|
226
|
+
const command = "onboard";
|
|
227
|
+
const refuse = (reason, message, code, extra = {}) => {
|
|
228
|
+
io.write(io.json ? envelopeJson({ ok: false, command, reason, message, ...extra }) : message);
|
|
229
|
+
return code;
|
|
230
|
+
};
|
|
231
|
+
let agents;
|
|
232
|
+
if (io.agentFlag !== undefined) {
|
|
233
|
+
const asked = io.agentFlag.split(",").map(one => one.trim()).filter(one => one !== "");
|
|
234
|
+
const unknown = asked.filter(one => !ONBOARD_AGENTS.includes(one));
|
|
235
|
+
if (asked.length === 0 || unknown.length > 0)
|
|
236
|
+
return refuse("usage", "--agent takes claude, codex, or claude,codex", 2);
|
|
237
|
+
agents = [...new Set(asked)];
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
agents = detectAgents(io.home, io.env);
|
|
241
|
+
}
|
|
242
|
+
// The skill: validate every file first.
|
|
243
|
+
const steps = planOperatorSkill(agents, { home: io.home, env: io.env, version: io.version, remove: io.remove });
|
|
244
|
+
const changes = steps.filter(step => step.action === "create" || step.action === "replace" || step.action === "remove");
|
|
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";
|
|
271
|
+
let skill;
|
|
272
|
+
if (foreign.length > 0) {
|
|
273
|
+
skill = { state: foreign.some(step => step.action === "not-ours") ? "not-ours" : "edited", files: steps, wrote: [], removed: [] };
|
|
274
|
+
}
|
|
275
|
+
else if (!skillChanges) {
|
|
276
|
+
skill = { state: io.remove ? "absent" : "current", files: steps, wrote: [], removed: [] };
|
|
277
|
+
}
|
|
278
|
+
else if (!consent) {
|
|
279
|
+
skill = { state: unconfirmed, files: steps, wrote: [], removed: [] };
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const done = applyOperatorSkill(steps, io.version);
|
|
283
|
+
skill = { state: io.remove ? "removed" : "written", files: steps, ...done };
|
|
284
|
+
}
|
|
285
|
+
const skillLine = () => {
|
|
286
|
+
const where = (list) => list.map(step => step.path).join(", ");
|
|
287
|
+
const nothing = io.remove ? "removed" : "written";
|
|
288
|
+
switch (skill.state) {
|
|
289
|
+
case "written": return `skill wrote ${skill.wrote.join(", ")}`;
|
|
290
|
+
case "removed": return `skill removed ${skill.removed.join(", ")}`;
|
|
291
|
+
case "current": return `skill up to date: ${where(steps)}`;
|
|
292
|
+
case "absent": return "skill not installed; nothing to remove";
|
|
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)}`;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
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 };
|
|
300
|
+
if (io.remove) {
|
|
301
|
+
if (skill.state === "not-ours" || skill.state === "edited")
|
|
302
|
+
return refuse(skill.state, skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
|
|
303
|
+
if (skill.state === "needs-yes" || skill.state === "declined") {
|
|
304
|
+
return refuse("unconfirmed", skillLine().replace(/^skill\s+/, ""), 3, { skill: skillData });
|
|
305
|
+
}
|
|
306
|
+
io.write(io.json ? envelopeJson({ ok: true, command, skill: skillData }) : skillLine());
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
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
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const reports = await Promise.all(ONBOARD_AGENTS.map(async (agent) => agentReport(agent, await io.checkConnection(agent))));
|
|
318
|
+
const handoff = buildHandoff({ url: io.url, login: io.login });
|
|
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
|
+
})();
|
|
331
|
+
if (io.json) {
|
|
332
|
+
io.write(envelopeJson({
|
|
333
|
+
ok: true,
|
|
334
|
+
command,
|
|
335
|
+
project: projectData,
|
|
336
|
+
...(projectProblem === null ? {} : { projectProblem }),
|
|
337
|
+
agents: reports,
|
|
338
|
+
skill: skillData,
|
|
339
|
+
mcp,
|
|
340
|
+
handoff,
|
|
341
|
+
}));
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
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}`;
|
|
347
|
+
io.write([
|
|
348
|
+
projectLine,
|
|
349
|
+
`agents ${reports.map(one => `${one.name}: ${[one.words, one.plan].filter(Boolean).join(" · ")}`).join("; ")}`,
|
|
350
|
+
skillLine(),
|
|
351
|
+
...mcp.map((one, index) => `${index === 0 ? "tools " : " "} ${one.command} (add Toolroll as tools; not run)`),
|
|
352
|
+
"",
|
|
353
|
+
...handoffLines(handoff),
|
|
354
|
+
].join("\n"));
|
|
355
|
+
return 0;
|
|
356
|
+
}
|