@dalmasonto/taskflow-mcp 2.5.0 → 2.6.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/README.md +253 -73
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +38 -2
- package/dist/client.js +10 -1
- package/dist/client.js.map +1 -1
- package/dist/doctor.js +1 -1
- package/dist/doctor.js.map +1 -1
- package/dist/events.d.ts +12 -2
- package/dist/events.js +23 -4
- package/dist/events.js.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/init-config.d.ts +89 -0
- package/dist/init-config.js +232 -0
- package/dist/init-config.js.map +1 -0
- package/dist/init.d.ts +150 -0
- package/dist/init.js +784 -0
- package/dist/init.js.map +1 -0
- package/dist/resolve.d.ts +2 -0
- package/dist/resolve.js.map +1 -1
- package/dist/runtime.js +56 -38
- package/dist/runtime.js.map +1 -1
- package/dist/server.js +183 -8
- package/dist/server.js.map +1 -1
- package/dist/tmux.d.ts +52 -9
- package/dist/tmux.js +96 -28
- package/dist/tmux.js.map +1 -1
- package/package.json +7 -3
package/dist/init.js
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `taskflow init` (also `taskflow-mcp init`) — the post-install walkthrough.
|
|
3
|
+
*
|
|
4
|
+
* Asks which coding agent(s) ("harnesses") to wire up and at what scope, then:
|
|
5
|
+
* 1. registers the `taskflow-mcp` server with each harness — through the
|
|
6
|
+
* harness's own CLI when it is on PATH, else by merging its config file;
|
|
7
|
+
* 2. for Claude Code, offers the lifecycle hooks;
|
|
8
|
+
* 3. finds `.taskflow.json`, or creates one by minting an agent when given a
|
|
9
|
+
* user token (reusing `--mint`'s request), or explains the manual step;
|
|
10
|
+
* 4. runs the `--doctor` check;
|
|
11
|
+
* 5. prints the next steps, ending at the web app.
|
|
12
|
+
*
|
|
13
|
+
* Everything that touches the outside world — files, PATH, child processes,
|
|
14
|
+
* the network, the terminal — comes in through {@link InitDeps}, so the plan
|
|
15
|
+
* and the merges are tested against fake paths and never the real home dir.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { accessSync, constants as fsConstants, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
21
|
+
import { createInterface } from "node:readline/promises";
|
|
22
|
+
import { findConfigPath, CONFIG_FILENAME } from "./config.js";
|
|
23
|
+
import { requestMint } from "./mint.js";
|
|
24
|
+
import { runDoctor } from "./doctor.js";
|
|
25
|
+
import { HOOK_COMMAND, MCP_COMMAND, MergeError, SERVER_NAME, jsonHasServer, mergeClaudeHooks, mergeCodexToml, mergeGitignore, mergeMcpServersJson, mergeOpencodeJson, simpleDiff, tomlHasServer, } from "./init-config.js";
|
|
26
|
+
/** The hosted app. A self-hosted install passes `--app-url`. */
|
|
27
|
+
export const DEFAULT_APP_URL = "https://taskflow.supercodehive.com";
|
|
28
|
+
/** The hosted API — what `.taskflow.json`'s `server` points at. */
|
|
29
|
+
export const DEFAULT_SERVER_URL = "https://api.taskflow.supercodehive.com";
|
|
30
|
+
export const HARNESSES = ["claude", "codex", "gemini", "cursor", "opencode"];
|
|
31
|
+
export const HARNESS_LABEL = {
|
|
32
|
+
claude: "Claude Code",
|
|
33
|
+
codex: "Codex CLI",
|
|
34
|
+
gemini: "Gemini CLI",
|
|
35
|
+
cursor: "Cursor",
|
|
36
|
+
opencode: "opencode",
|
|
37
|
+
};
|
|
38
|
+
/** Accepted spellings on the command line. */
|
|
39
|
+
const HARNESS_ALIASES = {
|
|
40
|
+
claude: "claude",
|
|
41
|
+
"claude-code": "claude",
|
|
42
|
+
claudecode: "claude",
|
|
43
|
+
codex: "codex",
|
|
44
|
+
gemini: "gemini",
|
|
45
|
+
"gemini-cli": "gemini",
|
|
46
|
+
cursor: "cursor",
|
|
47
|
+
opencode: "opencode",
|
|
48
|
+
"open-code": "opencode",
|
|
49
|
+
};
|
|
50
|
+
/** A clear, user-facing failure. */
|
|
51
|
+
export class InitError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "InitError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export const INIT_USAGE = `taskflow init — connect your coding agent to TaskFlow
|
|
58
|
+
|
|
59
|
+
taskflow init Interactive walkthrough.
|
|
60
|
+
taskflow init --harness claude --scope user --yes
|
|
61
|
+
Non-interactive (scripts / CI).
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--harness <names> claude, codex, gemini, cursor, opencode (comma-separated,
|
|
65
|
+
repeatable, or "all").
|
|
66
|
+
--scope <s> user (global, every project) or project (this repo only).
|
|
67
|
+
--dir <path> Project root for --scope project and .taskflow.json
|
|
68
|
+
(default: the current directory).
|
|
69
|
+
--hooks / --no-hooks
|
|
70
|
+
Install the Claude Code lifecycle hooks (default: yes).
|
|
71
|
+
--server <url> TaskFlow API for a new .taskflow.json
|
|
72
|
+
(default: ${DEFAULT_SERVER_URL}).
|
|
73
|
+
--project <id> Project id to link an agent in.
|
|
74
|
+
--token <t> YOUR user token (or TASKFLOW_USER_TOKEN) — with --project,
|
|
75
|
+
mints an agent and writes .taskflow.json.
|
|
76
|
+
--profile <name> Profile name for the minted agent (default: main).
|
|
77
|
+
--display-name <s> Display name for the minted agent.
|
|
78
|
+
--app-url <url> Your TaskFlow web app, when self-hosting
|
|
79
|
+
(default: ${DEFAULT_APP_URL}).
|
|
80
|
+
--no-doctor Skip the connection check at the end.
|
|
81
|
+
--dry-run Print what would run / be written; change nothing.
|
|
82
|
+
-y, --yes Accept defaults; never prompt.
|
|
83
|
+
-h, --help This message.`;
|
|
84
|
+
function takeValue(argv, i, flag) {
|
|
85
|
+
const next = argv[i + 1];
|
|
86
|
+
if (next === undefined || next.startsWith("-")) {
|
|
87
|
+
throw new InitError(`${flag} needs a value.`);
|
|
88
|
+
}
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
export function parseHarnesses(value) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const raw of value.split(",")) {
|
|
94
|
+
const name = raw.trim().toLowerCase();
|
|
95
|
+
if (!name)
|
|
96
|
+
continue;
|
|
97
|
+
if (name === "all") {
|
|
98
|
+
out.push(...HARNESSES);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const h = HARNESS_ALIASES[name];
|
|
102
|
+
if (!h) {
|
|
103
|
+
throw new InitError(`Unknown harness "${raw.trim()}". Choose from: ${HARNESSES.join(", ")}.`);
|
|
104
|
+
}
|
|
105
|
+
out.push(h);
|
|
106
|
+
}
|
|
107
|
+
return [...new Set(out)];
|
|
108
|
+
}
|
|
109
|
+
export function parseScope(value) {
|
|
110
|
+
const v = value.trim().toLowerCase();
|
|
111
|
+
if (v === "user" || v === "global")
|
|
112
|
+
return "user";
|
|
113
|
+
if (v === "project" || v === "local" || v === "repo")
|
|
114
|
+
return "project";
|
|
115
|
+
throw new InitError(`Unknown scope "${value}". Use "user" (global) or "project".`);
|
|
116
|
+
}
|
|
117
|
+
/** Parse `init`'s arguments (without the `init` word itself). */
|
|
118
|
+
export function parseInitArgs(argv, env = {}) {
|
|
119
|
+
const args = {
|
|
120
|
+
harnesses: [],
|
|
121
|
+
scope: undefined,
|
|
122
|
+
yes: false,
|
|
123
|
+
dryRun: false,
|
|
124
|
+
hooks: undefined,
|
|
125
|
+
dir: undefined,
|
|
126
|
+
server: undefined,
|
|
127
|
+
project: undefined,
|
|
128
|
+
token: env.TASKFLOW_USER_TOKEN || undefined,
|
|
129
|
+
profile: "main",
|
|
130
|
+
displayName: undefined,
|
|
131
|
+
appUrl: undefined,
|
|
132
|
+
doctor: true,
|
|
133
|
+
help: false,
|
|
134
|
+
};
|
|
135
|
+
for (let i = 0; i < argv.length; i++) {
|
|
136
|
+
let arg = argv[i];
|
|
137
|
+
let inline;
|
|
138
|
+
const eq = arg.indexOf("=");
|
|
139
|
+
if (arg.startsWith("--") && eq !== -1) {
|
|
140
|
+
inline = arg.slice(eq + 1);
|
|
141
|
+
arg = arg.slice(0, eq);
|
|
142
|
+
}
|
|
143
|
+
const value = () => {
|
|
144
|
+
if (inline !== undefined)
|
|
145
|
+
return inline;
|
|
146
|
+
const v = takeValue(argv, i, arg);
|
|
147
|
+
i++;
|
|
148
|
+
return v;
|
|
149
|
+
};
|
|
150
|
+
switch (arg) {
|
|
151
|
+
case "--harness":
|
|
152
|
+
case "--harnesses":
|
|
153
|
+
case "--agent":
|
|
154
|
+
args.harnesses = [...new Set([...args.harnesses, ...parseHarnesses(value())])];
|
|
155
|
+
break;
|
|
156
|
+
case "--scope":
|
|
157
|
+
args.scope = parseScope(value());
|
|
158
|
+
break;
|
|
159
|
+
case "--global":
|
|
160
|
+
case "--user":
|
|
161
|
+
args.scope = "user";
|
|
162
|
+
break;
|
|
163
|
+
case "--project-scope":
|
|
164
|
+
case "--local":
|
|
165
|
+
args.scope = "project";
|
|
166
|
+
break;
|
|
167
|
+
case "-y":
|
|
168
|
+
case "--yes":
|
|
169
|
+
args.yes = true;
|
|
170
|
+
break;
|
|
171
|
+
case "--dry-run":
|
|
172
|
+
args.dryRun = true;
|
|
173
|
+
break;
|
|
174
|
+
case "--hooks":
|
|
175
|
+
args.hooks = true;
|
|
176
|
+
break;
|
|
177
|
+
case "--no-hooks":
|
|
178
|
+
args.hooks = false;
|
|
179
|
+
break;
|
|
180
|
+
case "--dir":
|
|
181
|
+
args.dir = value();
|
|
182
|
+
break;
|
|
183
|
+
case "--server":
|
|
184
|
+
args.server = value();
|
|
185
|
+
break;
|
|
186
|
+
case "--project": {
|
|
187
|
+
const v = value();
|
|
188
|
+
const n = Number(v);
|
|
189
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
190
|
+
throw new InitError(`--project must be a project id, got "${v}".`);
|
|
191
|
+
args.project = n;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "--token":
|
|
195
|
+
args.token = value();
|
|
196
|
+
break;
|
|
197
|
+
case "--profile":
|
|
198
|
+
args.profile = value();
|
|
199
|
+
break;
|
|
200
|
+
case "--display-name":
|
|
201
|
+
args.displayName = value();
|
|
202
|
+
break;
|
|
203
|
+
case "--app-url":
|
|
204
|
+
args.appUrl = value();
|
|
205
|
+
break;
|
|
206
|
+
case "--no-doctor":
|
|
207
|
+
args.doctor = false;
|
|
208
|
+
break;
|
|
209
|
+
case "-h":
|
|
210
|
+
case "--help":
|
|
211
|
+
args.help = true;
|
|
212
|
+
break;
|
|
213
|
+
default:
|
|
214
|
+
throw new InitError(`Unknown option "${argv[i]}". Run "taskflow init --help".`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return args;
|
|
218
|
+
}
|
|
219
|
+
function codexHome(p) {
|
|
220
|
+
return p.env.CODEX_HOME || join(p.home, ".codex");
|
|
221
|
+
}
|
|
222
|
+
/** Where each harness keeps its MCP config, per scope. `null` = no such scope. */
|
|
223
|
+
export function harnessConfigPath(h, scope, p) {
|
|
224
|
+
switch (h) {
|
|
225
|
+
case "claude":
|
|
226
|
+
// User scope lives inside ~/.claude.json — Claude Code's live state file,
|
|
227
|
+
// which we only ever READ; writes go through the `claude` CLI.
|
|
228
|
+
return scope === "user" ? join(p.home, ".claude.json") : join(p.dir, ".mcp.json");
|
|
229
|
+
case "codex":
|
|
230
|
+
// Codex reads MCP servers from its home config only.
|
|
231
|
+
return join(codexHome(p), "config.toml");
|
|
232
|
+
case "gemini":
|
|
233
|
+
return scope === "user" ? join(p.home, ".gemini", "settings.json") : join(p.dir, ".gemini", "settings.json");
|
|
234
|
+
case "cursor":
|
|
235
|
+
return scope === "user" ? join(p.home, ".cursor", "mcp.json") : join(p.dir, ".cursor", "mcp.json");
|
|
236
|
+
case "opencode":
|
|
237
|
+
return scope === "user"
|
|
238
|
+
? join(p.env.XDG_CONFIG_HOME || join(p.home, ".config"), "opencode", "opencode.json")
|
|
239
|
+
: join(p.dir, "opencode.json");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Manual instructions for one harness — the fallback when we won't write. */
|
|
243
|
+
export function manualInstructions(h, scope, p) {
|
|
244
|
+
const path = harnessConfigPath(h, scope, p);
|
|
245
|
+
switch (h) {
|
|
246
|
+
case "claude":
|
|
247
|
+
return [
|
|
248
|
+
`Run: claude mcp add --scope ${scope} ${SERVER_NAME} -- ${MCP_COMMAND}`,
|
|
249
|
+
scope === "project"
|
|
250
|
+
? `or add to ${path}: {"mcpServers":{"${SERVER_NAME}":{"command":"${MCP_COMMAND}","args":[]}}}`
|
|
251
|
+
: "(install Claude Code first if the claude command is missing)",
|
|
252
|
+
];
|
|
253
|
+
case "codex":
|
|
254
|
+
return [
|
|
255
|
+
`Run: codex mcp add ${SERVER_NAME} -- ${MCP_COMMAND}`,
|
|
256
|
+
`or append to ${path}:`,
|
|
257
|
+
` [mcp_servers.${SERVER_NAME}]`,
|
|
258
|
+
` command = "${MCP_COMMAND}"`,
|
|
259
|
+
" args = []",
|
|
260
|
+
];
|
|
261
|
+
case "gemini":
|
|
262
|
+
case "cursor":
|
|
263
|
+
return [`Add to ${path}:`, ` {"mcpServers":{"${SERVER_NAME}":{"command":"${MCP_COMMAND}","args":[]}}}`];
|
|
264
|
+
case "opencode":
|
|
265
|
+
return [
|
|
266
|
+
`Add to ${path}:`,
|
|
267
|
+
` {"mcp":{"${SERVER_NAME}":{"type":"local","command":["${MCP_COMMAND}"],"enabled":true}}}`,
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function writeOrManual(h, path, before, merge, what, manual) {
|
|
272
|
+
try {
|
|
273
|
+
const r = merge(before);
|
|
274
|
+
if (!r.changed)
|
|
275
|
+
return { kind: "skip", harness: h, summary: `${what} already in ${path}` };
|
|
276
|
+
return { kind: "write", harness: h, summary: `${what} → ${path}`, path, before, after: r.text };
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
if (err instanceof MergeError) {
|
|
280
|
+
return { kind: "manual", harness: h, summary: `cannot safely edit ${path}: ${err.message}`, lines: manual };
|
|
281
|
+
}
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Plan the MCP registration for one harness. */
|
|
286
|
+
export function planRegistration(h, requested, p) {
|
|
287
|
+
const out = [];
|
|
288
|
+
let scope = requested;
|
|
289
|
+
if (h === "codex" && scope === "project") {
|
|
290
|
+
out.push({
|
|
291
|
+
kind: "skip",
|
|
292
|
+
harness: h,
|
|
293
|
+
summary: "Codex keeps MCP servers in its user config (~/.codex/config.toml); registering there. " +
|
|
294
|
+
"The server still reads .taskflow.json from the directory Codex runs in.",
|
|
295
|
+
});
|
|
296
|
+
scope = "user";
|
|
297
|
+
}
|
|
298
|
+
const path = harnessConfigPath(h, scope, p);
|
|
299
|
+
const manual = manualInstructions(h, scope, p);
|
|
300
|
+
const current = p.readFile(path);
|
|
301
|
+
const label = `${HARNESS_LABEL[h]} MCP server "${SERVER_NAME}"`;
|
|
302
|
+
switch (h) {
|
|
303
|
+
case "claude": {
|
|
304
|
+
if (jsonHasServer(current)) {
|
|
305
|
+
out.push({ kind: "skip", harness: h, summary: `${label} already registered (${path})` });
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
const fallback = scope === "project"
|
|
309
|
+
? writeOrManual(h, path, current, (t) => mergeMcpServersJson(t, path), label, manual)
|
|
310
|
+
: { kind: "manual", harness: h, summary: `register ${label} by hand`, lines: manual };
|
|
311
|
+
if (p.which("claude")) {
|
|
312
|
+
out.push({
|
|
313
|
+
kind: "exec",
|
|
314
|
+
harness: h,
|
|
315
|
+
summary: `${label} (${scope} scope) via the claude CLI`,
|
|
316
|
+
cmd: "claude",
|
|
317
|
+
args: ["mcp", "add", "--scope", scope, SERVER_NAME, "--", MCP_COMMAND],
|
|
318
|
+
fallback,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
out.push(fallback);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
case "codex": {
|
|
327
|
+
if (tomlHasServer(current)) {
|
|
328
|
+
out.push({ kind: "skip", harness: h, summary: `${label} already registered (${path})` });
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
const fallback = writeOrManual(h, path, current, mergeCodexToml, label, manual);
|
|
332
|
+
if (p.which("codex")) {
|
|
333
|
+
out.push({
|
|
334
|
+
kind: "exec",
|
|
335
|
+
harness: h,
|
|
336
|
+
summary: `${label} via the codex CLI`,
|
|
337
|
+
cmd: "codex",
|
|
338
|
+
args: ["mcp", "add", SERVER_NAME, "--", MCP_COMMAND],
|
|
339
|
+
fallback,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
out.push(fallback);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
case "gemini":
|
|
348
|
+
case "cursor":
|
|
349
|
+
out.push(writeOrManual(h, path, current, (t) => mergeMcpServersJson(t, path), label, manual));
|
|
350
|
+
return out;
|
|
351
|
+
case "opencode": {
|
|
352
|
+
// opencode also accepts opencode.jsonc; a JSONC file is left to the user.
|
|
353
|
+
const jsonc = path.replace(/\.json$/, ".jsonc");
|
|
354
|
+
if (p.exists(jsonc)) {
|
|
355
|
+
out.push({
|
|
356
|
+
kind: "manual",
|
|
357
|
+
harness: h,
|
|
358
|
+
summary: `${jsonc} exists (JSON with comments) — not editing it`,
|
|
359
|
+
lines: manualInstructions(h, scope, p).map((l) => l.replace(path, jsonc)),
|
|
360
|
+
});
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
out.push(writeOrManual(h, path, current, (t) => mergeOpencodeJson(t, path), label, manual));
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Plan the Claude Code hooks. */
|
|
369
|
+
export function planHooks(scope, p) {
|
|
370
|
+
const path = scope === "user" ? join(p.home, ".claude", "settings.json") : join(p.dir, ".claude", "settings.json");
|
|
371
|
+
return writeOrManual("claude", path, p.readFile(path), (t) => mergeClaudeHooks(t, path), `Claude Code hooks (${HOOK_COMMAND})`, [`Add the "hooks" block from the taskflow-mcp README to ${path}.`]);
|
|
372
|
+
}
|
|
373
|
+
/** The whole registration plan. Pure over {@link PlanEnv}. */
|
|
374
|
+
export function buildPlan(input, p) {
|
|
375
|
+
const actions = [];
|
|
376
|
+
for (const h of input.harnesses) {
|
|
377
|
+
actions.push(...planRegistration(h, input.scope, p));
|
|
378
|
+
if (h === "claude" && input.hooks)
|
|
379
|
+
actions.push(planHooks(input.scope, p));
|
|
380
|
+
}
|
|
381
|
+
return actions;
|
|
382
|
+
}
|
|
383
|
+
/** Render a plan for `--dry-run` (and for the confirm prompt). */
|
|
384
|
+
export function describePlan(actions, withDiffs) {
|
|
385
|
+
const lines = [];
|
|
386
|
+
for (const a of actions) {
|
|
387
|
+
switch (a.kind) {
|
|
388
|
+
case "exec":
|
|
389
|
+
lines.push(` run ${a.cmd} ${a.args.join(" ")}`);
|
|
390
|
+
lines.push(` (${a.summary})`);
|
|
391
|
+
break;
|
|
392
|
+
case "write":
|
|
393
|
+
lines.push(` write ${a.summary}${a.before === null ? " (new file)" : ` (backup: ${a.path}.bak)`}`);
|
|
394
|
+
if (withDiffs)
|
|
395
|
+
for (const d of simpleDiff(a.before, a.after))
|
|
396
|
+
lines.push(` ${d}`);
|
|
397
|
+
break;
|
|
398
|
+
case "skip":
|
|
399
|
+
lines.push(` ok ${a.summary}`);
|
|
400
|
+
break;
|
|
401
|
+
case "manual":
|
|
402
|
+
lines.push(` manual ${a.summary}`);
|
|
403
|
+
for (const l of a.lines)
|
|
404
|
+
lines.push(` ${l}`);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return lines;
|
|
409
|
+
}
|
|
410
|
+
/** Back `path` up to `<path>.bak` (or `.bak.N` when that is taken). Returns the backup path. */
|
|
411
|
+
export function backupPath(path, exists) {
|
|
412
|
+
let candidate = `${path}.bak`;
|
|
413
|
+
for (let n = 1; exists(candidate); n++)
|
|
414
|
+
candidate = `${path}.bak.${n}`;
|
|
415
|
+
return candidate;
|
|
416
|
+
}
|
|
417
|
+
function applyWrite(a, d, mode) {
|
|
418
|
+
if (a.before !== null && d.exists(a.path)) {
|
|
419
|
+
const bak = backupPath(a.path, d.exists);
|
|
420
|
+
d.copyFile(a.path, bak);
|
|
421
|
+
d.log(` backup ${bak}`);
|
|
422
|
+
}
|
|
423
|
+
d.mkdirp(dirname(a.path));
|
|
424
|
+
d.writeFile(a.path, a.after, mode);
|
|
425
|
+
d.log(` wrote ${a.summary}`);
|
|
426
|
+
}
|
|
427
|
+
/** Apply a plan. Returns the manual steps still owed by the user. */
|
|
428
|
+
export function applyPlan(actions, d) {
|
|
429
|
+
const owed = [];
|
|
430
|
+
const run = (a) => {
|
|
431
|
+
switch (a.kind) {
|
|
432
|
+
case "skip":
|
|
433
|
+
d.log(` ok ${a.summary}`);
|
|
434
|
+
return;
|
|
435
|
+
case "manual":
|
|
436
|
+
d.log(` manual ${a.summary}`);
|
|
437
|
+
owed.push(...a.lines);
|
|
438
|
+
return;
|
|
439
|
+
case "write":
|
|
440
|
+
try {
|
|
441
|
+
applyWrite(a, d);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
d.log(` FAILED writing ${a.path}: ${err.message}`);
|
|
445
|
+
owed.push(`Edit ${a.path} by hand (see "taskflow init --dry-run" for the change).`);
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
case "exec": {
|
|
449
|
+
const r = d.exec(a.cmd, a.args);
|
|
450
|
+
const output = `${r.stdout}\n${r.stderr}`;
|
|
451
|
+
if (r.code === 0) {
|
|
452
|
+
d.log(` ran ${a.cmd} ${a.args.join(" ")}`);
|
|
453
|
+
}
|
|
454
|
+
else if (/already exists|already configured|already added/i.test(output)) {
|
|
455
|
+
d.log(` ok ${a.summary}: already registered`);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
d.log(` FAILED ${a.cmd} ${a.args.join(" ")} (exit ${r.code})`);
|
|
459
|
+
const detail = output.trim().split("\n").filter(Boolean).slice(-2);
|
|
460
|
+
for (const l of detail)
|
|
461
|
+
d.log(` ${l}`);
|
|
462
|
+
if (a.fallback) {
|
|
463
|
+
d.log(" falling back:");
|
|
464
|
+
run(a.fallback);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
for (const a of actions)
|
|
472
|
+
run(a);
|
|
473
|
+
return owed;
|
|
474
|
+
}
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// .taskflow.json
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
/** The web app for a given API origin, when we can tell. */
|
|
479
|
+
export function appUrlFor(server, explicit) {
|
|
480
|
+
if (explicit)
|
|
481
|
+
return explicit.replace(/\/$/, "");
|
|
482
|
+
if (!server)
|
|
483
|
+
return DEFAULT_APP_URL;
|
|
484
|
+
try {
|
|
485
|
+
const u = new URL(server);
|
|
486
|
+
if (u.hostname === "api.taskflow.supercodehive.com" || u.hostname === "taskflow.supercodehive.com") {
|
|
487
|
+
return DEFAULT_APP_URL;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// fall through
|
|
492
|
+
}
|
|
493
|
+
return undefined; // self-hosted: we don't know where its frontend lives
|
|
494
|
+
}
|
|
495
|
+
/** Build a fresh single-profile `.taskflow.json` from a minted profile. */
|
|
496
|
+
export function newConfig(server, project, profileName, profile) {
|
|
497
|
+
return { server: server.replace(/\/$/, ""), project, default_profile: profileName, profiles: { [profileName]: profile } };
|
|
498
|
+
}
|
|
499
|
+
async function askChoice(pr, question, options, defaults, multi) {
|
|
500
|
+
const lines = options.map((o, i) => ` ${i + 1}) ${o.label}${defaults.includes(o.value) ? " (default)" : ""}`);
|
|
501
|
+
const hint = multi ? "numbers or names, comma-separated" : "number or name";
|
|
502
|
+
for (;;) {
|
|
503
|
+
const raw = (await pr.ask(`${question}\n${lines.join("\n")}\n[${hint}; Enter = default] > `)).trim();
|
|
504
|
+
if (!raw) {
|
|
505
|
+
if (defaults.length)
|
|
506
|
+
return defaults;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const picks = [];
|
|
510
|
+
let bad = false;
|
|
511
|
+
for (const part of raw.split(/[,\s]+/).filter(Boolean)) {
|
|
512
|
+
const n = Number(part);
|
|
513
|
+
const byNum = Number.isInteger(n) ? options[n - 1] : undefined;
|
|
514
|
+
const byName = options.find((o) => o.value === part.toLowerCase() || HARNESS_ALIASES[part.toLowerCase()] === o.value);
|
|
515
|
+
const hit = byNum ?? byName;
|
|
516
|
+
if (!hit)
|
|
517
|
+
bad = true;
|
|
518
|
+
else
|
|
519
|
+
picks.push(hit.value);
|
|
520
|
+
}
|
|
521
|
+
if (!bad && picks.length && (multi || picks.length === 1))
|
|
522
|
+
return [...new Set(picks)];
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async function askYesNo(pr, question, dflt) {
|
|
526
|
+
const raw = (await pr.ask(`${question} ${dflt ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
527
|
+
if (!raw)
|
|
528
|
+
return dflt;
|
|
529
|
+
return raw.startsWith("y");
|
|
530
|
+
}
|
|
531
|
+
/** Which harness CLIs / config dirs are present, for the default selection. */
|
|
532
|
+
export function detectHarnesses(p) {
|
|
533
|
+
const found = [];
|
|
534
|
+
if (p.which("claude"))
|
|
535
|
+
found.push("claude");
|
|
536
|
+
if (p.which("codex"))
|
|
537
|
+
found.push("codex");
|
|
538
|
+
if (p.which("gemini"))
|
|
539
|
+
found.push("gemini");
|
|
540
|
+
if (p.which("cursor") || p.which("cursor-agent") || p.exists(join(p.home, ".cursor")))
|
|
541
|
+
found.push("cursor");
|
|
542
|
+
if (p.which("opencode"))
|
|
543
|
+
found.push("opencode");
|
|
544
|
+
return found;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The walkthrough. Returns a process exit code: 0 when every registration went
|
|
548
|
+
* through (manual steps may remain), 1 on a usage error.
|
|
549
|
+
*/
|
|
550
|
+
export async function runInit(argv, deps) {
|
|
551
|
+
const log = deps.log;
|
|
552
|
+
let args;
|
|
553
|
+
try {
|
|
554
|
+
args = parseInitArgs(argv, deps.plan.env);
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
log(`taskflow init: ${err.message}`);
|
|
558
|
+
return 1;
|
|
559
|
+
}
|
|
560
|
+
if (args.help) {
|
|
561
|
+
log(INIT_USAGE);
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
const p = { ...deps.plan, dir: resolve(deps.plan.dir, args.dir ?? ".") };
|
|
565
|
+
const pr = deps.interactive && !args.yes ? deps.prompter : undefined;
|
|
566
|
+
log("TaskFlow setup");
|
|
567
|
+
log("");
|
|
568
|
+
// 1. Harnesses.
|
|
569
|
+
let harnesses = args.harnesses;
|
|
570
|
+
if (harnesses.length === 0) {
|
|
571
|
+
const detected = detectHarnesses(p);
|
|
572
|
+
if (!pr) {
|
|
573
|
+
log("taskflow init: pass --harness (claude, codex, gemini, cursor, opencode) when not running interactively.");
|
|
574
|
+
if (detected.length)
|
|
575
|
+
log(` detected on this machine: ${detected.join(", ")}`);
|
|
576
|
+
return 1;
|
|
577
|
+
}
|
|
578
|
+
harnesses = await askChoice(pr, "Which coding agent(s) should use TaskFlow?", HARNESSES.map((h) => ({ value: h, label: `${HARNESS_LABEL[h]}${detected.includes(h) ? " — detected" : ""}` })), detected.length ? detected : ["claude"], true);
|
|
579
|
+
}
|
|
580
|
+
// 2. Scope.
|
|
581
|
+
let scope = args.scope;
|
|
582
|
+
if (!scope) {
|
|
583
|
+
scope = pr
|
|
584
|
+
? (await askChoice(pr, "\nInstall scope?", [
|
|
585
|
+
{ value: "user", label: "user — global: every project on this machine" },
|
|
586
|
+
{ value: "project", label: `project — only ${p.dir}` },
|
|
587
|
+
], ["user"], false))[0]
|
|
588
|
+
: "user";
|
|
589
|
+
}
|
|
590
|
+
// 3. Hooks (Claude Code only).
|
|
591
|
+
let hooks = args.hooks ?? true;
|
|
592
|
+
if (harnesses.includes("claude") && args.hooks === undefined && pr) {
|
|
593
|
+
hooks = await askYesNo(pr, "\nInstall the Claude Code hooks (live session + activity logging on the board)?", true);
|
|
594
|
+
}
|
|
595
|
+
// 4. Plan + confirm + apply.
|
|
596
|
+
const plan = buildPlan({ harnesses, scope, hooks }, p);
|
|
597
|
+
log("");
|
|
598
|
+
log(args.dryRun ? "Plan (dry run — nothing will change):" : "Plan:");
|
|
599
|
+
for (const l of describePlan(plan, args.dryRun))
|
|
600
|
+
log(l);
|
|
601
|
+
log("");
|
|
602
|
+
let owed = [];
|
|
603
|
+
const hasWork = plan.some((a) => a.kind === "exec" || a.kind === "write");
|
|
604
|
+
if (args.dryRun) {
|
|
605
|
+
owed = plan.flatMap((a) => (a.kind === "manual" ? a.lines : []));
|
|
606
|
+
}
|
|
607
|
+
else if (hasWork && pr && !(await askYesNo(pr, "Apply these changes?", true))) {
|
|
608
|
+
log("Nothing changed.");
|
|
609
|
+
pr.close();
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
owed = applyPlan(plan, deps);
|
|
614
|
+
log("");
|
|
615
|
+
}
|
|
616
|
+
// 5. .taskflow.json
|
|
617
|
+
let configPath;
|
|
618
|
+
try {
|
|
619
|
+
configPath = findConfigPath({ startDir: p.dir, env: p.env });
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
configPath = undefined;
|
|
623
|
+
}
|
|
624
|
+
let server = args.server;
|
|
625
|
+
let project = args.project;
|
|
626
|
+
let token = args.token;
|
|
627
|
+
let createdConfig = false;
|
|
628
|
+
if (configPath) {
|
|
629
|
+
log(`Credentials: found ${configPath}`);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
log(`Credentials: no ${CONFIG_FILENAME} in ${p.dir} or its parents.`);
|
|
633
|
+
if (pr && !(project && token)) {
|
|
634
|
+
const want = await askYesNo(pr, "Link an agent now? This needs a TaskFlow account, a project id and YOUR user token.", false);
|
|
635
|
+
if (want) {
|
|
636
|
+
server = (await pr.ask(` API server [${server ?? DEFAULT_SERVER_URL}]: `)).trim() || server;
|
|
637
|
+
const rawProject = (await pr.ask(` Project id${project ? ` [${project}]` : ""}: `)).trim();
|
|
638
|
+
if (rawProject)
|
|
639
|
+
project = Number(rawProject);
|
|
640
|
+
token = (await pr.ask(" User token (input is visible): ")).trim() || token;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (project && Number.isInteger(project) && project > 0 && token) {
|
|
644
|
+
const srv = (server ?? DEFAULT_SERVER_URL).replace(/\/$/, "");
|
|
645
|
+
const target = join(p.dir, CONFIG_FILENAME);
|
|
646
|
+
const displayName = args.displayName ?? `${HARNESS_LABEL[harnesses[0] ?? "claude"]} (${args.profile})`;
|
|
647
|
+
if (args.dryRun) {
|
|
648
|
+
log(` would mint agent "${displayName}" (profile ${args.profile}) in project ${project} at ${srv}`);
|
|
649
|
+
log(` would write ${target}`);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
try {
|
|
653
|
+
const profile = await requestMint(srv, project, { name: args.profile, displayName, token }, token, deps.fetch);
|
|
654
|
+
const cfg = newConfig(srv, project, args.profile, profile);
|
|
655
|
+
deps.writeFile(target, `${JSON.stringify(cfg, null, 2)}\n`, 0o600);
|
|
656
|
+
configPath = target;
|
|
657
|
+
createdConfig = true;
|
|
658
|
+
log(` minted agent ${profile.agent_id} — wrote ${target}`);
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
log(` could not link an agent: ${err.message}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// The file holds secret keys: keep it out of git.
|
|
665
|
+
if (p.exists(join(p.dir, ".git")) || p.exists(join(p.dir, ".gitignore"))) {
|
|
666
|
+
const gi = join(p.dir, ".gitignore");
|
|
667
|
+
const r = mergeGitignore(p.readFile(gi));
|
|
668
|
+
if (r.changed) {
|
|
669
|
+
if (args.dryRun)
|
|
670
|
+
log(` would add ${CONFIG_FILENAME} to ${gi}`);
|
|
671
|
+
else if (createdConfig) {
|
|
672
|
+
deps.writeFile(gi, r.text);
|
|
673
|
+
log(` added ${CONFIG_FILENAME} to ${gi}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
pr?.close();
|
|
680
|
+
// 6. Doctor.
|
|
681
|
+
if (configPath && args.doctor && !args.dryRun) {
|
|
682
|
+
log("");
|
|
683
|
+
log("Checking the connection (taskflow-mcp --doctor):");
|
|
684
|
+
await deps.doctor(p.dir);
|
|
685
|
+
}
|
|
686
|
+
// 7. Next steps.
|
|
687
|
+
const app = appUrlFor(server, args.appUrl);
|
|
688
|
+
log("");
|
|
689
|
+
log("Next steps:");
|
|
690
|
+
let n = 1;
|
|
691
|
+
const step = (s) => {
|
|
692
|
+
log(` ${n++}. ${s}`);
|
|
693
|
+
};
|
|
694
|
+
for (const l of owed)
|
|
695
|
+
step(l);
|
|
696
|
+
step(`Restart ${harnesses.map((h) => HARNESS_LABEL[h]).join(" / ")} so it picks up the "${SERVER_NAME}" MCP server.`);
|
|
697
|
+
const needsCredentials = !configPath && !(args.dryRun && project && token);
|
|
698
|
+
const where = app ?? "your own TaskFlow frontend (self-hosted; pass --app-url to have it printed here)";
|
|
699
|
+
if (needsCredentials) {
|
|
700
|
+
step("Once .taskflow.json exists, verify with: taskflow-mcp --doctor");
|
|
701
|
+
step("Ask your agent to call whoami — it should answer with its TaskFlow identity.");
|
|
702
|
+
step(`Sign up or log in at ${where}. Create a project, open its API Base page and link an agent ` +
|
|
703
|
+
`(profile "main"), then save the block it shows as ${join(p.dir, CONFIG_FILENAME)} ` +
|
|
704
|
+
"(keep it out of git) — or re-run: taskflow init --project <id> --token <your user token>");
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
step("Ask your agent to call whoami — it should answer with its TaskFlow identity.");
|
|
708
|
+
step(`Sign up or log in at ${where} to watch your agents work.`);
|
|
709
|
+
}
|
|
710
|
+
return 0;
|
|
711
|
+
}
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
// Real dependencies
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
/** Is `cmd` an executable on PATH? */
|
|
716
|
+
export function onPath(cmd, env = process.env) {
|
|
717
|
+
const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
718
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
719
|
+
if (!dir)
|
|
720
|
+
continue;
|
|
721
|
+
for (const ext of exts) {
|
|
722
|
+
try {
|
|
723
|
+
accessSync(join(dir, cmd + ext), fsConstants.X_OK);
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
// keep looking
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
/** Wire {@link runInit} to the real machine and terminal. */
|
|
734
|
+
export async function runInitCommand(argv) {
|
|
735
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
736
|
+
let rl;
|
|
737
|
+
const prompter = interactive
|
|
738
|
+
? {
|
|
739
|
+
ask: (q) => {
|
|
740
|
+
rl ??= createInterface({ input: process.stdin, output: process.stdout });
|
|
741
|
+
return rl.question(q);
|
|
742
|
+
},
|
|
743
|
+
close: () => {
|
|
744
|
+
rl?.close();
|
|
745
|
+
rl = undefined;
|
|
746
|
+
},
|
|
747
|
+
}
|
|
748
|
+
: undefined;
|
|
749
|
+
try {
|
|
750
|
+
return await runInit(argv, {
|
|
751
|
+
interactive,
|
|
752
|
+
prompter,
|
|
753
|
+
plan: {
|
|
754
|
+
home: homedir(),
|
|
755
|
+
dir: process.cwd(),
|
|
756
|
+
env: process.env,
|
|
757
|
+
readFile: (path) => {
|
|
758
|
+
try {
|
|
759
|
+
return readFileSync(path, "utf8");
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
exists: existsSync,
|
|
766
|
+
which: (cmd) => onPath(cmd),
|
|
767
|
+
},
|
|
768
|
+
writeFile: (path, text, mode) => writeFileSync(path, text, mode === undefined ? "utf8" : { encoding: "utf8", mode }),
|
|
769
|
+
copyFile: copyFileSync,
|
|
770
|
+
mkdirp: (dir) => mkdirSync(dir, { recursive: true }),
|
|
771
|
+
exists: existsSync,
|
|
772
|
+
exec: (cmd, args) => {
|
|
773
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", shell: process.platform === "win32" });
|
|
774
|
+
return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? String(r.error ?? "") };
|
|
775
|
+
},
|
|
776
|
+
log: (line) => process.stdout.write(`${line}\n`),
|
|
777
|
+
doctor: (dir) => runDoctor({ startDir: dir }),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
prompter?.close();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
//# sourceMappingURL=init.js.map
|