@nolto/cli 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createRequire as createRequire2 } from "module";
5
- import { fileURLToPath as fileURLToPath2 } from "url";
6
- import path8 from "path";
5
+ import { fileURLToPath as fileURLToPath3 } from "url";
6
+ import path12 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
@@ -67,18 +67,9 @@ function isNetworkError(err) {
67
67
  }
68
68
 
69
69
  // src/constants.ts
70
- var PLAN_STATUSES = ["not_started", "in_progress", "done", "discarded"];
71
- var TEST_VERDICTS = ["passed", "failed", "skipped"];
72
- var REVIEW_VERDICTS = ["go", "no_go"];
73
- var PLAN_DOCUMENT_KINDS = ["plan", "final_report", "review_report", "test_report", "other"];
74
- var PLAN_TITLE_MAX = 500;
75
- var PLAN_CONTENT_MAX = 5e4;
76
- var PHASES_MAX = 50;
77
70
  var DOCUMENT_MAX_BYTES = 2 * 1024 * 1024;
78
- var DOCUMENT_FILENAME_MAX = 255;
79
71
  var DEFAULT_BASE_URL = "https://nolto.app";
80
72
  var CLI_USER_AGENT_NAME = "nolto-cli";
81
- var QUEUE_MAX_ENTRIES = 200;
82
73
 
83
74
  // src/config.ts
84
75
  var configSchema = z.object({
@@ -86,7 +77,10 @@ var configSchema = z.object({
86
77
  baseUrl: z.string().url().optional(),
87
78
  defaultProjectId: z.string().uuid().optional()
88
79
  }).strict();
89
- var repoBindingSchema = z.object({ projectId: z.string().uuid() }).passthrough();
80
+ var repoBindingSchema = z.object({
81
+ projectId: z.string().uuid(),
82
+ roadmapSlug: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/).optional()
83
+ }).passthrough();
90
84
  async function loadRepoBinding(filePath) {
91
85
  let raw;
92
86
  try {
@@ -135,8 +129,7 @@ function findRepoBindingFile(args) {
135
129
  const startDir = cpd != null && cpd.length > 0 ? cpd : args.cwd;
136
130
  return findNoltoJsonSync(startDir);
137
131
  }
138
- async function writeRepoBinding(root, projectId) {
139
- const filePath = path.join(root, "nolto.json");
132
+ async function mergeJsonFile(filePath, patch) {
140
133
  let existing = {};
141
134
  try {
142
135
  const raw = await readFile(filePath, "utf8");
@@ -154,11 +147,17 @@ async function writeRepoBinding(root, projectId) {
154
147
  if (code !== "ENOENT") {
155
148
  }
156
149
  }
157
- const merged = { ...existing, projectId };
150
+ const merged = { ...existing, ...patch };
158
151
  const { chmod } = await import("fs/promises");
159
152
  await writeFile(filePath, JSON.stringify(merged, null, 2) + "\n", { mode: 420 });
160
153
  await chmod(filePath, 420);
161
154
  }
155
+ async function writeRepoBinding(root, projectId) {
156
+ await mergeJsonFile(path.join(root, "nolto.json"), { projectId });
157
+ }
158
+ async function writeRoadmapSlug(root, slug) {
159
+ await mergeJsonFile(path.join(root, "nolto.json"), { roadmapSlug: slug });
160
+ }
162
161
  function getConfigDir(env) {
163
162
  const xdg = env["XDG_CONFIG_HOME"];
164
163
  const base = xdg != null && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
@@ -273,80 +272,49 @@ function maskToken(token) {
273
272
  return "\u2026" + token.slice(-4);
274
273
  }
275
274
 
276
- // src/mcp.ts
277
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
278
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
279
- function buildMcpEndpoint(baseUrl) {
280
- const normalized = baseUrl.replace(/\/+$/, "");
281
- return new URL(normalized + "/mcp");
282
- }
283
- function createMcpCaller(opts) {
284
- const { baseUrl, token, version } = opts;
285
- return {
286
- async call(toolName, args) {
287
- const capture = { lastNonOk: null };
288
- const capturingFetch = async (input, init) => {
289
- const res = await fetch(input, init);
290
- if (!res.ok) {
291
- capture.lastNonOk = {
292
- status: res.status,
293
- retryAfter: res.headers.get("retry-after") ?? void 0,
294
- wwwAuthenticate: res.headers.get("www-authenticate") ?? void 0
295
- };
296
- }
297
- return res;
298
- };
299
- const transport = new StreamableHTTPClientTransport(buildMcpEndpoint(baseUrl), {
300
- requestInit: {
301
- headers: { Authorization: `Bearer ${token}` }
302
- },
303
- fetch: capturingFetch
275
+ // src/http.ts
276
+ function createHttpClient(opts) {
277
+ const { baseUrl, version, token } = opts;
278
+ const base = baseUrl.replace(/\/+$/, "");
279
+ async function request(method, path13, body) {
280
+ if (!path13.startsWith("/api/")) {
281
+ throw new CliError(`HTTP client path must start with /api/, got: ${path13}`, 2);
282
+ }
283
+ const url = `${base}${path13}`;
284
+ const headers = {
285
+ "Content-Type": "application/json",
286
+ "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
287
+ };
288
+ if (token) {
289
+ headers["Authorization"] = `Bearer ${token}`;
290
+ }
291
+ let response;
292
+ try {
293
+ response = await fetch(url, {
294
+ method,
295
+ headers,
296
+ body: body === void 0 ? void 0 : JSON.stringify(body)
304
297
  });
305
- const client = new Client({ name: CLI_USER_AGENT_NAME, version });
306
- try {
307
- await client.connect(transport);
308
- const result = await client.callTool({ name: toolName, arguments: args });
309
- if (result.isError === true) {
310
- const content2 = result.content;
311
- const firstText = Array.isArray(content2) && content2.length > 0 ? content2[0]?.text : void 0;
312
- throw new CliError(firstText ?? "Tool error", 1);
313
- }
314
- const content = result.content;
315
- const firstItem = Array.isArray(content) && content.length > 0 ? content[0] : void 0;
316
- const rawText = firstItem?.text;
317
- if (rawText == null) {
318
- return result;
319
- }
320
- try {
321
- return JSON.parse(rawText);
322
- } catch {
323
- return rawText;
324
- }
325
- } catch (err) {
326
- if (err instanceof CliError) {
327
- throw err;
328
- }
329
- if (capture.lastNonOk != null) {
330
- throw mapHttpStatusToCliError(
331
- capture.lastNonOk.status,
332
- capture.lastNonOk.retryAfter,
333
- capture.lastNonOk.wwwAuthenticate
334
- );
335
- }
336
- if (isNetworkError(err)) {
337
- throw new CliError(`Cannot reach ${baseUrl}`, 5);
338
- }
339
- if (err instanceof Error && err.message) {
340
- throw new CliError(err.message, 1);
341
- }
342
- throw new CliError(String(err), 1);
343
- } finally {
344
- try {
345
- await client.close();
346
- } catch {
347
- }
298
+ } catch (err) {
299
+ if (isNetworkError(err)) {
300
+ throw new CliError(
301
+ `Network error contacting ${base}: ${err instanceof Error ? err.message : String(err)}`,
302
+ 5
303
+ );
348
304
  }
305
+ throw err;
349
306
  }
307
+ if (!response.ok) {
308
+ const retryAfter = response.headers.get("retry-after") ?? void 0;
309
+ const wwwAuthenticate = response.headers.get("www-authenticate") ?? void 0;
310
+ throw mapHttpStatusToCliError(response.status, retryAfter, wwwAuthenticate);
311
+ }
312
+ return response.json();
313
+ }
314
+ return {
315
+ get: (p) => request("GET", p),
316
+ post: (p, b) => request("POST", p, b),
317
+ put: (p, b) => request("PUT", p, b)
350
318
  };
351
319
  }
352
320
 
@@ -356,466 +324,620 @@ import { Command } from "commander";
356
324
  // src/commands/init.ts
357
325
  import readline from "readline/promises";
358
326
  import { createRequire } from "module";
359
- import { fileURLToPath } from "url";
360
- import path2 from "path";
327
+ import { fileURLToPath as fileURLToPath2 } from "url";
328
+ import path6 from "path";
361
329
  import fs from "fs";
362
330
 
363
- // src/unwrap.ts
364
- function unwrapList(result, key) {
365
- if (Array.isArray(result)) {
366
- return result;
367
- }
368
- if (result != null && typeof result === "object") {
369
- const inner = result[key];
370
- if (Array.isArray(inner)) {
371
- return inner;
331
+ // src/commands/link.ts
332
+ import path2 from "path";
333
+ import { statSync as statSync2 } from "fs";
334
+
335
+ // src/output.ts
336
+ function printResult(value, mode2, opts = {}) {
337
+ const out = opts.stream ?? process.stdout;
338
+ if (mode2 === "json") {
339
+ out.write(JSON.stringify(value, null, 2) + "\n");
340
+ } else {
341
+ out.write(formatValue(value) + "\n");
342
+ }
343
+ }
344
+ function printError(err, mode2) {
345
+ if (mode2 === "json") {
346
+ const envelope = {
347
+ error: {
348
+ message: err.message,
349
+ exitCode: err.exitCode,
350
+ ...err.status != null ? { status: err.status } : {},
351
+ ...err.hint != null ? { hint: err.hint } : {}
352
+ }
353
+ };
354
+ process.stderr.write(JSON.stringify(envelope, null, 2) + "\n");
355
+ } else {
356
+ process.stderr.write(`Error: ${err.message}
357
+ `);
358
+ if (err.hint != null) {
359
+ process.stderr.write(`Hint: ${err.hint}
360
+ `);
372
361
  }
373
362
  }
374
- return [];
363
+ }
364
+ function formatValue(value) {
365
+ if (value === null || value === void 0) {
366
+ return "";
367
+ }
368
+ if (typeof value === "string") {
369
+ return value;
370
+ }
371
+ if (typeof value === "number" || typeof value === "boolean") {
372
+ return String(value);
373
+ }
374
+ if (Array.isArray(value)) {
375
+ return value.map((v) => formatValue(v)).join("\n");
376
+ }
377
+ if (typeof value === "object") {
378
+ const obj = value;
379
+ const keys = Object.keys(obj);
380
+ const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
381
+ return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${formatValue(obj[k])}`).join("\n");
382
+ }
383
+ return JSON.stringify(value, null, 2);
375
384
  }
376
385
 
377
- // src/commands/init.ts
378
- var __dirname = path2.dirname(fileURLToPath(import.meta.url));
379
- var _require = createRequire(import.meta.url);
380
- function getCliVersion() {
381
- const candidates = [
382
- path2.resolve(__dirname, "../package.json"),
383
- // bundled: dist/../package.json
384
- path2.resolve(__dirname, "../../package.json")
385
- // source: src/commands/../../package.json
386
- ];
387
- for (const pkgPath of candidates) {
388
- if (fs.existsSync(pkgPath)) {
389
- try {
390
- const pkg = _require(pkgPath);
391
- return pkg.version ?? "0.0.0";
392
- } catch {
393
- }
386
+ // src/commands/link.ts
387
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
388
+ function resolveStartDir(env, cwd) {
389
+ const cpd = env["CLAUDE_PROJECT_DIR"];
390
+ return cpd != null && cpd.length > 0 ? cpd : cwd;
391
+ }
392
+ function dirHasGit(dir) {
393
+ try {
394
+ statSync2(path2.join(dir, ".git"));
395
+ return true;
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+ function findRepoRoot(startDir, hasGit = dirHasGit) {
401
+ let current = startDir;
402
+ while (true) {
403
+ if (hasGit(current)) {
404
+ return { root: current, foundGit: true };
394
405
  }
406
+ const parent = path2.dirname(current);
407
+ if (parent === current) break;
408
+ current = parent;
395
409
  }
396
- return "0.0.0";
410
+ return { root: startDir, foundGit: false };
397
411
  }
398
- function register(program, deps) {
399
- program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
400
- const configPath = deps.configPath;
401
- if (!opts.force) {
402
- let existing = null;
403
- try {
404
- existing = await loadConfigFile(configPath);
405
- } catch {
406
- }
407
- if (existing != null) {
408
- const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
409
- try {
410
- const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
411
- if (answer.trim().toLowerCase() !== "y") {
412
- process.stdout.write("Cancelled.\n");
413
- return;
414
- }
415
- } finally {
416
- rl2.close();
417
- }
418
- }
412
+ async function handleShow(deps, projectBindingPath, mode2) {
413
+ if (projectBindingPath == null) {
414
+ if (mode2 === "json") {
415
+ printResult({ bound: false, projectBindingPath: null }, mode2);
416
+ } else {
417
+ process.stdout.write("No nolto.json binding found in this directory tree.\n");
419
418
  }
420
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
421
- let token = "";
422
- try {
423
- const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
424
- const baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
425
- token = await promptHidden(rl, "API token: ");
426
- if (token.length === 0) {
427
- throw new CliError("Token is required.", 2);
428
- }
429
- const caller = createMcpCaller({ baseUrl, token, version: getCliVersion() });
430
- let projects = [];
431
- try {
432
- const result = await caller.call("list_projects", {});
433
- projects = unwrapList(result, "projects");
434
- } catch (err) {
435
- if (err instanceof CliError && err.exitCode === 3) {
436
- throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
437
- }
438
- throw err;
439
- }
440
- let defaultProjectId;
441
- let defaultProjectName;
442
- if (projects.length > 0) {
443
- process.stdout.write("\nProjects:\n");
444
- projects.forEach((p, i) => {
445
- process.stdout.write(` (${i + 1}) ${p.name} \u2014 ${p.id}
446
- `);
447
- });
448
- const pick = await rl.question("Default project number (or skip): ");
449
- const num = parseInt(pick.trim(), 10);
450
- if (!isNaN(num) && num >= 1 && num <= projects.length) {
451
- defaultProjectId = projects[num - 1].id;
452
- defaultProjectName = projects[num - 1].name;
453
- }
454
- }
455
- await saveConfigFile(configPath, {
456
- token,
457
- baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
458
- defaultProjectId
459
- });
460
- const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
461
- process.stdout.write(`
462
- Saved ${configPath}
419
+ return;
420
+ }
421
+ const binding = await loadRepoBinding(projectBindingPath).catch((err) => {
422
+ if (err instanceof CliError) throw err;
423
+ throw new CliError(`Cannot read binding: ${String(err)}`, 2);
424
+ });
425
+ if (mode2 === "json") {
426
+ printResult({
427
+ bound: binding != null,
428
+ projectId: binding?.projectId ?? null,
429
+ projectBindingPath,
430
+ source: deps.settings.source.project === "repo" ? "repo" : "file"
431
+ }, mode2);
432
+ } else {
433
+ if (binding == null) {
434
+ process.stdout.write(`Binding file found at ${projectBindingPath} but could not be read.
463
435
  `);
464
- process.stdout.write(`baseUrl: ${baseUrl}
436
+ } else {
437
+ process.stdout.write(`Binding file : ${projectBindingPath}
465
438
  `);
466
- process.stdout.write(`token: ${maskToken(token)} (verified)
439
+ process.stdout.write(`projectId : ${binding.projectId}
467
440
  `);
468
- process.stdout.write(`defaultProject: ${projectDisplay}
441
+ const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
442
+ process.stdout.write(`source : ${active}
469
443
  `);
470
- } finally {
471
- rl.close();
472
444
  }
473
- });
445
+ }
474
446
  }
475
- async function promptHidden(rl, prompt) {
476
- const iface = rl;
477
- const originalWrite = iface._writeToOutput?.bind(rl);
478
- let muted = false;
479
- iface._writeToOutput = (str) => {
480
- if (muted) {
481
- if (str !== "\r\n" && str !== "\n" && str !== "\r" && !str.startsWith("\x1B")) {
482
- process.stdout.write("*");
483
- } else {
484
- originalWrite?.(str);
485
- }
486
- return;
447
+ async function handleUnlink(projectBindingPath, mode2) {
448
+ const { readFile: readFile7, writeFile: writeFile6, chmod } = await import("fs/promises");
449
+ let existing = {};
450
+ try {
451
+ const raw = await readFile7(projectBindingPath, "utf8");
452
+ const parsed = JSON.parse(raw);
453
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
454
+ throw new CliError(
455
+ `Cannot unlink ${projectBindingPath}: file contains valid JSON but is not a plain object (got ${Array.isArray(parsed) ? "array" : String(parsed)}). Remove or fix the file manually.`,
456
+ 2
457
+ );
487
458
  }
488
- originalWrite?.(str);
489
- };
490
- muted = true;
491
- const value = await rl.question(prompt);
492
- muted = false;
493
- process.stdout.write("\n");
494
- iface._writeToOutput = originalWrite;
495
- return value;
496
- }
497
-
498
- // src/commands/login.ts
499
- import readline2 from "readline/promises";
500
-
501
- // src/http.ts
502
- function createHttpClient(opts) {
503
- const { baseUrl, version, token } = opts;
504
- const base = baseUrl.replace(/\/+$/, "");
505
- return {
506
- async post(path9, body) {
507
- if (!path9.startsWith("/api/")) {
508
- throw new CliError(`HTTP client path must start with /api/, got: ${path9}`, 2);
509
- }
510
- const url = `${base}${path9}`;
511
- const headers = {
512
- "Content-Type": "application/json",
513
- "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
514
- };
515
- if (token) {
516
- headers["Authorization"] = `Bearer ${token}`;
517
- }
518
- let response;
519
- try {
520
- response = await fetch(url, {
521
- method: "POST",
522
- headers,
523
- body: JSON.stringify(body)
524
- });
525
- } catch (err) {
526
- if (isNetworkError(err)) {
527
- throw new CliError(
528
- `Network error contacting ${base}: ${err instanceof Error ? err.message : String(err)}`,
529
- 5
530
- );
531
- }
532
- throw err;
533
- }
534
- if (!response.ok) {
535
- const retryAfter = response.headers.get("retry-after") ?? void 0;
536
- const wwwAuthenticate = response.headers.get("www-authenticate") ?? void 0;
537
- throw mapHttpStatusToCliError(response.status, retryAfter, wwwAuthenticate);
538
- }
539
- return response.json();
540
- }
541
- };
459
+ existing = parsed;
460
+ } catch (err) {
461
+ if (err instanceof CliError) throw err;
462
+ throw new CliError(`Cannot read ${projectBindingPath}: ${String(err)}`, 2);
463
+ }
464
+ const { projectId: _removed, ...rest } = existing;
465
+ void _removed;
466
+ await writeFile6(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
467
+ await chmod(projectBindingPath, 420);
468
+ if (mode2 === "json") {
469
+ printResult({ unlinked: true, projectBindingPath }, mode2);
470
+ } else {
471
+ process.stdout.write(`Removed projectId from ${projectBindingPath}.
472
+ `);
473
+ }
542
474
  }
543
-
544
- // src/login-poll.ts
545
- async function pollUntilToken(opts) {
546
- const { http, deviceCode, expiresIn, baseUrl } = opts;
547
- let intervalSeconds = opts.intervalSeconds;
548
- const deadline = Date.now() + expiresIn * 1e3;
549
- await sleep(intervalSeconds * 1e3);
550
- while (Date.now() < deadline) {
551
- let resp;
475
+ async function performLink(deps, projectId, mode2) {
476
+ if (!UUID_RE.test(projectId)) {
477
+ throw new CliError(
478
+ `Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
479
+ 2
480
+ );
481
+ }
482
+ const startDir = resolveStartDir(process.env, process.cwd());
483
+ const { root, foundGit } = findRepoRoot(startDir);
484
+ if (!foundGit) {
485
+ process.stderr.write(
486
+ `Warning: no .git directory found above ${startDir}. Writing nolto.json to current directory.
487
+ `
488
+ );
489
+ }
490
+ if (deps.settings.token != null) {
552
491
  try {
553
- resp = await http.post("/api/cli/auth/poll", {
554
- device_code: deviceCode
492
+ const http = deps.http ?? createHttpClient({
493
+ baseUrl: deps.settings.baseUrl,
494
+ token: deps.settings.token,
495
+ version: deps.version
555
496
  });
556
- } catch (err) {
557
- if (err instanceof CliError && err.exitCode === 4) {
558
- await sleep(intervalSeconds * 1e3);
559
- continue;
497
+ const result = await http.get(
498
+ "/api/projects"
499
+ );
500
+ const projects = Array.isArray(result.projects) ? result.projects : [];
501
+ if (!projects.some((p) => p.id === projectId)) {
502
+ process.stderr.write(
503
+ `Warning: project ${projectId} was not found in GET /api/projects.
504
+ Proceeding anyway \u2014 verify the ID is correct.
505
+ `
506
+ );
560
507
  }
561
- throw err;
508
+ } catch {
509
+ process.stderr.write(
510
+ "Warning: could not verify project membership (offline or token issue). Proceeding anyway.\n"
511
+ );
562
512
  }
563
- if (!resp.error) {
564
- if (!resp.token) {
565
- throw new CliError("Server returned success but no token.", 5);
513
+ }
514
+ await writeRepoBinding(root, projectId);
515
+ const writtenPath = path2.join(root, "nolto.json");
516
+ if (mode2 === "json") {
517
+ printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
518
+ } else {
519
+ process.stdout.write(
520
+ `Linked this repo to project ${projectId} (wrote ${writtenPath}).
521
+ Commit nolto.json to share the binding with your team.
522
+ `
523
+ );
524
+ }
525
+ }
526
+ function register(program, deps) {
527
+ const cmd = program.command("link [projectId]").description(
528
+ "Bind this repository to a Nolto project.\nWrites nolto.json at the repo root. Commit it to share the binding with your team.\n\nExamples:\n nolto link <uuid> Write / update nolto.json\n nolto link --show Show the current binding\n nolto link --unlink Remove the projectId from nolto.json"
529
+ ).option("--show", "Show the current repo binding (path + projectId + source)").option("--unlink", "Remove the projectId key from nolto.json");
530
+ cmd.action(async (projectId) => {
531
+ const { output } = deps;
532
+ const projectBindingPath = deps.projectBindingPath ?? null;
533
+ const mode2 = output.mode;
534
+ if (cmd.opts()["show"]) {
535
+ await handleShow(deps, projectBindingPath, mode2);
536
+ return;
537
+ }
538
+ if (cmd.opts()["unlink"]) {
539
+ if (projectBindingPath == null) {
540
+ throw new CliError("No nolto.json found in this directory tree. Nothing to unlink.", 2);
566
541
  }
567
- return resp.token;
542
+ await handleUnlink(projectBindingPath, mode2);
543
+ return;
568
544
  }
569
- switch (resp.error) {
570
- case "authorization_pending":
571
- await sleep(intervalSeconds * 1e3);
572
- break;
573
- case "slow_down":
574
- intervalSeconds += 5;
575
- await sleep(intervalSeconds * 1e3);
576
- break;
577
- case "expired_token":
578
- throw new CliError("Device code expired. Run `nolto login` again.", 2);
579
- case "access_denied":
580
- throw new CliError("\u30ED\u30B0\u30A4\u30F3\u304C\u62D2\u5426\u3055\u308C\u307E\u3057\u305F\u3002", 3);
581
- case "token_cap_exceeded":
582
- throw new CliError(
583
- `\u30A2\u30AF\u30C6\u30A3\u30D6\u306A API token \u304C\u4E0A\u9650(20)\u3067\u3059\u3002${baseUrl}/settings/tokens \u3067\u4E0D\u8981\u306A\u30C8\u30FC\u30AF\u30F3\u3092\u5931\u52B9\u3057\u3066\u304B\u3089\u518D\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
584
- 2
585
- );
586
- default:
587
- throw new CliError(`Unexpected poll response: ${resp.error}`, 5);
545
+ if (projectId == null || projectId.trim().length === 0) {
546
+ throw new CliError(
547
+ "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --unlink to remove it.",
548
+ 2
549
+ );
588
550
  }
589
- }
590
- throw new CliError("Login timed out. Run `nolto login` again.", 2);
591
- }
592
- function sleep(ms) {
593
- return new Promise((resolve) => setTimeout(resolve, ms));
551
+ await performLink(deps, projectId, mode2);
552
+ });
594
553
  }
595
554
 
596
- // src/client-inject.ts
597
- import { readFile as readFile2, writeFile as writeFile2, rename, mkdir as mkdir2, unlink } from "fs/promises";
598
- import { execFile } from "child_process";
599
- import { promisify } from "util";
600
- import os2 from "os";
555
+ // src/skill-install.ts
556
+ import { cp, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, rm } from "fs/promises";
557
+ import { existsSync } from "fs";
601
558
  import path3 from "path";
602
- function getExecFileAsync() {
603
- return promisify(execFile);
604
- }
605
- async function atomicWrite(filePath, content, mode2) {
606
- const dir = path3.dirname(filePath);
607
- await mkdir2(dir, { recursive: true, mode: 448 });
608
- const tmp = `${filePath}.tmp.${Date.now()}`;
609
- try {
610
- await writeFile2(tmp, content, { mode: mode2 });
611
- await rename(tmp, filePath);
612
- } catch (err) {
613
- await unlink(tmp).catch(() => void 0);
614
- throw err;
615
- }
616
- }
617
- async function readJsonConfig(filePath) {
618
- try {
619
- const raw = await readFile2(filePath, "utf8");
620
- const parsed = JSON.parse(raw);
621
- return { parsed, raw };
622
- } catch (err) {
623
- const code = err.code;
624
- if (code === "ENOENT") return null;
625
- return null;
626
- }
627
- }
628
- async function injectCursor(opts) {
629
- const configDir = opts.cursorConfigDir ?? process.env["CURSOR_CONFIG_DIR"] ?? path3.join(os2.homedir(), ".cursor");
630
- const configPath = path3.join(configDir, "mcp.json");
631
- const mcpUrl = `${opts.baseUrl.replace(/\/+$/, "")}/mcp`;
632
- let existing = {};
633
- const raw = await readFile2(configPath, "utf8").catch((err) => {
634
- if (err.code === "ENOENT") return null;
635
- return null;
636
- });
637
- if (raw !== null) {
559
+ import { fileURLToPath } from "url";
560
+ var __dirname = path3.dirname(fileURLToPath(import.meta.url));
561
+ function resolveSkillSourceDir() {
562
+ const candidates = [
563
+ path3.resolve(__dirname, "skill/roadmap-progress"),
564
+ // bundled: dist/skill/...
565
+ path3.resolve(__dirname, "../../../skills/roadmap-progress")
566
+ // source: <repo>/skills/...
567
+ ];
568
+ for (const candidate of candidates) {
569
+ if (existsSync(path3.join(candidate, "SKILL.md"))) return candidate;
570
+ }
571
+ throw new Error("Bundled roadmap-progress skill not found. Reinstall @nolto/cli.");
572
+ }
573
+ var VERSION_MARKER = ".nolto-skill-version";
574
+ async function installSkill(args) {
575
+ const targetDir = path3.join(args.skillsParentDir, "roadmap-progress");
576
+ const markerPath = path3.join(targetDir, VERSION_MARKER);
577
+ const dirExists = existsSync(targetDir);
578
+ let installedVersion = null;
579
+ if (dirExists) {
638
580
  try {
639
- existing = JSON.parse(raw);
581
+ installedVersion = (await readFile2(markerPath, "utf8")).trim();
640
582
  } catch {
641
- const backupPath = `${configPath}.bak.${Date.now()}`;
642
- process.stdout.write(
643
- `Warning: ${configPath} contains invalid JSON. Backing up to ${backupPath}
644
- `
645
- );
646
- await writeFile2(backupPath, raw, { mode: 384 }).catch(() => void 0);
647
- existing = {};
583
+ installedVersion = null;
648
584
  }
649
585
  }
650
- const updatedServers = {
651
- ...existing.mcpServers,
652
- nolto: {
653
- url: mcpUrl,
654
- headers: {
655
- Authorization: `Bearer ${opts.token}`
656
- }
657
- }
658
- };
659
- const newConfig = {
660
- ...existing,
661
- mcpServers: updatedServers
662
- };
663
- await atomicWrite(configPath, JSON.stringify(newConfig, null, 2) + "\n", 384);
664
- process.stdout.write(`Updated ${configPath} (cursor)
665
- `);
666
- }
667
- async function injectClaude(opts) {
668
- const mcpUrl = `${opts.baseUrl.replace(/\/+$/, "")}/mcp`;
669
- const claudePath = await findOnPath("claude");
670
- if (claudePath) {
671
- const execFileAsync2 = getExecFileAsync();
672
- await execFileAsync2(claudePath, ["mcp", "remove", "nolto", "--scope", "user"]).catch(
673
- () => void 0
674
- );
675
- try {
676
- const { stderr } = await execFileAsync2(claudePath, [
677
- "mcp",
678
- "add",
679
- "nolto",
680
- mcpUrl,
681
- "--transport",
682
- "http",
683
- "--scope",
684
- "user",
685
- "--header",
686
- `Authorization: Bearer ${opts.token}`
687
- ]);
688
- if (stderr) {
689
- process.stderr.write(`[claude mcp add] ${stderr}
690
- `);
691
- }
692
- process.stdout.write("Registered nolto MCP server in Claude Code (user scope).\n");
693
- process.stdout.write("Reconnect or restart Claude Code to use it.\n");
694
- return;
695
- } catch (err) {
696
- const msg = err instanceof Error ? err.message : String(err);
697
- process.stderr.write(
698
- `Warning: claude mcp add failed (${msg}); writing project .mcp.json instead.
699
- `
700
- );
701
- }
586
+ if (dirExists && installedVersion === args.version && args.force !== true) {
587
+ return { action: "skipped", targetDir };
702
588
  }
703
- const cwd = opts.claudeCwd ?? process.cwd();
704
- const configPath = path3.join(cwd, ".mcp.json");
705
- const result = await readJsonConfig(configPath);
706
- const existing = result?.parsed ?? {};
707
- const updatedServers = {
708
- ...existing.mcpServers,
709
- nolto: {
710
- url: mcpUrl,
711
- headers: {
712
- Authorization: "Bearer ${NOLTO_MCP_TOKEN}"
713
- }
714
- }
715
- };
716
- const newConfig = {
717
- ...existing,
718
- mcpServers: updatedServers
719
- };
720
- await atomicWrite(configPath, JSON.stringify(newConfig, null, 2) + "\n", 384);
721
- const tokensUrl = `${opts.baseUrl.replace(/\/+$/, "")}/settings/tokens`;
722
- process.stdout.write(
723
- `Updated ${configPath} (claude project .mcp.json) \u2014 uses the NOLTO_MCP_TOKEN env var; no token is stored in the file, so it is safe to commit.
724
- `
725
- );
726
- process.stdout.write("Set NOLTO_MCP_TOKEN in your environment before launching Claude Code, e.g.:\n");
727
- process.stdout.write(" export NOLTO_MCP_TOKEN=<your token>\n");
728
- process.stdout.write(`Your token is saved in your nolto config (from this login); or create one at ${tokensUrl}
729
- `);
589
+ await rm(targetDir, { recursive: true, force: true });
590
+ await mkdir2(args.skillsParentDir, { recursive: true });
591
+ await cp(args.sourceDir, targetDir, { recursive: true });
592
+ await writeFile2(markerPath, args.version + "\n", "utf8");
593
+ return { action: dirExists ? "updated" : "installed", targetDir };
730
594
  }
731
- async function injectCodex(opts) {
732
- const mcpUrl = `${opts.baseUrl.replace(/\/+$/, "")}/mcp`;
733
- const codexPath = await findOnPath("codex");
734
- if (!codexPath) {
735
- process.stdout.write(
736
- `
737
- To add Nolto to Codex, run:
738
-
739
- codex mcp add nolto --url ${mcpUrl} --bearer-token-env-var NOLTO_TOKEN
740
595
 
741
- Then set NOLTO_TOKEN=${opts.token} in your environment.
742
- `
743
- );
744
- return;
745
- }
596
+ // src/registry.ts
597
+ import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
598
+ import path4 from "path";
599
+ import { z as z2 } from "zod";
600
+ var registrySchema = z2.object({
601
+ schemaVersion: z2.literal(1),
602
+ repos: z2.array(z2.object({ root: z2.string().min(1) }).passthrough())
603
+ }).passthrough();
604
+ function getRegistryPath(env) {
605
+ return path4.join(getConfigDir(env), "registry.json");
606
+ }
607
+ async function loadRegistry(filePath) {
608
+ let raw;
746
609
  try {
747
- const execFileAsync2 = getExecFileAsync();
748
- const { stderr } = await execFileAsync2(codexPath, [
749
- "mcp",
750
- "add",
751
- "nolto",
752
- "--url",
753
- mcpUrl,
754
- "--bearer-token-env-var",
755
- "NOLTO_TOKEN"
756
- ]);
757
- if (stderr) {
758
- process.stderr.write(`[codex mcp add] ${stderr}
759
- `);
760
- }
761
- process.stdout.write(`Registered nolto MCP server in Codex.
762
- `);
763
- process.stdout.write(
764
- `Set NOLTO_TOKEN=${opts.token.slice(0, 8)}... in your environment (or shell profile).
765
- `
766
- );
610
+ raw = await readFile3(filePath, "utf8");
767
611
  } catch (err) {
768
- const msg = err instanceof Error ? err.message : String(err);
769
- throw new CliError(`codex mcp add failed: ${msg}`, 5);
612
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
613
+ if (code === "ENOENT") {
614
+ return { schemaVersion: 1, repos: [] };
615
+ }
616
+ throw new CliError(`Cannot read registry: ${filePath}: ${String(err)}`, 2);
770
617
  }
771
- }
772
- async function findOnPath(bin) {
618
+ let parsed;
773
619
  try {
774
- const execFileAsync2 = getExecFileAsync();
775
- const { stdout } = await execFileAsync2(
776
- process.platform === "win32" ? "where" : "which",
777
- [bin]
778
- );
779
- const found = stdout.trim().split("\n")[0]?.trim();
780
- return found && found.length > 0 ? found : null;
620
+ parsed = JSON.parse(raw);
781
621
  } catch {
782
- return null;
783
- }
784
- }
785
- async function injectClient(opts) {
786
- switch (opts.client) {
787
- case "cursor":
788
- return injectCursor(opts);
789
- case "claude":
790
- return injectClaude(opts);
791
- case "codex":
792
- return injectCodex(opts);
793
- default: {
794
- const _never = opts.client;
795
- throw new CliError(`Unknown client: ${String(_never)}`, 2);
796
- }
622
+ throw new CliError(`Malformed JSON in ${filePath}. Fix or remove the file.`, 2);
623
+ }
624
+ const result = registrySchema.safeParse(parsed);
625
+ if (!result.success) {
626
+ const issue = result.error.issues[0];
627
+ const fieldPath = issue?.path.join(".") ?? "";
628
+ throw new CliError(
629
+ `Invalid registry at ${filePath}: ${fieldPath.length > 0 ? `field "${fieldPath}" \u2014 ` : ""}${issue?.message ?? "validation failed"}`,
630
+ 2
631
+ );
632
+ }
633
+ return result.data;
634
+ }
635
+ async function addRepoToRegistry(filePath, repoRoot) {
636
+ const registry = await loadRegistry(filePath);
637
+ if (registry.repos.some((repo) => repo.root === repoRoot)) {
638
+ return { added: false, registry };
797
639
  }
640
+ const updated = { ...registry, repos: [...registry.repos, { root: repoRoot }] };
641
+ await mkdir3(path4.dirname(filePath), { recursive: true, mode: 448 });
642
+ await writeFile3(filePath, JSON.stringify(updated, null, 2) + "\n", "utf8");
643
+ return { added: true, registry: updated };
798
644
  }
799
645
 
800
- // src/commands/login.ts
646
+ // src/roadmap-scaffold.ts
647
+ import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
648
+ import { existsSync as existsSync2 } from "fs";
649
+ import path5 from "path";
650
+ function slugifyProjectId(name) {
651
+ const slug = name.toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[-_.]+$/, "");
652
+ return slug.length > 0 ? slug : "project";
653
+ }
654
+ async function scaffoldRoadmap(args) {
655
+ const dir = path5.join(args.repoRoot, ".roadmap");
656
+ const filePath = path5.join(dir, "roadmap.json");
657
+ if (existsSync2(filePath)) {
658
+ return { created: false, path: filePath };
659
+ }
660
+ const repoBasename = path5.basename(args.repoRoot);
661
+ const roadmap = {
662
+ schemaVersion: 2,
663
+ project: {
664
+ id: slugifyProjectId(repoBasename),
665
+ name: args.projectName,
666
+ repository: repoBasename
667
+ },
668
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
669
+ currentTaskId: null,
670
+ summary: "",
671
+ phases: []
672
+ };
673
+ await mkdir4(dir, { recursive: true });
674
+ await writeFile4(filePath, JSON.stringify(roadmap, null, 2) + "\n", "utf8");
675
+ return { created: true, path: filePath };
676
+ }
677
+
678
+ // src/commands/init.ts
679
+ var __dirname2 = path6.dirname(fileURLToPath2(import.meta.url));
680
+ var _require = createRequire(import.meta.url);
681
+ function getCliVersion() {
682
+ const candidates = [
683
+ path6.resolve(__dirname2, "../package.json"),
684
+ // bundled: dist/../package.json
685
+ path6.resolve(__dirname2, "../../package.json")
686
+ // source: src/commands/../../package.json
687
+ ];
688
+ for (const pkgPath of candidates) {
689
+ if (fs.existsSync(pkgPath)) {
690
+ try {
691
+ const pkg = _require(pkgPath);
692
+ return pkg.version ?? "0.0.0";
693
+ } catch {
694
+ }
695
+ }
696
+ }
697
+ return "0.0.0";
698
+ }
801
699
  function register2(program, deps) {
802
- program.command("login").description("Authenticate via browser and save an API token to the config file.").option("--client <name>", "Inject token into MCP client config (cursor|claude|codex)").option("--force", "Overwrite existing token without prompting").action(
803
- async (opts) => {
804
- const { configPath, settings, output } = deps;
805
- const validClients = ["cursor", "claude", "codex"];
806
- if (opts.client !== void 0 && !validClients.includes(opts.client)) {
807
- throw new CliError(
808
- `--client must be one of: ${validClients.join(", ")}. Got: ${opts.client}`,
809
- 2
810
- );
700
+ program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
701
+ const configPath = deps.configPath;
702
+ if (!opts.force) {
703
+ let existing = null;
704
+ try {
705
+ existing = await loadConfigFile(configPath);
706
+ } catch {
811
707
  }
812
- const clientTarget = opts.client;
813
- if (!opts.force) {
814
- let existing = null;
708
+ if (existing != null) {
709
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
815
710
  try {
816
- existing = await loadConfigFile(configPath);
817
- } catch {
818
- }
711
+ const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
712
+ if (answer.trim().toLowerCase() !== "y") {
713
+ process.stdout.write("Cancelled.\n");
714
+ return;
715
+ }
716
+ } finally {
717
+ rl2.close();
718
+ }
719
+ }
720
+ }
721
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
722
+ let token = "";
723
+ try {
724
+ const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
725
+ const baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
726
+ const currentToken = deps.settings.token;
727
+ const tokenPrompt = currentToken != null ? "API token [press Enter to keep current token]: " : "API token: ";
728
+ const enteredToken = await promptHidden(rl, tokenPrompt);
729
+ token = enteredToken.length > 0 ? enteredToken : currentToken ?? "";
730
+ if (token.length === 0) {
731
+ throw new CliError("Token is required.", 2);
732
+ }
733
+ const http = createHttpClient({
734
+ baseUrl,
735
+ token,
736
+ version: getCliVersion()
737
+ });
738
+ let projects = [];
739
+ try {
740
+ const result = await http.get("/api/projects");
741
+ projects = Array.isArray(result.projects) ? result.projects : [];
742
+ } catch (err) {
743
+ if (err instanceof CliError && err.exitCode === 3) {
744
+ throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
745
+ }
746
+ throw err;
747
+ }
748
+ let defaultProjectId;
749
+ let defaultProjectName;
750
+ if (projects.length > 0) {
751
+ process.stdout.write("\nProjects:\n");
752
+ projects.forEach((p, i) => {
753
+ process.stdout.write(` (${i + 1}) ${p.name} \u2014 ${p.id}
754
+ `);
755
+ });
756
+ } else {
757
+ process.stdout.write("\nNo projects yet.\n");
758
+ }
759
+ const pick = await rl.question("Default project number, 'c' to create new (or Enter to skip): ");
760
+ const trimmedPick = pick.trim().toLowerCase();
761
+ if (trimmedPick === "c") {
762
+ const name = await rl.question("New project name: ");
763
+ if (name.trim().length === 0) {
764
+ throw new CliError("Project name is required.", 2);
765
+ }
766
+ const created = await http.post(
767
+ "/api/projects",
768
+ { name: name.trim() }
769
+ );
770
+ if (created.project?.id == null) {
771
+ throw new CliError("Project API did not return a project id.", 2);
772
+ }
773
+ defaultProjectId = created.project.id;
774
+ defaultProjectName = created.project.name ?? name.trim();
775
+ process.stdout.write(`Created project ${defaultProjectName} (${defaultProjectId})
776
+ `);
777
+ } else {
778
+ const num = parseInt(trimmedPick, 10);
779
+ if (!isNaN(num) && num >= 1 && num <= projects.length) {
780
+ defaultProjectId = projects[num - 1].id;
781
+ defaultProjectName = projects[num - 1].name;
782
+ }
783
+ }
784
+ await saveConfigFile(configPath, {
785
+ token,
786
+ baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
787
+ defaultProjectId
788
+ });
789
+ const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
790
+ process.stdout.write(`
791
+ Saved ${configPath}
792
+ `);
793
+ process.stdout.write(`baseUrl: ${baseUrl}
794
+ `);
795
+ process.stdout.write(`token: ${maskToken(token)} (verified)
796
+ `);
797
+ process.stdout.write(`defaultProject: ${projectDisplay}
798
+ `);
799
+ if (defaultProjectId != null) {
800
+ const startDir = resolveStartDir(process.env, process.cwd());
801
+ const { root, foundGit } = findRepoRoot(startDir);
802
+ if (foundGit) {
803
+ const setup = await rl.question(`
804
+ Set up this repository (${root}) for roadmap sync? [Y/n] `);
805
+ if (setup.trim().toLowerCase() !== "n") {
806
+ await writeRepoBinding(root, defaultProjectId);
807
+ process.stdout.write(`binding: wrote ${path6.join(root, "nolto.json")}
808
+ `);
809
+ const sourceDir = resolveSkillSourceDir();
810
+ const version = getCliVersion();
811
+ const claudeInstall = await installSkill({
812
+ skillsParentDir: path6.join(root, ".claude", "skills"),
813
+ sourceDir,
814
+ version
815
+ });
816
+ process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
817
+ `);
818
+ if (fs.existsSync(path6.join(root, ".agents")) || fs.existsSync(path6.join(root, ".codex"))) {
819
+ const agentsInstall = await installSkill({
820
+ skillsParentDir: path6.join(root, ".agents", "skills"),
821
+ sourceDir,
822
+ version
823
+ });
824
+ process.stdout.write(`skill (agents): ${agentsInstall.action} ${agentsInstall.targetDir}
825
+ `);
826
+ }
827
+ const scaffold = await scaffoldRoadmap({
828
+ repoRoot: root,
829
+ projectName: defaultProjectName ?? path6.basename(root)
830
+ });
831
+ process.stdout.write(
832
+ scaffold.created ? `roadmap: created ${scaffold.path}
833
+ ` : `roadmap: exists ${scaffold.path}
834
+ `
835
+ );
836
+ const registryResult = await addRepoToRegistry(getRegistryPath(process.env), root);
837
+ process.stdout.write(
838
+ registryResult.added ? `watch registry: added ${root}
839
+ ` : `watch registry: already registered
840
+ `
841
+ );
842
+ }
843
+ } else {
844
+ process.stdout.write("\nNo git repository found here \u2014 skipped repo setup. Run `nolto init` inside a repo to set up roadmap sync.\n");
845
+ }
846
+ }
847
+ } finally {
848
+ rl.close();
849
+ }
850
+ });
851
+ }
852
+ async function promptHidden(rl, prompt) {
853
+ const iface = rl;
854
+ const originalWrite = iface._writeToOutput?.bind(rl);
855
+ let muted = false;
856
+ iface._writeToOutput = (str) => {
857
+ if (muted) {
858
+ if (str !== "\r\n" && str !== "\n" && str !== "\r" && !str.startsWith("\x1B")) {
859
+ process.stdout.write("*");
860
+ } else {
861
+ originalWrite?.(str);
862
+ }
863
+ return;
864
+ }
865
+ originalWrite?.(str);
866
+ };
867
+ muted = true;
868
+ const value = await rl.question(prompt);
869
+ muted = false;
870
+ process.stdout.write("\n");
871
+ iface._writeToOutput = originalWrite;
872
+ return value;
873
+ }
874
+
875
+ // src/commands/login.ts
876
+ import readline2 from "readline/promises";
877
+
878
+ // src/login-poll.ts
879
+ async function pollUntilToken(opts) {
880
+ const { http, deviceCode, expiresIn, baseUrl } = opts;
881
+ let intervalSeconds = opts.intervalSeconds;
882
+ const deadline = Date.now() + expiresIn * 1e3;
883
+ await sleep(intervalSeconds * 1e3);
884
+ while (Date.now() < deadline) {
885
+ let resp;
886
+ try {
887
+ resp = await http.post("/api/cli/auth/poll", {
888
+ device_code: deviceCode
889
+ });
890
+ } catch (err) {
891
+ if (err instanceof CliError && err.exitCode === 4) {
892
+ await sleep(intervalSeconds * 1e3);
893
+ continue;
894
+ }
895
+ throw err;
896
+ }
897
+ if (!resp.error) {
898
+ if (!resp.token) {
899
+ throw new CliError("Server returned success but no token.", 5);
900
+ }
901
+ return resp.token;
902
+ }
903
+ switch (resp.error) {
904
+ case "authorization_pending":
905
+ await sleep(intervalSeconds * 1e3);
906
+ break;
907
+ case "slow_down":
908
+ intervalSeconds += 5;
909
+ await sleep(intervalSeconds * 1e3);
910
+ break;
911
+ case "expired_token":
912
+ throw new CliError("Device code expired. Run `nolto login` again.", 2);
913
+ case "access_denied":
914
+ throw new CliError("\u30ED\u30B0\u30A4\u30F3\u304C\u62D2\u5426\u3055\u308C\u307E\u3057\u305F\u3002", 3);
915
+ case "token_cap_exceeded":
916
+ throw new CliError(
917
+ `\u30A2\u30AF\u30C6\u30A3\u30D6\u306A API token \u304C\u4E0A\u9650(20)\u3067\u3059\u3002${baseUrl}/settings/tokens \u3067\u4E0D\u8981\u306A\u30C8\u30FC\u30AF\u30F3\u3092\u5931\u52B9\u3057\u3066\u304B\u3089\u518D\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
918
+ 2
919
+ );
920
+ default:
921
+ throw new CliError(`Unexpected poll response: ${resp.error}`, 5);
922
+ }
923
+ }
924
+ throw new CliError("Login timed out. Run `nolto login` again.", 2);
925
+ }
926
+ function sleep(ms) {
927
+ return new Promise((resolve) => setTimeout(resolve, ms));
928
+ }
929
+
930
+ // src/commands/login.ts
931
+ function register3(program, deps) {
932
+ program.command("login").description("Authenticate via browser and save an API token to the config file.").option("--force", "Overwrite existing token without prompting").action(
933
+ async (opts) => {
934
+ const { configPath, settings, output } = deps;
935
+ if (!opts.force) {
936
+ let existing = null;
937
+ try {
938
+ existing = await loadConfigFile(configPath);
939
+ } catch {
940
+ }
819
941
  if (existing?.token) {
820
942
  const rl = readline2.createInterface({
821
943
  input: process.stdin,
@@ -883,103 +1005,25 @@ function register2(program, deps) {
883
1005
  process.stdout.write(`token: ${maskToken(token)}
884
1006
  `);
885
1007
  }
886
- if (clientTarget) {
887
- try {
888
- await injectClient({
889
- client: clientTarget,
890
- token,
891
- baseUrl: settings.baseUrl
892
- });
893
- } catch (err) {
894
- const msg = err instanceof Error ? err.message : String(err);
895
- process.stderr.write(
896
- `Warning: failed to inject into ${clientTarget} config: ${msg}
897
- `
898
- );
899
- }
900
- }
901
1008
  }
902
1009
  );
903
1010
  }
904
1011
 
905
- // src/output.ts
906
- function printResult(value, mode2, opts = {}) {
907
- const out = opts.stream ?? process.stdout;
908
- if (mode2 === "json") {
909
- out.write(JSON.stringify(value, null, 2) + "\n");
910
- } else {
911
- out.write(formatValue(value) + "\n");
912
- }
913
- }
914
- function printError(err, mode2) {
915
- if (mode2 === "json") {
916
- const envelope = {
917
- error: {
918
- message: err.message,
919
- exitCode: err.exitCode,
920
- ...err.status != null ? { status: err.status } : {},
921
- ...err.hint != null ? { hint: err.hint } : {}
922
- }
923
- };
924
- process.stderr.write(JSON.stringify(envelope, null, 2) + "\n");
925
- } else {
926
- process.stderr.write(`Error: ${err.message}
927
- `);
928
- if (err.hint != null) {
929
- process.stderr.write(`Hint: ${err.hint}
930
- `);
931
- }
932
- }
933
- }
934
- function formatTable(rows, columns) {
935
- if (rows.length === 0) {
936
- return "(empty)";
937
- }
938
- const maxKeyLen = columns.reduce((m, c) => Math.max(m, c.length), 0);
939
- return rows.map(
940
- (row) => columns.map((col) => {
941
- const val = row[col] ?? "";
942
- return ` ${col.padEnd(maxKeyLen)}: ${val}`;
943
- }).join("\n")
944
- ).join("\n\n");
945
- }
946
- function formatRecord(record) {
947
- const keys = Object.keys(record);
948
- const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
949
- return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${record[k] ?? ""}`).join("\n");
950
- }
951
- function formatValue(value) {
952
- if (value === null || value === void 0) {
953
- return "";
954
- }
955
- if (typeof value === "string") {
956
- return value;
957
- }
958
- if (typeof value === "number" || typeof value === "boolean") {
959
- return String(value);
960
- }
961
- if (Array.isArray(value)) {
962
- return value.map((v) => formatValue(v)).join("\n");
963
- }
964
- if (typeof value === "object") {
965
- const obj = value;
966
- const keys = Object.keys(obj);
967
- const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
968
- return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${formatValue(obj[k])}`).join("\n");
969
- }
970
- return JSON.stringify(value, null, 2);
971
- }
972
-
973
1012
  // src/commands/whoami.ts
974
- function register3(program, deps) {
1013
+ function register4(program, deps) {
975
1014
  program.command("whoami").description("Show the current authentication and configuration state.").action(async () => {
976
1015
  const { settings, output, configPath } = deps;
977
1016
  const mode2 = output.mode;
978
1017
  let projectCount;
979
1018
  if (settings.token != null) {
980
1019
  try {
981
- const result = await deps.caller.call("list_projects", {});
982
- projectCount = unwrapList(result, "projects").length;
1020
+ const http = deps.http ?? createHttpClient({
1021
+ baseUrl: settings.baseUrl,
1022
+ token: settings.token,
1023
+ version: deps.version
1024
+ });
1025
+ const result = await http.get("/api/projects");
1026
+ projectCount = Array.isArray(result.projects) ? result.projects.length : 0;
983
1027
  } catch {
984
1028
  }
985
1029
  }
@@ -1018,1087 +1062,469 @@ function register3(program, deps) {
1018
1062
  });
1019
1063
  }
1020
1064
 
1021
- // src/commands/project.ts
1022
- function assertToken(token) {
1023
- if (token == null) {
1024
- throw new CliError(
1025
- "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
1026
- 3
1027
- );
1028
- }
1029
- }
1030
- function register4(program, deps) {
1031
- const project = program.command("project").description("Manage projects.");
1032
- project.command("list").description("List all projects.").action(async () => {
1033
- assertToken(deps.settings.token);
1034
- const result = await deps.caller.call("list_projects", {});
1035
- if (deps.output.mode === "json") {
1036
- printResult(result, "json");
1037
- return;
1038
- }
1039
- const rows = unwrapList(result, "projects");
1040
- const defaultId = deps.settings.defaultProjectId;
1041
- const tableRows = rows.map((p) => ({
1042
- id: p.id ?? "",
1043
- name: p.name ?? "",
1044
- role: p.role ?? "",
1045
- default: p.id === defaultId ? "yes" : ""
1046
- }));
1047
- process.stdout.write(formatTable(tableRows, ["id", "name", "role", "default"]) + "\n");
1048
- });
1049
- project.command("register <name>").description("Register a new project.").option("--description <text>", "Project description").option("--repository-url <url>", "Repository URL").action(async (name, opts) => {
1050
- assertToken(deps.settings.token);
1051
- const args = { name };
1052
- if (opts.description != null) args["description"] = opts.description;
1053
- if (opts.repositoryUrl != null) args["repositoryUrl"] = opts.repositoryUrl;
1054
- const result = await deps.caller.call("register_project", args);
1055
- if (deps.output.mode === "json") {
1056
- printResult(result, "json");
1057
- return;
1058
- }
1059
- const r = result;
1060
- const id = r?.id ?? "";
1061
- const registeredName = r?.name ?? name;
1062
- process.stdout.write(`Registered ${registeredName} (${id})
1063
- `);
1064
- });
1065
- project.command("set-default <projectId>").description("Set the default project.").option("--local", "Write to local config file only (no MCP call)").action(async (projectId, opts) => {
1066
- if (opts.local) {
1067
- const existing = await loadConfigFile(deps.configPath).catch(() => null);
1068
- await saveConfigFile(deps.configPath, { ...existing ?? {}, defaultProjectId: projectId });
1069
- } else {
1070
- assertToken(deps.settings.token);
1071
- await deps.caller.call("set_default_project", { projectId });
1072
- }
1073
- process.stdout.write(`Default project set to ${projectId}.
1074
- `);
1075
- });
1076
- }
1077
-
1078
- // src/commands/plan-register.ts
1079
- import { execFile as execFile2 } from "child_process";
1080
- import { promisify as promisify2 } from "util";
1081
- import { z as z2 } from "zod";
1065
+ // src/commands/sync.ts
1066
+ import { readFile as readFile4 } from "fs/promises";
1067
+ import { existsSync as existsSync3 } from "fs";
1082
1068
 
1083
- // src/fsx.ts
1084
- import { readFile as readFile3, stat as stat2 } from "fs/promises";
1085
- import path4 from "path";
1086
- async function readPlanFile(filePath) {
1087
- const absPath = path4.resolve(filePath);
1088
- let content;
1089
- try {
1090
- content = await readFile3(absPath, "utf8");
1091
- } catch (err) {
1092
- const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
1093
- if (code === "ENOENT") {
1094
- throw new CliError(`File not found: ${absPath}`, 2);
1095
- }
1096
- throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
1097
- }
1098
- const trimmed = content.trim();
1099
- if (trimmed.length === 0) {
1100
- throw new CliError(`Plan file is empty: ${absPath}`, 2);
1101
- }
1102
- if (trimmed.length > PLAN_CONTENT_MAX) {
1103
- throw new CliError(
1104
- `Plan file exceeds ${PLAN_CONTENT_MAX} characters (${trimmed.length}): ${absPath}`,
1105
- 2
1106
- );
1107
- }
1108
- const stem = path4.basename(absPath, path4.extname(absPath));
1109
- return { content: trimmed, sourcePath: absPath, titleFallback: stem };
1110
- }
1111
- async function readDocFile(filePath) {
1112
- const absPath = path4.resolve(filePath);
1113
- let fileSize;
1114
- try {
1115
- const info = await stat2(absPath);
1116
- fileSize = info.size;
1117
- } catch (err) {
1118
- const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
1119
- if (code === "ENOENT") {
1120
- throw new CliError(`File not found: ${absPath}`, 2);
1121
- }
1122
- throw new CliError(`Cannot stat file: ${absPath}: ${String(err)}`, 2);
1123
- }
1124
- if (fileSize > DOCUMENT_MAX_BYTES) {
1125
- throw new CliError(`Document exceeds 2 MiB: ${absPath}`, 2);
1126
- }
1127
- let buf;
1128
- try {
1129
- buf = await readFile3(absPath);
1130
- } catch (err) {
1131
- throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
1132
- }
1133
- const isBinary = buf.includes(0) || !isUtf8RoundTrip(buf);
1134
- const rawFilename = path4.basename(absPath);
1135
- const filename = rawFilename.slice(0, DOCUMENT_FILENAME_MAX);
1136
- if (isBinary) {
1137
- return { content: buf.toString("base64"), encoding: "base64", filename };
1138
- }
1139
- return { content: buf.toString("utf8"), encoding: "utf8", filename };
1140
- }
1141
- function isUtf8RoundTrip(buf) {
1142
- try {
1143
- const str = buf.toString("utf8");
1144
- const reEncoded = Buffer.from(str, "utf8");
1145
- if (reEncoded.length !== buf.length) {
1146
- return false;
1147
- }
1148
- for (let i = 0; i < buf.length; i++) {
1149
- if (buf[i] !== reEncoded[i]) {
1150
- return false;
1151
- }
1152
- }
1153
- return true;
1154
- } catch {
1155
- return false;
1156
- }
1157
- }
1158
-
1159
- // src/markdown.ts
1160
- function extractTitle(md) {
1161
- const lines = md.split("\n");
1162
- let inFence = false;
1163
- let fenceChar = "";
1164
- for (const line of lines) {
1165
- const trimmed = line.trimStart();
1166
- if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
1167
- const ch = trimmed[0];
1168
- if (!inFence) {
1169
- inFence = true;
1170
- fenceChar = ch;
1171
- } else if (ch === fenceChar) {
1172
- inFence = false;
1173
- fenceChar = "";
1174
- }
1175
- continue;
1176
- }
1177
- if (inFence) {
1178
- continue;
1179
- }
1180
- if (trimmed.startsWith("# ")) {
1181
- const title = trimmed.slice(2).trim();
1182
- if (title.length > 0) {
1183
- return title;
1184
- }
1185
- }
1186
- }
1187
- return null;
1188
- }
1069
+ // src/sync-repo.ts
1070
+ import path8 from "path";
1189
1071
 
1190
- // src/commands/plan-register.ts
1191
- var execFileAsync = promisify2(execFile2);
1192
- function assertToken2(token) {
1193
- if (token == null) {
1194
- throw new CliError(
1195
- "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
1196
- 3
1197
- );
1198
- }
1199
- }
1200
- var phaseArraySchema = z2.array(
1201
- z2.object({
1202
- title: z2.string().min(1),
1203
- content: z2.string().min(1),
1204
- status: z2.string().optional(),
1205
- plannedStartAt: z2.string().optional(),
1206
- plannedEndAt: z2.string().optional()
1207
- })
1208
- ).max(PHASES_MAX);
1209
- function collect(val, prev) {
1210
- return [...prev, val];
1211
- }
1212
- function resolveUrl(detailUrl, baseUrl) {
1213
- if (detailUrl == null) return "";
1214
- try {
1215
- return new URL(detailUrl, baseUrl).href;
1216
- } catch {
1217
- return detailUrl;
1218
- }
1219
- }
1220
- async function getGitContributor() {
1221
- const result = {};
1222
- try {
1223
- const { stdout: name } = await execFileAsync("git", ["config", "user.name"], { timeout: 3e3 });
1224
- const trimmed = name.trim();
1225
- if (trimmed.length > 0) result["userName"] = trimmed;
1226
- } catch {
1227
- }
1228
- try {
1229
- const { stdout: email } = await execFileAsync("git", ["config", "user.email"], { timeout: 3e3 });
1230
- const trimmed = email.trim();
1231
- if (trimmed.length > 0) result["userEmail"] = trimmed;
1232
- } catch {
1233
- }
1234
- return result;
1235
- }
1236
- function registerPlanRegisterSubcommand(plan, deps) {
1237
- plan.command("register").description("Register a new plan from a file.").requiredOption("--file <path>", "Path to plan markdown file").option("--title <text>", "Plan title (default: first # heading or filename)").option("--status <status>", "Initial status").option("--planned-start <ISO>", "Planned start date (ISO 8601)").option("--planned-end <ISO>", "Planned end date (ISO 8601)").option("--phases <json>", "Phases JSON array").option("--doc <kind=path>", "Attach a document (repeatable; e.g. --doc plan=report.md)", collect, []).option("--source-url <url>", "Source URL").option("--source-hash <hex>", "Source content hash").option("--no-git", "Skip git author detection").action(async (opts) => {
1238
- assertToken2(deps.settings.token);
1239
- if (opts.status != null && !PLAN_STATUSES.includes(opts.status)) {
1240
- throw new CliError(
1241
- `Invalid status "${opts.status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1242
- 2
1243
- );
1244
- }
1245
- const { content, sourcePath, titleFallback } = await readPlanFile(opts.file);
1246
- const title = opts.title ?? extractTitle(content) ?? titleFallback;
1247
- if (title.length > PLAN_TITLE_MAX) {
1248
- throw new CliError(
1249
- `Plan title is too long (${title.length} chars, max ${PLAN_TITLE_MAX}).`,
1250
- 2
1251
- );
1252
- }
1253
- let parsedPhases;
1254
- if (opts.phases != null) {
1255
- try {
1256
- parsedPhases = JSON.parse(opts.phases);
1257
- } catch (err) {
1258
- throw new CliError(
1259
- `Invalid --phases JSON: ${err instanceof Error ? err.message : String(err)}`,
1260
- 2
1261
- );
1262
- }
1263
- const phaseResult = phaseArraySchema.safeParse(parsedPhases);
1264
- if (!phaseResult.success) {
1265
- const issue = phaseResult.error.issues[0];
1266
- if (issue?.code === "too_big" && issue.path.length === 0) {
1267
- throw new CliError(`Too many phases (max ${PHASES_MAX}).`, 2);
1268
- }
1269
- const fieldPath = issue?.path.join(".") ?? "";
1270
- const fieldDesc = fieldPath.length > 0 ? `field "${fieldPath}"` : "unknown field";
1271
- throw new CliError(`Invalid --phases JSON: ${fieldDesc}`, 2);
1272
- }
1273
- parsedPhases = phaseResult.data;
1274
- }
1275
- if (opts.doc.length > 10) {
1276
- throw new CliError("Too many documents (max 10).", 2);
1277
- }
1278
- const documents = [];
1279
- for (const docSpec of opts.doc) {
1280
- const match = /^([a-z_]+)=(.+)$/.exec(docSpec);
1281
- if (match == null) {
1282
- throw new CliError(`Invalid --doc format "${docSpec}". Expected kind=path.`, 2);
1283
- }
1284
- const kind = match[1];
1285
- const docPath = match[2];
1286
- if (!PLAN_DOCUMENT_KINDS.includes(kind)) {
1287
- throw new CliError(
1288
- `Invalid document kind "${kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
1289
- 2
1290
- );
1291
- }
1292
- const { content: docContent, encoding, filename } = await readDocFile(docPath);
1293
- documents.push({ kind, filename, content: docContent, encoding });
1294
- }
1295
- const source = { kind: "file", path: sourcePath };
1296
- if (opts.sourceUrl != null) source["url"] = opts.sourceUrl;
1297
- if (opts.sourceHash != null) source["hash"] = opts.sourceHash;
1298
- const git = opts.git !== false ? await getGitContributor() : {};
1299
- const planPayload = {
1300
- title,
1301
- content,
1302
- ...opts.status != null ? { status: opts.status } : {},
1303
- ...opts.plannedStart != null ? { plannedStartAt: opts.plannedStart } : {},
1304
- ...opts.plannedEnd != null ? { plannedEndAt: opts.plannedEnd } : {},
1305
- ...parsedPhases != null ? { phases: parsedPhases } : {},
1306
- ...documents.length > 0 ? { documents } : {}
1307
- };
1308
- const mcpArgs = { plan: planPayload, source, git };
1309
- if (deps.settings.defaultProjectId != null) {
1310
- mcpArgs["projectId"] = deps.settings.defaultProjectId;
1311
- }
1312
- const result = await deps.caller.call("register_plan", mcpArgs);
1313
- if (deps.output.mode === "json") {
1314
- printResult(result, "json");
1315
- return;
1316
- }
1317
- const r = result;
1318
- const detailUrl = resolveUrl(r?.detailUrl, deps.settings.baseUrl);
1319
- process.stdout.write(
1320
- formatRecord({
1321
- planId: r?.planId ?? "",
1322
- transformStatus: r?.transformStatus ?? "",
1323
- url: detailUrl
1324
- }) + "\n"
1325
- );
1326
- });
1327
- }
1072
+ // src/sync-core.ts
1073
+ import { createHash } from "crypto";
1074
+ import path7 from "path";
1328
1075
 
1329
- // src/commands/plan.ts
1330
- function assertToken3(token) {
1331
- if (token == null) {
1332
- throw new CliError(
1333
- "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
1334
- 3
1335
- );
1336
- }
1337
- }
1338
- function register5(program, deps) {
1339
- const plan = program.command("plan").description("Manage plans.");
1340
- plan.command("list").description("List plans.").option("--status <status>", "Filter by status").action(async (opts) => {
1341
- assertToken3(deps.settings.token);
1342
- if (opts.status != null && !PLAN_STATUSES.includes(opts.status)) {
1343
- throw new CliError(
1344
- `Invalid status "${opts.status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1345
- 2
1346
- );
1347
- }
1348
- const args = {};
1349
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1350
- if (opts.status != null) args["status"] = opts.status;
1351
- const result = await deps.caller.call("list_plans", args);
1352
- if (deps.output.mode === "json") {
1353
- printResult(result, "json");
1354
- return;
1355
- }
1356
- const rows = unwrapList(result, "plans");
1357
- const tableRows = rows.map((p) => ({
1358
- id: p.id ?? "",
1359
- title: p.display_title ?? p.raw_title ?? p.title ?? "",
1360
- status: p.status ?? "",
1361
- createdAt: p.created_at ?? p.createdAt ?? ""
1362
- }));
1363
- process.stdout.write(formatTable(tableRows, ["id", "title", "status", "createdAt"]) + "\n");
1364
- });
1365
- plan.command("get <planId>").description("Get plan details.").action(async (planId) => {
1366
- assertToken3(deps.settings.token);
1367
- const args = { planId };
1368
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1369
- const result = await deps.caller.call("get_plan", args);
1370
- if (deps.output.mode === "json") {
1371
- printResult(result, "json");
1372
- return;
1373
- }
1374
- printResult(result, "human");
1375
- });
1376
- registerPlanRegisterSubcommand(plan, deps);
1377
- plan.command("status <planId> <status>").description("Update plan status.").option("--message <text>", "Optional message").action(async (planId, status, opts) => {
1378
- assertToken3(deps.settings.token);
1379
- if (!PLAN_STATUSES.includes(status)) {
1380
- throw new CliError(
1381
- `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1382
- 2
1383
- );
1384
- }
1385
- const args = { planId, status };
1386
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1387
- if (opts.message != null) args["message"] = opts.message;
1388
- const result = await deps.caller.call("update_plan_status", args);
1389
- if (deps.output.mode === "json") {
1390
- printResult(result, "json");
1391
- return;
1392
- }
1393
- process.stdout.write(`Plan ${planId} \u2192 ${status}.
1394
- `);
1395
- });
1396
- plan.command("review <planId> <verdict>").description("Record a plan review.").option("--summary <text>", "Review summary").action(async (planId, verdict, opts) => {
1397
- assertToken3(deps.settings.token);
1398
- if (!REVIEW_VERDICTS.includes(verdict)) {
1399
- throw new CliError(
1400
- `Invalid verdict "${verdict}". Valid values: ${REVIEW_VERDICTS.join(", ")}`,
1401
- 2
1402
- );
1403
- }
1404
- const args = { planId, verdict };
1405
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1406
- if (opts.summary != null) args["summary"] = opts.summary;
1407
- const result = await deps.caller.call("record_plan_review", args);
1408
- if (deps.output.mode === "json") {
1409
- printResult(result, "json");
1410
- return;
1411
- }
1412
- process.stdout.write(`Review recorded: ${verdict}.
1413
- `);
1414
- });
1076
+ // ../roadmap-schema/src/index.ts
1077
+ var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
1078
+ var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
1079
+ var ALLOWED_KEYS = {
1080
+ roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
1081
+ project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
1082
+ phase: /* @__PURE__ */ new Set(["id", "title", "status", "plan", "tasks"]),
1083
+ task: /* @__PURE__ */ new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
1084
+ };
1085
+ function isRecord(value) {
1086
+ return value != null && typeof value === "object" && !Array.isArray(value);
1087
+ }
1088
+ function checkKeys(value, allowed, at, errors) {
1089
+ if (!isRecord(value)) return;
1090
+ for (const key of Object.keys(value)) {
1091
+ if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
1092
+ }
1093
+ }
1094
+ function checkPlan(value, at, errors) {
1095
+ if (value === void 0) return;
1096
+ if (typeof value !== "string" || value.length === 0) {
1097
+ errors.push(`${at}.plan must be a non-empty string.`);
1098
+ }
1099
+ }
1100
+ function derivePhaseStatus(phase) {
1101
+ if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
1102
+ if (phase.tasks.some((task) => task.status === "in-progress")) return "in-progress";
1103
+ if (phase.tasks.some((task) => task.status === "blocked")) return "blocked";
1104
+ if (phase.tasks.some((task) => task.status === "done")) return "in-progress";
1105
+ return "todo";
1106
+ }
1107
+ function validateRoadmap(value) {
1108
+ const errors = [];
1109
+ const warnings = [];
1110
+ if (!isRecord(value)) return { errors: ["Root must be an object."], warnings };
1111
+ checkKeys(value, ALLOWED_KEYS.roadmap, "roadmap", errors);
1112
+ if (value["schemaVersion"] !== 1 && value["schemaVersion"] !== 2) {
1113
+ errors.push("schemaVersion must be 1 or 2.");
1114
+ } else if (value["schemaVersion"] === 1) {
1115
+ warnings.push("schemaVersion 1 is legacy; the next roadmap-progress mutation migrates this file to 2.");
1116
+ }
1117
+ const project = value["project"];
1118
+ checkKeys(project, ALLOWED_KEYS.project, "project", errors);
1119
+ const projectRecord = isRecord(project) ? project : {};
1120
+ if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
1121
+ if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
1122
+ if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
1123
+ if (Number.isNaN(Date.parse(String(value["updatedAt"])))) errors.push("updatedAt must be a valid date-time.");
1124
+ if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
1125
+ if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
1126
+ const allIds = /* @__PURE__ */ new Set();
1127
+ const tasks = /* @__PURE__ */ new Map();
1128
+ const phases = Array.isArray(value["phases"]) ? value["phases"] : [];
1129
+ for (const [phaseIndex, rawPhase] of phases.entries()) {
1130
+ const at = `phases[${phaseIndex}]`;
1131
+ checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
1132
+ const phase = isRecord(rawPhase) ? rawPhase : {};
1133
+ const phaseId = String(phase["id"] ?? "");
1134
+ if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
1135
+ if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
1136
+ allIds.add(phaseId);
1137
+ if (phase["title"] == null || phase["title"] === "") errors.push(`${at}.title is required.`);
1138
+ if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
1139
+ checkPlan(phase["plan"], at, errors);
1140
+ if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
1141
+ const rawTasks = Array.isArray(phase["tasks"]) ? phase["tasks"] : [];
1142
+ for (const [taskIndex, rawTask] of rawTasks.entries()) {
1143
+ const taskAt = `${at}.tasks[${taskIndex}]`;
1144
+ checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
1145
+ const task = isRecord(rawTask) ? rawTask : {};
1146
+ const taskId = String(task["id"] ?? "");
1147
+ if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
1148
+ if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
1149
+ allIds.add(taskId);
1150
+ tasks.set(taskId, task);
1151
+ if (task["title"] == null || task["title"] === "") errors.push(`${taskAt}.title is required.`);
1152
+ if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
1153
+ checkPlan(task["plan"], taskAt, errors);
1154
+ if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
1155
+ if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
1156
+ if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) errors.push(`${taskAt}.dependsOn must be an array.`);
1157
+ }
1158
+ if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
1159
+ const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
1160
+ if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
1161
+ }
1162
+ }
1163
+ for (const task of tasks.values()) {
1164
+ for (const dependency of task.dependsOn ?? []) {
1165
+ if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
1166
+ if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
1167
+ }
1168
+ }
1169
+ const currentTaskId = value["currentTaskId"];
1170
+ if (currentTaskId !== null && currentTaskId !== void 0) {
1171
+ const current = tasks.get(String(currentTaskId));
1172
+ if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
1173
+ else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
1174
+ }
1175
+ return { errors, warnings };
1176
+ }
1177
+ function deriveTaskStats(roadmap) {
1178
+ let done = 0;
1179
+ let blocked = 0;
1180
+ let inProgress = 0;
1181
+ let todo = 0;
1182
+ for (const phase of roadmap.phases) {
1183
+ for (const task of phase.tasks) {
1184
+ if (task.status === "done") done += 1;
1185
+ else if (task.status === "blocked") blocked += 1;
1186
+ else if (task.status === "in-progress") inProgress += 1;
1187
+ else todo += 1;
1188
+ }
1189
+ }
1190
+ const total = done + blocked + inProgress + todo;
1191
+ return {
1192
+ total,
1193
+ done,
1194
+ blocked,
1195
+ inProgress,
1196
+ todo,
1197
+ progressPct: total > 0 ? Math.round(done / total * 100) : 0
1198
+ };
1415
1199
  }
1416
1200
 
1417
- // src/commands/phase.ts
1418
- function assertToken4(token) {
1419
- if (token == null) {
1420
- throw new CliError(
1421
- "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
1422
- 3
1423
- );
1424
- }
1425
- }
1426
- function register6(program, deps) {
1427
- const phase = program.command("phase").description("Manage plan phases.");
1428
- phase.command("status <planId> <phaseId> <status>").description("Update a phase status.").option("--message <text>", "Optional message").action(async (planId, phaseId, status, opts) => {
1429
- assertToken4(deps.settings.token);
1430
- if (!PLAN_STATUSES.includes(status)) {
1431
- throw new CliError(
1432
- `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1433
- 2
1434
- );
1435
- }
1436
- const args = {
1437
- planId,
1438
- phaseId,
1439
- status
1440
- };
1441
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1442
- if (opts.message != null) args["message"] = opts.message;
1443
- const result = await deps.caller.call("update_phase_status", args);
1444
- if (deps.output.mode === "json") {
1445
- printResult(result, "json");
1446
- return;
1447
- }
1448
- const r = result;
1449
- const planStatus = r?.planStatus ?? "";
1450
- process.stdout.write(`Phase ${phaseId} \u2192 ${status} (plan now ${planStatus}).
1451
- `);
1452
- });
1453
- phase.command("test <planId> <phaseId> <verdict>").description("Record a phase test result.").option("--round <n>", "Test round number (positive integer)").option("--summary <text>", "Test summary").action(async (planId, phaseId, verdict, opts) => {
1454
- assertToken4(deps.settings.token);
1455
- if (!TEST_VERDICTS.includes(verdict)) {
1456
- throw new CliError(
1457
- `Invalid verdict "${verdict}". Valid values: ${TEST_VERDICTS.join(", ")}`,
1458
- 2
1459
- );
1460
- }
1461
- let round;
1462
- if (opts.round != null) {
1463
- if (!/^[1-9]\d*$/.test(opts.round)) {
1464
- throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
1465
- }
1466
- const parsed = Number(opts.round);
1467
- if (!Number.isInteger(parsed) || parsed < 1) {
1468
- throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
1201
+ // src/sync-core.ts
1202
+ function sha256Hex(content) {
1203
+ return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
1204
+ }
1205
+ function collectPlanRefs(roadmap) {
1206
+ const seen = /* @__PURE__ */ new Set();
1207
+ const refs = [];
1208
+ for (const phase of roadmap.phases) {
1209
+ if (phase.plan != null && !seen.has(phase.plan)) {
1210
+ seen.add(phase.plan);
1211
+ refs.push({ path: phase.plan, ownerId: phase.id });
1212
+ }
1213
+ for (const task of phase.tasks) {
1214
+ if (task.plan != null && !seen.has(task.plan)) {
1215
+ seen.add(task.plan);
1216
+ refs.push({ path: task.plan, ownerId: task.id });
1469
1217
  }
1470
- round = parsed;
1471
- }
1472
- const args = {
1473
- planId,
1474
- phaseId,
1475
- verdict
1476
- };
1477
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1478
- if (round != null) args["round"] = round;
1479
- if (opts.summary != null) args["summary"] = opts.summary;
1480
- const result = await deps.caller.call("record_phase_test_result", args);
1481
- if (deps.output.mode === "json") {
1482
- printResult(result, "json");
1483
- return;
1484
- }
1485
- const roundDisplay = round != null ? String(round) : "\u2014";
1486
- process.stdout.write(`Recorded ${verdict} (round ${roundDisplay}).
1487
- `);
1488
- });
1489
- }
1490
-
1491
- // src/commands/doc.ts
1492
- function assertToken5(token) {
1493
- if (token == null) {
1494
- throw new CliError(
1495
- "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
1496
- 3
1497
- );
1498
- }
1499
- }
1500
- function register7(program, deps) {
1501
- const doc = program.command("doc").description("Manage plan documents.");
1502
- doc.command("upload <planId>").description("Upload a document to a plan.").requiredOption("--file <path>", "Path to document file").requiredOption("--kind <kind>", `Document kind (${PLAN_DOCUMENT_KINDS.join(", ")})`).option("--phase <phaseId>", "Associate with a specific phase").option("--filename <name>", "Override filename").action(async (planId, opts) => {
1503
- assertToken5(deps.settings.token);
1504
- if (!PLAN_DOCUMENT_KINDS.includes(opts.kind)) {
1505
- throw new CliError(
1506
- `Invalid kind "${opts.kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
1507
- 2
1508
- );
1509
- }
1510
- const { content, encoding, filename: autoFilename } = await readDocFile(opts.file);
1511
- const filename = opts.filename != null ? opts.filename.slice(0, DOCUMENT_FILENAME_MAX) : autoFilename;
1512
- const args = {
1513
- planId,
1514
- kind: opts.kind,
1515
- filename,
1516
- content,
1517
- encoding
1518
- };
1519
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1520
- if (opts.phase != null) args["phaseId"] = opts.phase;
1521
- const result = await deps.caller.call("upload_plan_document", args);
1522
- if (deps.output.mode === "json") {
1523
- printResult(result, "json");
1524
- return;
1525
- }
1526
- const byteCount = encoding === "base64" ? Math.floor(content.length * 3 / 4) : Buffer.byteLength(content, "utf8");
1527
- process.stdout.write(`Uploaded ${filename} (${encoding}, ${byteCount} bytes).
1528
- `);
1529
- });
1530
- }
1531
-
1532
- // src/queue-file.ts
1533
- import { openSync, closeSync, writeFileSync, unlinkSync, renameSync, statSync as statSync2 } from "fs";
1534
- import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, unlink as unlink2, appendFile } from "fs/promises";
1535
- import path5 from "path";
1536
- import crypto from "crypto";
1537
- function queueFilePath(projectDir) {
1538
- return path5.join(projectDir, ".nolto", "pending.jsonl");
1539
- }
1540
- function lockFilePath(projectDir) {
1541
- return path5.join(projectDir, ".nolto", "flush.lock");
1542
- }
1543
- function logFilePath(projectDir) {
1544
- return path5.join(projectDir, ".nolto", "flush.log");
1545
- }
1546
- function resolveQueueDir(inputs) {
1547
- if (inputs.flagDir != null) return inputs.flagDir;
1548
- if (inputs.env["NOLTO_QUEUE_DIR"]) return inputs.env["NOLTO_QUEUE_DIR"];
1549
- if (inputs.env["CLAUDE_PROJECT_DIR"]) return inputs.env["CLAUDE_PROJECT_DIR"];
1550
- return findAncestorWithMarker(inputs.cwd);
1551
- }
1552
- function findAncestorWithMarker(startDir) {
1553
- let current = startDir;
1554
- while (true) {
1555
- if (hasMarkerSync(current, ".git") || hasMarkerSync(current, ".nolto")) {
1556
- return current;
1557
1218
  }
1558
- const parent = path5.dirname(current);
1559
- if (parent === current) break;
1560
- current = parent;
1561
- }
1562
- return startDir;
1563
- }
1564
- function hasMarkerSync(dir, marker) {
1565
- try {
1566
- statSync2(path5.join(dir, marker));
1567
- return true;
1568
- } catch {
1569
- return false;
1570
1219
  }
1220
+ return refs;
1571
1221
  }
1572
- async function readQueue(projectDir) {
1573
- const filePath = queueFilePath(projectDir);
1222
+ async function loadValidRoadmap(repoRoot, readFile7) {
1223
+ const filePath = path7.join(repoRoot, ".roadmap", "roadmap.json");
1574
1224
  let raw;
1575
1225
  try {
1576
- raw = await readFile4(filePath, "utf8");
1577
- } catch {
1578
- return [];
1579
- }
1580
- const entries = [];
1581
- for (const line of raw.split("\n")) {
1582
- const trimmed = line.trim();
1583
- if (!trimmed) continue;
1584
- try {
1585
- const obj = JSON.parse(trimmed);
1586
- entries.push(obj);
1587
- } catch {
1588
- await appendLog(projectDir, "warn", `malformed queue line skipped: ${trimmed.slice(0, 80)}`);
1589
- }
1590
- }
1591
- return entries;
1592
- }
1593
- async function appendEntry(projectDir, entry) {
1594
- const noltoDir = path5.join(projectDir, ".nolto");
1595
- await mkdir3(noltoDir, { recursive: true, mode: 448 });
1596
- const existing = await readQueue(projectDir);
1597
- if (existing.length >= QUEUE_MAX_ENTRIES) {
1598
- throw new CliError(
1599
- `Queue is full (${QUEUE_MAX_ENTRIES} entries). Flush before adding more.`,
1600
- 2
1601
- );
1602
- }
1603
- const newEntry = {
1604
- id: crypto.randomUUID(),
1605
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1606
- tool: entry.tool,
1607
- args: { ...entry.args }
1608
- };
1609
- const line = JSON.stringify(newEntry) + "\n";
1610
- try {
1611
- await appendFile(queueFilePath(projectDir), line, "utf8");
1612
- } catch (err) {
1613
- if (err instanceof CliError) throw err;
1614
- throw new CliError(
1615
- `Failed to write queue entry: ${err.message ?? String(err)}`,
1616
- 2
1617
- );
1618
- }
1619
- return newEntry;
1620
- }
1621
- async function atomicRewriteQueue(projectDir, entries) {
1622
- const filePath = queueFilePath(projectDir);
1623
- if (entries.length === 0) {
1624
- try {
1625
- await unlink2(filePath);
1626
- } catch (err) {
1627
- const code = err.code;
1628
- if (code !== "ENOENT") throw err;
1629
- }
1630
- return;
1631
- }
1632
- const noltoDir = path5.join(projectDir, ".nolto");
1633
- await mkdir3(noltoDir, { recursive: true });
1634
- const tmpPath = filePath + ".tmp";
1635
- const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
1636
- await writeFile3(tmpPath, content, "utf8");
1637
- renameSync(tmpPath, filePath);
1638
- }
1639
- async function acquireLock(projectDir) {
1640
- const noltoDir = path5.join(projectDir, ".nolto");
1641
- await mkdir3(noltoDir, { recursive: true });
1642
- const lockPath = lockFilePath(projectDir);
1643
- return tryAcquire(lockPath);
1644
- }
1645
- async function tryAcquire(lockPath) {
1646
- try {
1647
- const fd = openSync(lockPath, "wx");
1648
- writeFileSync(fd, String(process.pid));
1649
- closeSync(fd);
1650
- return makeLockHandle(lockPath);
1651
- } catch (err) {
1652
- if (err.code !== "EEXIST") throw err;
1653
- }
1654
- let pidStr;
1655
- try {
1656
- pidStr = await readFile4(lockPath, "utf8");
1657
- } catch {
1658
- try {
1659
- unlinkSync(lockPath);
1660
- } catch {
1661
- }
1662
- return tryAcquire(lockPath);
1663
- }
1664
- const pid = parseInt(pidStr, 10);
1665
- if (isNaN(pid)) {
1666
- try {
1667
- unlinkSync(lockPath);
1668
- } catch {
1669
- }
1670
- return tryAcquire(lockPath);
1671
- }
1672
- try {
1673
- process.kill(pid, 0);
1674
- return null;
1675
- } catch (sigErr) {
1676
- if (sigErr.code === "ESRCH") {
1677
- try {
1678
- unlinkSync(lockPath);
1679
- } catch {
1680
- }
1681
- return tryAcquire(lockPath);
1682
- }
1683
- return null;
1684
- }
1685
- }
1686
- function makeLockHandle(lockPath) {
1687
- let released = false;
1688
- return {
1689
- async release() {
1690
- if (released) return;
1691
- released = true;
1692
- try {
1693
- unlinkSync(lockPath);
1694
- } catch {
1695
- }
1696
- }
1697
- };
1698
- }
1699
- async function appendLog(projectDir, level, message) {
1700
- try {
1701
- const noltoDir = path5.join(projectDir, ".nolto");
1702
- await mkdir3(noltoDir, { recursive: true });
1703
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}
1704
- `;
1705
- await appendFile(logFilePath(projectDir), line, "utf8");
1226
+ raw = await readFile7(filePath);
1227
+ } catch {
1228
+ throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
1229
+ }
1230
+ let parsed;
1231
+ try {
1232
+ parsed = JSON.parse(raw);
1706
1233
  } catch {
1234
+ throw new CliError(`Malformed JSON in ${filePath}.`, 2);
1235
+ }
1236
+ const { errors } = validateRoadmap(parsed);
1237
+ if (errors.length > 0) {
1238
+ throw new CliError(
1239
+ `roadmap.json failed validation \u2014 not syncing:
1240
+ ${errors.join("\n ")}`,
1241
+ 2
1242
+ );
1707
1243
  }
1244
+ return parsed;
1708
1245
  }
1709
-
1710
- // src/commands/queue.ts
1711
- function withQueueDir(cmd) {
1712
- return cmd.option("--queue-dir <path>", "Override project directory for queue files");
1713
- }
1714
- function registerQueue(program, deps) {
1715
- const queue = program.command("queue").description("Queue a progress report for later flush.");
1716
- withQueueDir(
1717
- queue.command("phase-status <planId> <phaseId> <status>").description("Queue a phase status update (offline, no token required).").option("--message <text>", "Optional message")
1718
- ).action(
1719
- async (planId, phaseId, status, opts) => {
1720
- if (!PLAN_STATUSES.includes(status)) {
1721
- throw new CliError(
1722
- `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1723
- 2
1724
- );
1725
- }
1726
- const projectDir = resolveQueueDir({
1727
- flagDir: opts.queueDir,
1728
- env: process.env,
1729
- cwd: process.cwd()
1730
- });
1731
- const args = {
1732
- planId,
1733
- phaseId,
1734
- status
1735
- };
1736
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1737
- if (opts.message != null) args["message"] = opts.message;
1738
- const entry = await appendEntry(projectDir, { tool: "update_phase_status", args });
1739
- if (deps.output.mode === "json") {
1740
- printResult({ queued: true, id: entry.id }, "json");
1741
- return;
1742
- }
1743
- process.stdout.write(`Queued update_phase_status.
1744
- `);
1745
- }
1746
- );
1747
- withQueueDir(
1748
- queue.command("phase-test <planId> <phaseId> <verdict>").description("Queue a phase test result (offline, no token required).").option("--round <n>", "Test round number (positive integer)").option("--summary <text>", "Test summary")
1749
- ).action(
1750
- async (planId, phaseId, verdict, opts) => {
1751
- if (!TEST_VERDICTS.includes(verdict)) {
1752
- throw new CliError(
1753
- `Invalid verdict "${verdict}". Valid values: ${TEST_VERDICTS.join(", ")}`,
1754
- 2
1755
- );
1756
- }
1757
- let round;
1758
- if (opts.round != null) {
1759
- if (!/^[1-9]\d*$/.test(opts.round)) {
1760
- throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
1761
- }
1762
- const parsed = Number(opts.round);
1763
- if (!Number.isInteger(parsed) || parsed < 1) {
1764
- throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
1765
- }
1766
- round = parsed;
1767
- }
1768
- const projectDir = resolveQueueDir({
1769
- flagDir: opts.queueDir,
1770
- env: process.env,
1771
- cwd: process.cwd()
1772
- });
1773
- const args = {
1774
- planId,
1775
- phaseId,
1776
- verdict
1777
- };
1778
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1779
- if (round != null) args["round"] = round;
1780
- if (opts.summary != null) args["summary"] = opts.summary;
1781
- const entry = await appendEntry(projectDir, { tool: "record_phase_test_result", args });
1782
- if (deps.output.mode === "json") {
1783
- printResult({ queued: true, id: entry.id }, "json");
1784
- return;
1785
- }
1786
- process.stdout.write(`Queued record_phase_test_result.
1787
- `);
1788
- }
1789
- );
1790
- withQueueDir(
1791
- queue.command("plan-status <planId> <status>").description("Queue a plan status update (offline, no token required).").option("--message <text>", "Optional message")
1792
- ).action(
1793
- async (planId, status, opts) => {
1794
- if (!PLAN_STATUSES.includes(status)) {
1795
- throw new CliError(
1796
- `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1797
- 2
1798
- );
1799
- }
1800
- const projectDir = resolveQueueDir({
1801
- flagDir: opts.queueDir,
1802
- env: process.env,
1803
- cwd: process.cwd()
1804
- });
1805
- const args = {
1806
- planId,
1807
- status
1808
- };
1809
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1810
- if (opts.message != null) args["message"] = opts.message;
1811
- const entry = await appendEntry(projectDir, { tool: "update_plan_status", args });
1812
- if (deps.output.mode === "json") {
1813
- printResult({ queued: true, id: entry.id }, "json");
1814
- return;
1815
- }
1816
- process.stdout.write(`Queued update_plan_status.
1817
- `);
1246
+ async function buildSyncBody(args) {
1247
+ const planDocuments = [];
1248
+ for (const ref of collectPlanRefs(args.roadmap)) {
1249
+ const absolute = path7.join(args.repoRoot, ref.path);
1250
+ if (!args.deps.fileExists(absolute)) {
1251
+ args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1252
+ continue;
1818
1253
  }
1254
+ const content = await args.deps.readFile(absolute);
1255
+ planDocuments.push({
1256
+ path: ref.path,
1257
+ taskId: ref.ownerId,
1258
+ content,
1259
+ contentHash: sha256Hex(content)
1260
+ });
1261
+ }
1262
+ return { roadmap: args.roadmap, planDocuments };
1263
+ }
1264
+ async function runSync(args, deps) {
1265
+ const body = await buildSyncBody({
1266
+ roadmap: args.roadmap,
1267
+ repoRoot: args.repoRoot,
1268
+ deps
1269
+ });
1270
+ const response = await deps.http.put(
1271
+ `/api/projects/${args.projectId}/roadmaps/${args.slug}`,
1272
+ body
1819
1273
  );
1820
- withQueueDir(
1821
- queue.command("plan-review <planId> <verdict>").description("Queue a plan review (offline, no token required).").option("--summary <text>", "Review summary")
1822
- ).action(
1823
- async (planId, verdict, opts) => {
1824
- if (!REVIEW_VERDICTS.includes(verdict)) {
1825
- throw new CliError(
1826
- `Invalid verdict "${verdict}". Valid values: ${REVIEW_VERDICTS.join(", ")}`,
1827
- 2
1828
- );
1829
- }
1830
- const projectDir = resolveQueueDir({
1831
- flagDir: opts.queueDir,
1832
- env: process.env,
1833
- cwd: process.cwd()
1834
- });
1835
- const args = {
1836
- planId,
1837
- verdict
1838
- };
1839
- if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1840
- if (opts.summary != null) args["summary"] = opts.summary;
1841
- const entry = await appendEntry(projectDir, { tool: "record_plan_review", args });
1842
- if (deps.output.mode === "json") {
1843
- printResult({ queued: true, id: entry.id }, "json");
1844
- return;
1845
- }
1846
- process.stdout.write(`Queued record_plan_review.
1847
- `);
1848
- }
1274
+ const stats = deriveTaskStats(args.roadmap);
1275
+ deps.log(
1276
+ `Synced ${args.slug}: ${stats.done}/${stats.total} done \u2014 docs upserted ${response.documents.upserted}, unchanged ${response.documents.unchanged}, deleted ${response.documents.deleted}`
1849
1277
  );
1278
+ return response;
1850
1279
  }
1851
1280
 
1852
- // src/detach.ts
1853
- import { spawn } from "child_process";
1854
- function spawnDetachedFlush(scriptPath, queueDir) {
1855
- const extraArgs = queueDir != null ? ["--queue-dir", queueDir] : [];
1856
- const child = spawn(
1857
- process.execPath,
1858
- [scriptPath, "flush", ...extraArgs],
1859
- {
1860
- detached: true,
1861
- stdio: "ignore",
1862
- env: process.env
1863
- }
1281
+ // src/sync-repo.ts
1282
+ async function syncRepo(args, io) {
1283
+ const bindingPath = path8.join(args.root, "nolto.json");
1284
+ const binding = await loadRepoBinding(bindingPath);
1285
+ const projectId = binding?.projectId ?? args.defaultProjectId;
1286
+ if (projectId == null) {
1287
+ throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1288
+ }
1289
+ let slug = binding?.roadmapSlug;
1290
+ if (slug == null) {
1291
+ slug = slugifyProjectId(path8.basename(args.root));
1292
+ await writeRoadmapSlug(args.root, slug);
1293
+ io.log(`roadmapSlug not set \u2014 derived "${slug}" and saved to nolto.json`);
1294
+ }
1295
+ const roadmap = await loadValidRoadmap(args.root, io.readFile);
1296
+ const response = await runSync(
1297
+ { repoRoot: args.root, projectId, slug, roadmap },
1298
+ { http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
1864
1299
  );
1865
- child.unref();
1866
- return child.pid;
1300
+ const planAbsPaths = collectPlanRefs(roadmap).map((ref) => path8.join(args.root, ref.path));
1301
+ return { ...response, planAbsPaths };
1867
1302
  }
1868
1303
 
1869
- // src/commands/flush.ts
1870
- async function runFlushWorker(deps, projectDir) {
1871
- const lock = await acquireLock(projectDir);
1872
- if (lock === null) {
1873
- process.stdout.write("Another flush is running.\n");
1874
- return { flushed: 0, remaining: 0, failed: 0 };
1875
- }
1876
- try {
1877
- const entries = await readQueue(projectDir);
1878
- if (entries.length === 0) {
1879
- process.stdout.write("Nothing to flush.\n");
1880
- return { flushed: 0, remaining: 0, failed: 0 };
1881
- }
1304
+ // src/commands/sync.ts
1305
+ function register5(program, deps) {
1306
+ program.command("sync").description("Push .roadmap/roadmap.json and linked plan documents to Nolto (idempotent full upsert).").action(async () => {
1882
1307
  if (deps.settings.token == null) {
1883
- await appendLog(projectDir, "warn", "flush skipped: no token configured");
1884
- return { flushed: 0, remaining: entries.length, failed: 0 };
1308
+ throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
1885
1309
  }
1886
- let flushed = 0;
1887
- let remaining = entries.slice();
1888
- for (const entry of entries) {
1889
- try {
1890
- await deps.caller.call(entry.tool, entry.args);
1891
- } catch (err) {
1892
- const cliErr = err instanceof CliError ? err : new CliError(String(err), 5);
1893
- await appendLog(
1894
- projectDir,
1895
- "error",
1896
- `${entry.tool} id=${entry.id} exit=${cliErr.exitCode} ${cliErr.message}`
1897
- );
1898
- return { flushed, remaining: remaining.length, failed: 1 };
1899
- }
1900
- flushed++;
1901
- remaining = remaining.slice(1);
1902
- await atomicRewriteQueue(projectDir, remaining);
1310
+ const startDir = resolveStartDir(process.env, process.cwd());
1311
+ const { root, foundGit } = findRepoRoot(startDir);
1312
+ if (!foundGit) {
1313
+ throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
1903
1314
  }
1904
- return { flushed, remaining: 0, failed: 0 };
1905
- } finally {
1906
- await lock.release();
1907
- }
1908
- }
1909
- function register8(program, deps) {
1910
- program.command("flush").description("Flush the pending queue, sending each entry to the Nolto MCP server.").option("--detach", "Spawn a detached background worker and exit immediately.").option("--queue-dir <path>", "Override project directory for queue files").action(async (opts) => {
1911
- const projectDir = resolveQueueDir({
1912
- flagDir: opts.queueDir,
1913
- env: process.env,
1914
- cwd: process.cwd()
1315
+ const http = createHttpClient({
1316
+ baseUrl: deps.settings.baseUrl,
1317
+ version: deps.version,
1318
+ token: deps.settings.token
1915
1319
  });
1916
- if (opts.detach) {
1917
- spawnDetachedFlush(process.argv[1], opts.queueDir);
1918
- return;
1919
- }
1920
- try {
1921
- const summary = await runFlushWorker(deps, projectDir);
1922
- if (deps.output.mode === "json") {
1923
- process.stdout.write(JSON.stringify(summary) + "\n");
1924
- } else {
1925
- if (summary.flushed > 0 || summary.failed > 0) {
1926
- process.stdout.write(
1927
- `Flushed ${summary.flushed}, remaining ${summary.remaining}, failed ${summary.failed}.
1928
- `
1929
- );
1930
- }
1320
+ const response = await syncRepo(
1321
+ { root, defaultProjectId: deps.settings.defaultProjectId },
1322
+ {
1323
+ readFile: (p) => readFile4(p, "utf8"),
1324
+ fileExists: (p) => existsSync3(p),
1325
+ http,
1326
+ log: (line) => process.stdout.write(line + "\n"),
1327
+ warn: (line) => process.stderr.write("Warning: " + line + "\n")
1931
1328
  }
1932
- } catch (err) {
1933
- const msg = err instanceof Error ? err.message : String(err);
1934
- await appendLog(projectDir, "error", `unexpected flush error: ${msg}`);
1935
- process.stderr.write(`Flush error (logged to .nolto/flush.log): ${msg}
1936
- `);
1329
+ );
1330
+ if (deps.output.mode === "json") {
1331
+ const { planAbsPaths: _planAbsPaths, ...publicResponse } = response;
1332
+ printResult(publicResponse, "json");
1937
1333
  }
1938
1334
  });
1939
1335
  }
1940
1336
 
1941
- // src/commands/link.ts
1942
- import path6 from "path";
1943
- import { statSync as statSync3 } from "fs";
1944
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1945
- function resolveStartDir(env, cwd) {
1946
- const cpd = env["CLAUDE_PROJECT_DIR"];
1947
- return cpd != null && cpd.length > 0 ? cpd : cwd;
1948
- }
1949
- function dirHasGit(dir) {
1950
- try {
1951
- statSync3(path6.join(dir, ".git"));
1952
- return true;
1953
- } catch {
1954
- return false;
1955
- }
1956
- }
1957
- function findRepoRoot(startDir, hasGit = dirHasGit) {
1958
- let current = startDir;
1959
- while (true) {
1960
- if (hasGit(current)) {
1961
- return { root: current, foundGit: true };
1962
- }
1963
- const parent = path6.dirname(current);
1964
- if (parent === current) break;
1965
- current = parent;
1966
- }
1967
- return { root: startDir, foundGit: false };
1968
- }
1969
- async function handleShow(deps, projectBindingPath, mode2) {
1970
- if (projectBindingPath == null) {
1971
- if (mode2 === "json") {
1972
- printResult({ bound: false, projectBindingPath: null }, mode2);
1973
- } else {
1974
- process.stdout.write("No nolto.json binding found in this directory tree.\n");
1975
- }
1976
- return;
1977
- }
1978
- const binding = await loadRepoBinding(projectBindingPath).catch((err) => {
1979
- if (err instanceof CliError) throw err;
1980
- throw new CliError(`Cannot read binding: ${String(err)}`, 2);
1981
- });
1982
- if (mode2 === "json") {
1983
- printResult({
1984
- bound: binding != null,
1985
- projectId: binding?.projectId ?? null,
1986
- projectBindingPath,
1987
- source: deps.settings.source.project === "repo" ? "repo" : "file"
1988
- }, mode2);
1989
- } else {
1990
- if (binding == null) {
1991
- process.stdout.write(`Binding file found at ${projectBindingPath} but could not be read.
1992
- `);
1993
- } else {
1994
- process.stdout.write(`Binding file : ${projectBindingPath}
1995
- `);
1996
- process.stdout.write(`projectId : ${binding.projectId}
1997
- `);
1998
- const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
1999
- process.stdout.write(`source : ${active}
2000
- `);
1337
+ // src/commands/watch.ts
1338
+ import { readFile as readFile5 } from "fs/promises";
1339
+ import { existsSync as existsSync4 } from "fs";
1340
+ import path10 from "path";
1341
+ import chokidar from "chokidar";
1342
+
1343
+ // src/watch-core.ts
1344
+ var RepoWatch = class {
1345
+ constructor(root, deps) {
1346
+ this.root = root;
1347
+ this.deps = deps;
1348
+ }
1349
+ root;
1350
+ deps;
1351
+ timer = null;
1352
+ running = false;
1353
+ pending = false;
1354
+ watchedPlans = [];
1355
+ handleEvent(_filePath) {
1356
+ if (this.timer != null) {
1357
+ this.deps.clearTimeoutFn(this.timer);
1358
+ }
1359
+ this.timer = this.deps.setTimeoutFn(() => {
1360
+ this.timer = null;
1361
+ void this.flush();
1362
+ }, this.deps.debounceMs);
1363
+ }
1364
+ async flush() {
1365
+ if (this.running) {
1366
+ this.pending = true;
1367
+ return;
2001
1368
  }
2002
- }
2003
- }
2004
- async function handleUnlink(projectBindingPath, mode2) {
2005
- const { readFile: readFile6, writeFile: writeFile5, chmod } = await import("fs/promises");
2006
- let existing = {};
2007
- try {
2008
- const raw = await readFile6(projectBindingPath, "utf8");
2009
- const parsed = JSON.parse(raw);
2010
- if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
2011
- throw new CliError(
2012
- `Cannot unlink ${projectBindingPath}: file contains valid JSON but is not a plain object (got ${Array.isArray(parsed) ? "array" : String(parsed)}). Remove or fix the file manually.`,
2013
- 2
1369
+ this.running = true;
1370
+ try {
1371
+ const result = await this.deps.sync();
1372
+ this.rearmPlanWatches(result.planAbsPaths);
1373
+ } catch (err) {
1374
+ this.deps.warn(
1375
+ `[${this.root}] sync failed: ${err instanceof Error ? err.message : String(err)}`
2014
1376
  );
1377
+ } finally {
1378
+ this.running = false;
1379
+ if (this.pending) {
1380
+ this.pending = false;
1381
+ void this.flush();
1382
+ }
2015
1383
  }
2016
- existing = parsed;
2017
- } catch (err) {
2018
- if (err instanceof CliError) throw err;
2019
- throw new CliError(`Cannot read ${projectBindingPath}: ${String(err)}`, 2);
2020
- }
2021
- const { projectId: _removed, ...rest } = existing;
2022
- void _removed;
2023
- await writeFile5(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
2024
- await chmod(projectBindingPath, 420);
2025
- if (mode2 === "json") {
2026
- printResult({ unlinked: true, projectBindingPath }, mode2);
2027
- } else {
2028
- process.stdout.write(`Removed projectId from ${projectBindingPath}.
2029
- `);
2030
1384
  }
2031
- }
2032
- async function performLink(deps, projectId, mode2) {
2033
- if (!UUID_RE.test(projectId)) {
2034
- throw new CliError(
2035
- `Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
2036
- 2
2037
- );
1385
+ rearmPlanWatches(next) {
1386
+ const current = new Set(this.watchedPlans);
1387
+ const nextSet = new Set(next);
1388
+ const toAdd = next.filter((p) => !current.has(p));
1389
+ const toRemove = this.watchedPlans.filter((p) => !nextSet.has(p));
1390
+ if (toRemove.length > 0) this.deps.watcher.unwatch(toRemove);
1391
+ if (toAdd.length > 0) this.deps.watcher.add(toAdd);
1392
+ this.watchedPlans = next;
2038
1393
  }
2039
- const startDir = resolveStartDir(process.env, process.cwd());
2040
- const { root, foundGit } = findRepoRoot(startDir);
2041
- if (!foundGit) {
2042
- process.stderr.write(
2043
- `Warning: no .git directory found above ${startDir}. Writing nolto.json to current directory.
2044
- `
1394
+ };
1395
+
1396
+ // src/service-install.ts
1397
+ import path9 from "path";
1398
+ import os2 from "os";
1399
+ function buildUnitFile(args) {
1400
+ return [
1401
+ "[Unit]",
1402
+ "Description=Nolto roadmap watch (auto-sync roadmap.json to Nolto)",
1403
+ "After=network-online.target",
1404
+ "",
1405
+ "[Service]",
1406
+ `ExecStart=${args.nodePath} ${args.scriptPath} watch`,
1407
+ "Restart=on-failure",
1408
+ "RestartSec=5",
1409
+ "",
1410
+ "[Install]",
1411
+ "WantedBy=default.target",
1412
+ ""
1413
+ ].join("\n");
1414
+ }
1415
+ function getUnitPath(env) {
1416
+ const xdg = env["XDG_CONFIG_HOME"];
1417
+ const base = xdg != null && xdg.length > 0 ? xdg : path9.join(os2.homedir(), ".config");
1418
+ return path9.join(base, "systemd", "user", "nolto-watch.service");
1419
+ }
1420
+ async function installServiceWith(deps) {
1421
+ const unitPath = getUnitPath(deps.env);
1422
+ await deps.mkdir(path9.dirname(unitPath));
1423
+ await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
1424
+ deps.log(`Wrote ${unitPath}`);
1425
+ const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
1426
+ const enable = reload.code === 0 ? await deps.exec(["systemctl", "--user", "enable", "--now", "nolto-watch"]) : reload;
1427
+ if (reload.code !== 0 || enable.code !== 0) {
1428
+ deps.warn(
1429
+ "Could not activate the service automatically. Run manually:\n systemctl --user daemon-reload\n systemctl --user enable --now nolto-watch"
2045
1430
  );
1431
+ return;
2046
1432
  }
2047
- if (deps.settings.token != null) {
2048
- try {
2049
- const result = await deps.caller.call("list_projects", {});
2050
- const projects = result?.projects ?? [];
2051
- if (!projects.some((p) => p.id === projectId)) {
2052
- process.stderr.write(
2053
- `Warning: project ${projectId} was not found in your list_projects response.
2054
- Proceeding anyway \u2014 verify the ID is correct.
2055
- `
2056
- );
1433
+ deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
1434
+ }
1435
+ async function installService() {
1436
+ const { writeFile: writeFile6, mkdir: mkdir6 } = await import("fs/promises");
1437
+ const { execFile } = await import("child_process");
1438
+ const { promisify } = await import("util");
1439
+ const execFileAsync = promisify(execFile);
1440
+ await installServiceWith({
1441
+ env: process.env,
1442
+ nodePath: process.execPath,
1443
+ scriptPath: path9.resolve(process.argv[1] ?? ""),
1444
+ writeFile: (p, content) => writeFile6(p, content, "utf8"),
1445
+ mkdir: async (p) => {
1446
+ await mkdir6(p, { recursive: true });
1447
+ },
1448
+ exec: async (cmd) => {
1449
+ try {
1450
+ await execFileAsync(cmd[0], cmd.slice(1));
1451
+ return { code: 0, stderr: "" };
1452
+ } catch (err) {
1453
+ const e = err;
1454
+ return { code: e.code ?? 1, stderr: e.stderr ?? String(err) };
2057
1455
  }
2058
- } catch {
2059
- process.stderr.write(
2060
- "Warning: could not verify project membership (offline or token issue). Proceeding anyway.\n"
2061
- );
2062
- }
2063
- }
2064
- await writeRepoBinding(root, projectId);
2065
- const writtenPath = path6.join(root, "nolto.json");
2066
- if (mode2 === "json") {
2067
- printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
2068
- } else {
2069
- process.stdout.write(
2070
- `Linked this repo to project ${projectId} (wrote ${writtenPath}).
2071
- Commit nolto.json to share the binding with your team.
2072
- `
2073
- );
2074
- }
1456
+ },
1457
+ log: (line) => process.stdout.write(line + "\n"),
1458
+ warn: (line) => process.stderr.write("Warning: " + line + "\n")
1459
+ });
2075
1460
  }
2076
- function register9(program, deps) {
2077
- const cmd = program.command("link [projectId]").description(
2078
- "Bind this repository to a Nolto project.\nWrites nolto.json at the repo root. Commit it to share the binding with your team.\n\nExamples:\n nolto link <uuid> Write / update nolto.json\n nolto link --show Show the current binding\n nolto link --unlink Remove the projectId from nolto.json"
2079
- ).option("--show", "Show the current repo binding (path + projectId + source)").option("--unlink", "Remove the projectId key from nolto.json");
2080
- cmd.action(async (projectId) => {
2081
- const { output } = deps;
2082
- const projectBindingPath = deps.projectBindingPath ?? null;
2083
- const mode2 = output.mode;
2084
- if (cmd.opts()["show"]) {
2085
- await handleShow(deps, projectBindingPath, mode2);
1461
+
1462
+ // src/commands/watch.ts
1463
+ function register6(program, deps) {
1464
+ program.command("watch").description("Watch every registered repository's roadmap + plan files and sync on change.").option("--debounce <ms>", "Debounce window in milliseconds", "2000").option("--install-service", "Install and enable a systemd user unit (nolto-watch) instead of watching").action(async (opts) => {
1465
+ if (opts.installService) {
1466
+ await installService();
2086
1467
  return;
2087
1468
  }
2088
- if (cmd.opts()["unlink"]) {
2089
- if (projectBindingPath == null) {
2090
- throw new CliError("No nolto.json found in this directory tree. Nothing to unlink.", 2);
2091
- }
2092
- await handleUnlink(projectBindingPath, mode2);
2093
- return;
1469
+ if (deps.settings.token == null) {
1470
+ throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
2094
1471
  }
2095
- if (projectId == null || projectId.trim().length === 0) {
2096
- throw new CliError(
2097
- "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --unlink to remove it.",
2098
- 2
2099
- );
1472
+ const debounceMs = Number.parseInt(opts.debounce, 10);
1473
+ if (Number.isNaN(debounceMs) || debounceMs < 0) {
1474
+ throw new CliError("--debounce must be a non-negative integer.", 2);
2100
1475
  }
2101
- await performLink(deps, projectId, mode2);
1476
+ const registry = await loadRegistry(getRegistryPath(process.env));
1477
+ const roots = registry.repos.map((r) => r.root).filter((root) => {
1478
+ if (existsSync4(root)) return true;
1479
+ process.stderr.write(`Warning: registered repo no longer exists, skipping: ${root}
1480
+ `);
1481
+ return false;
1482
+ });
1483
+ if (roots.length === 0) {
1484
+ throw new CliError("No watchable repositories in the registry. Run `nolto init` in a repo first.", 2);
1485
+ }
1486
+ const http = createHttpClient({
1487
+ baseUrl: deps.settings.baseUrl,
1488
+ version: deps.version,
1489
+ token: deps.settings.token
1490
+ });
1491
+ const watchers = [];
1492
+ for (const root of roots) {
1493
+ const roadmapPath = path10.join(root, ".roadmap", "roadmap.json");
1494
+ const watcher = chokidar.watch([roadmapPath], { ignoreInitial: true });
1495
+ watchers.push(watcher);
1496
+ const repoWatch = new RepoWatch(root, {
1497
+ sync: () => syncRepo(
1498
+ { root, defaultProjectId: deps.settings.defaultProjectId },
1499
+ {
1500
+ readFile: (p) => readFile5(p, "utf8"),
1501
+ fileExists: (p) => existsSync4(p),
1502
+ http,
1503
+ log: (line) => process.stdout.write(`[${path10.basename(root)}] ${line}
1504
+ `),
1505
+ warn: (line) => process.stderr.write(`Warning: [${path10.basename(root)}] ${line}
1506
+ `)
1507
+ }
1508
+ ),
1509
+ watcher,
1510
+ debounceMs,
1511
+ setTimeoutFn: (fn, ms) => setTimeout(fn, ms),
1512
+ clearTimeoutFn: (t) => clearTimeout(t),
1513
+ log: (line) => process.stdout.write(line + "\n"),
1514
+ warn: (line) => process.stderr.write(line + "\n")
1515
+ });
1516
+ watcher.on("all", (_event, filePath) => repoWatch.handleEvent(filePath));
1517
+ void repoWatch.flush();
1518
+ }
1519
+ process.stdout.write(`Watching ${roots.length} repositories (debounce ${debounceMs}ms). Ctrl-C to stop.
1520
+ `);
1521
+ await new Promise((resolve) => {
1522
+ const stop = () => {
1523
+ void Promise.all(watchers.map((w) => w.close())).then(() => resolve());
1524
+ };
1525
+ process.once("SIGINT", stop);
1526
+ process.once("SIGTERM", stop);
1527
+ });
2102
1528
  });
2103
1529
  }
2104
1530
 
@@ -2109,24 +1535,20 @@ function stripCommanderErrorPrefix(msg) {
2109
1535
  function buildProgram(deps) {
2110
1536
  const writeErr = (_msg) => {
2111
1537
  };
2112
- const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput({ writeErr }).description("Nolto CLI \u2014 register plans and update progress from your terminal.").option("--token <value>", "API token (overrides env/file)").option("--base-url <url>", "Nolto base URL (default: https://nolto.app)").option("--project <projectId>", "Default project ID").option("--json", "Output as JSON");
2113
- register(program, deps);
1538
+ const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput({ writeErr }).description("Nolto CLI \u2014 sync repository roadmaps with Nolto.").option("--token <value>", "API token (overrides env/file)").option("--base-url <url>", "Nolto base URL (default: https://nolto.app)").option("--project <projectId>", "Default project ID").option("--json", "Output as JSON");
2114
1539
  register2(program, deps);
2115
1540
  register3(program, deps);
2116
1541
  register4(program, deps);
1542
+ register(program, deps);
2117
1543
  register5(program, deps);
2118
1544
  register6(program, deps);
2119
- register7(program, deps);
2120
- registerQueue(program, deps);
2121
- register8(program, deps);
2122
- register9(program, deps);
2123
1545
  return program;
2124
1546
  }
2125
1547
 
2126
1548
  // src/update-notifier.ts
2127
- import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
1549
+ import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
2128
1550
  import https from "https";
2129
- import path7 from "path";
1551
+ import path11 from "path";
2130
1552
  var PACKAGE = "@nolto/cli";
2131
1553
  var CACHE_FILE = "update-check.json";
2132
1554
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -2184,16 +1606,16 @@ function fetchLatestFromRegistry() {
2184
1606
  async function refreshCache(cachePath, now, fetchLatest) {
2185
1607
  const latest = await fetchLatest();
2186
1608
  if (!latest) return;
2187
- await mkdir4(path7.dirname(cachePath), { recursive: true }).catch(() => void 0);
1609
+ await mkdir5(path11.dirname(cachePath), { recursive: true }).catch(() => void 0);
2188
1610
  const payload = { checkedAt: now, latest };
2189
- await writeFile4(cachePath, JSON.stringify(payload), { mode: 384 }).catch(() => void 0);
1611
+ await writeFile5(cachePath, JSON.stringify(payload), { mode: 384 }).catch(() => void 0);
2190
1612
  }
2191
1613
  async function checkForUpdate(opts) {
2192
1614
  if (isDisabled(opts.env)) return null;
2193
- const cachePath = path7.join(opts.configDir, CACHE_FILE);
1615
+ const cachePath = path11.join(opts.configDir, CACHE_FILE);
2194
1616
  let cache = {};
2195
1617
  try {
2196
- cache = JSON.parse(await readFile5(cachePath, "utf8"));
1618
+ cache = JSON.parse(await readFile6(cachePath, "utf8"));
2197
1619
  } catch {
2198
1620
  }
2199
1621
  if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
@@ -2221,11 +1643,11 @@ async function notifyUpdate(opts) {
2221
1643
  }
2222
1644
 
2223
1645
  // src/index.ts
2224
- var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
1646
+ var __dirname3 = path12.dirname(fileURLToPath3(import.meta.url));
2225
1647
  var require2 = createRequire2(import.meta.url);
2226
1648
  function getVersion() {
2227
1649
  try {
2228
- const pkgPath = path8.resolve(__dirname2, "../package.json");
1650
+ const pkgPath = path12.resolve(__dirname3, "../package.json");
2229
1651
  const pkg = require2(pkgPath);
2230
1652
  return pkg.version ?? "0.0.0";
2231
1653
  } catch {
@@ -2274,15 +1696,19 @@ async function main() {
2274
1696
  repoBinding
2275
1697
  });
2276
1698
  const version = getVersion();
2277
- const caller = settings.token != null ? createMcpCaller({ baseUrl: settings.baseUrl, token: settings.token, version }) : {
2278
- call: async (_toolName, _args) => {
2279
- throw new CliError(
2280
- "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
2281
- 3
2282
- );
2283
- }
2284
- };
2285
- const program = buildProgram({ caller, settings, output: { mode }, version, configPath, projectBindingPath });
1699
+ const http = createHttpClient({
1700
+ baseUrl: settings.baseUrl,
1701
+ version,
1702
+ token: settings.token
1703
+ });
1704
+ const program = buildProgram({
1705
+ http,
1706
+ settings,
1707
+ output: { mode },
1708
+ version,
1709
+ configPath,
1710
+ projectBindingPath
1711
+ });
2286
1712
  await program.parseAsync(process.argv);
2287
1713
  await notifyUpdate({ current: version, env: process.env, isJson: mode === "json", now: Date.now() });
2288
1714
  }