@nolto/cli 0.1.1 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +438 -14
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { createRequire as createRequire2 } from "module";
5
5
  import { fileURLToPath as fileURLToPath2 } from "url";
6
- import path4 from "path";
6
+ import path5 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
@@ -77,6 +77,7 @@ var DOCUMENT_MAX_BYTES = 2 * 1024 * 1024;
77
77
  var DOCUMENT_FILENAME_MAX = 255;
78
78
  var DEFAULT_BASE_URL = "https://nolto.app";
79
79
  var CLI_USER_AGENT_NAME = "nolto-cli";
80
+ var QUEUE_MAX_ENTRIES = 200;
80
81
 
81
82
  // src/config.ts
82
83
  var configSchema = z.object({
@@ -274,6 +275,22 @@ import { createRequire } from "module";
274
275
  import { fileURLToPath } from "url";
275
276
  import path2 from "path";
276
277
  import fs from "fs";
278
+
279
+ // src/unwrap.ts
280
+ function unwrapList(result, key) {
281
+ if (Array.isArray(result)) {
282
+ return result;
283
+ }
284
+ if (result != null && typeof result === "object") {
285
+ const inner = result[key];
286
+ if (Array.isArray(inner)) {
287
+ return inner;
288
+ }
289
+ }
290
+ return [];
291
+ }
292
+
293
+ // src/commands/init.ts
277
294
  var __dirname = path2.dirname(fileURLToPath(import.meta.url));
278
295
  var _require = createRequire(import.meta.url);
279
296
  function getCliVersion() {
@@ -329,9 +346,7 @@ function register(program, _deps) {
329
346
  let projects = [];
330
347
  try {
331
348
  const result = await caller.call("list_projects", {});
332
- if (Array.isArray(result)) {
333
- projects = result;
334
- }
349
+ projects = unwrapList(result, "projects");
335
350
  } catch (err) {
336
351
  if (err instanceof CliError && err.exitCode === 3) {
337
352
  throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
@@ -473,9 +488,7 @@ function register2(program, deps) {
473
488
  if (settings.token != null) {
474
489
  try {
475
490
  const result = await deps.caller.call("list_projects", {});
476
- if (Array.isArray(result)) {
477
- projectCount = result.length;
478
- }
491
+ projectCount = unwrapList(result, "projects").length;
479
492
  } catch {
480
493
  }
481
494
  }
@@ -500,7 +513,7 @@ function register2(program, deps) {
500
513
  ["configPath", configPath],
501
514
  ["token", tokenDisplay],
502
515
  ["defaultProject", `${settings.defaultProjectId ?? "none"} (source: ${settings.source.project})`],
503
- ["projects", projectCount != null ? String(projectCount) : "(no token)"]
516
+ ["projects", projectCount != null ? String(projectCount) : settings.token != null ? "(unavailable)" : "(no token)"]
504
517
  ];
505
518
  const maxKey = lines.reduce((m, [k]) => Math.max(m, k.length), 0);
506
519
  for (const [key, val] of lines) {
@@ -529,7 +542,7 @@ function register3(program, deps) {
529
542
  printResult(result, "json");
530
543
  return;
531
544
  }
532
- const rows = Array.isArray(result) ? result : [];
545
+ const rows = unwrapList(result, "projects");
533
546
  const defaultId = deps.settings.defaultProjectId;
534
547
  const tableRows = rows.map((p) => ({
535
548
  id: p.id ?? "",
@@ -847,12 +860,12 @@ function register4(program, deps) {
847
860
  printResult(result, "json");
848
861
  return;
849
862
  }
850
- const rows = Array.isArray(result) ? result : [];
863
+ const rows = unwrapList(result, "plans");
851
864
  const tableRows = rows.map((p) => ({
852
865
  id: p.id ?? "",
853
- title: p.title ?? "",
866
+ title: p.display_title ?? p.raw_title ?? p.title ?? "",
854
867
  status: p.status ?? "",
855
- createdAt: p.createdAt ?? ""
868
+ createdAt: p.created_at ?? p.createdAt ?? ""
856
869
  }));
857
870
  process.stdout.write(formatTable(tableRows, ["id", "title", "status", "createdAt"]) + "\n");
858
871
  });
@@ -1023,6 +1036,415 @@ function register6(program, deps) {
1023
1036
  });
1024
1037
  }
1025
1038
 
1039
+ // src/queue-file.ts
1040
+ import { openSync, closeSync, writeFileSync, unlinkSync, renameSync, statSync } from "fs";
1041
+ import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, unlink, appendFile } from "fs/promises";
1042
+ import path4 from "path";
1043
+ import crypto from "crypto";
1044
+ function queueFilePath(projectDir) {
1045
+ return path4.join(projectDir, ".nolto", "pending.jsonl");
1046
+ }
1047
+ function lockFilePath(projectDir) {
1048
+ return path4.join(projectDir, ".nolto", "flush.lock");
1049
+ }
1050
+ function logFilePath(projectDir) {
1051
+ return path4.join(projectDir, ".nolto", "flush.log");
1052
+ }
1053
+ function resolveQueueDir(inputs) {
1054
+ if (inputs.flagDir != null) return inputs.flagDir;
1055
+ if (inputs.env["NOLTO_QUEUE_DIR"]) return inputs.env["NOLTO_QUEUE_DIR"];
1056
+ if (inputs.env["CLAUDE_PROJECT_DIR"]) return inputs.env["CLAUDE_PROJECT_DIR"];
1057
+ return findAncestorWithMarker(inputs.cwd);
1058
+ }
1059
+ function findAncestorWithMarker(startDir) {
1060
+ let current = startDir;
1061
+ while (true) {
1062
+ if (hasMarkerSync(current, ".git") || hasMarkerSync(current, ".nolto")) {
1063
+ return current;
1064
+ }
1065
+ const parent = path4.dirname(current);
1066
+ if (parent === current) break;
1067
+ current = parent;
1068
+ }
1069
+ return startDir;
1070
+ }
1071
+ function hasMarkerSync(dir, marker) {
1072
+ try {
1073
+ statSync(path4.join(dir, marker));
1074
+ return true;
1075
+ } catch {
1076
+ return false;
1077
+ }
1078
+ }
1079
+ async function readQueue(projectDir) {
1080
+ const filePath = queueFilePath(projectDir);
1081
+ let raw;
1082
+ try {
1083
+ raw = await readFile3(filePath, "utf8");
1084
+ } catch {
1085
+ return [];
1086
+ }
1087
+ const entries = [];
1088
+ for (const line of raw.split("\n")) {
1089
+ const trimmed = line.trim();
1090
+ if (!trimmed) continue;
1091
+ try {
1092
+ const obj = JSON.parse(trimmed);
1093
+ entries.push(obj);
1094
+ } catch {
1095
+ await appendLog(projectDir, "warn", `malformed queue line skipped: ${trimmed.slice(0, 80)}`);
1096
+ }
1097
+ }
1098
+ return entries;
1099
+ }
1100
+ async function appendEntry(projectDir, entry) {
1101
+ const noltoDir = path4.join(projectDir, ".nolto");
1102
+ await mkdir2(noltoDir, { recursive: true, mode: 448 });
1103
+ const existing = await readQueue(projectDir);
1104
+ if (existing.length >= QUEUE_MAX_ENTRIES) {
1105
+ throw new CliError(
1106
+ `Queue is full (${QUEUE_MAX_ENTRIES} entries). Flush before adding more.`,
1107
+ 2
1108
+ );
1109
+ }
1110
+ const newEntry = {
1111
+ id: crypto.randomUUID(),
1112
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1113
+ tool: entry.tool,
1114
+ args: { ...entry.args }
1115
+ };
1116
+ const line = JSON.stringify(newEntry) + "\n";
1117
+ try {
1118
+ await appendFile(queueFilePath(projectDir), line, "utf8");
1119
+ } catch (err) {
1120
+ if (err instanceof CliError) throw err;
1121
+ throw new CliError(
1122
+ `Failed to write queue entry: ${err.message ?? String(err)}`,
1123
+ 2
1124
+ );
1125
+ }
1126
+ return newEntry;
1127
+ }
1128
+ async function atomicRewriteQueue(projectDir, entries) {
1129
+ const filePath = queueFilePath(projectDir);
1130
+ if (entries.length === 0) {
1131
+ try {
1132
+ await unlink(filePath);
1133
+ } catch (err) {
1134
+ const code = err.code;
1135
+ if (code !== "ENOENT") throw err;
1136
+ }
1137
+ return;
1138
+ }
1139
+ const noltoDir = path4.join(projectDir, ".nolto");
1140
+ await mkdir2(noltoDir, { recursive: true });
1141
+ const tmpPath = filePath + ".tmp";
1142
+ const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
1143
+ await writeFile2(tmpPath, content, "utf8");
1144
+ renameSync(tmpPath, filePath);
1145
+ }
1146
+ async function acquireLock(projectDir) {
1147
+ const noltoDir = path4.join(projectDir, ".nolto");
1148
+ await mkdir2(noltoDir, { recursive: true });
1149
+ const lockPath = lockFilePath(projectDir);
1150
+ return tryAcquire(lockPath);
1151
+ }
1152
+ async function tryAcquire(lockPath) {
1153
+ try {
1154
+ const fd = openSync(lockPath, "wx");
1155
+ writeFileSync(fd, String(process.pid));
1156
+ closeSync(fd);
1157
+ return makeLockHandle(lockPath);
1158
+ } catch (err) {
1159
+ if (err.code !== "EEXIST") throw err;
1160
+ }
1161
+ let pidStr;
1162
+ try {
1163
+ pidStr = await readFile3(lockPath, "utf8");
1164
+ } catch {
1165
+ try {
1166
+ unlinkSync(lockPath);
1167
+ } catch {
1168
+ }
1169
+ return tryAcquire(lockPath);
1170
+ }
1171
+ const pid = parseInt(pidStr, 10);
1172
+ if (isNaN(pid)) {
1173
+ try {
1174
+ unlinkSync(lockPath);
1175
+ } catch {
1176
+ }
1177
+ return tryAcquire(lockPath);
1178
+ }
1179
+ try {
1180
+ process.kill(pid, 0);
1181
+ return null;
1182
+ } catch (sigErr) {
1183
+ if (sigErr.code === "ESRCH") {
1184
+ try {
1185
+ unlinkSync(lockPath);
1186
+ } catch {
1187
+ }
1188
+ return tryAcquire(lockPath);
1189
+ }
1190
+ return null;
1191
+ }
1192
+ }
1193
+ function makeLockHandle(lockPath) {
1194
+ let released = false;
1195
+ return {
1196
+ async release() {
1197
+ if (released) return;
1198
+ released = true;
1199
+ try {
1200
+ unlinkSync(lockPath);
1201
+ } catch {
1202
+ }
1203
+ }
1204
+ };
1205
+ }
1206
+ async function appendLog(projectDir, level, message) {
1207
+ try {
1208
+ const noltoDir = path4.join(projectDir, ".nolto");
1209
+ await mkdir2(noltoDir, { recursive: true });
1210
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}
1211
+ `;
1212
+ await appendFile(logFilePath(projectDir), line, "utf8");
1213
+ } catch {
1214
+ }
1215
+ }
1216
+
1217
+ // src/commands/queue.ts
1218
+ function withQueueDir(cmd) {
1219
+ return cmd.option("--queue-dir <path>", "Override project directory for queue files");
1220
+ }
1221
+ function registerQueue(program, deps) {
1222
+ const queue = program.command("queue").description("Queue a progress report for later flush.");
1223
+ withQueueDir(
1224
+ queue.command("phase-status <planId> <phaseId> <status>").description("Queue a phase status update (offline, no token required).").option("--message <text>", "Optional message")
1225
+ ).action(
1226
+ async (planId, phaseId, status, opts) => {
1227
+ if (!PLAN_STATUSES.includes(status)) {
1228
+ throw new CliError(
1229
+ `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1230
+ 2
1231
+ );
1232
+ }
1233
+ const projectDir = resolveQueueDir({
1234
+ flagDir: opts.queueDir,
1235
+ env: process.env,
1236
+ cwd: process.cwd()
1237
+ });
1238
+ const args = {
1239
+ planId,
1240
+ phaseId,
1241
+ status
1242
+ };
1243
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1244
+ if (opts.message != null) args["message"] = opts.message;
1245
+ const entry = await appendEntry(projectDir, { tool: "update_phase_status", args });
1246
+ if (deps.output.mode === "json") {
1247
+ printResult({ queued: true, id: entry.id }, "json");
1248
+ return;
1249
+ }
1250
+ process.stdout.write(`Queued update_phase_status.
1251
+ `);
1252
+ }
1253
+ );
1254
+ withQueueDir(
1255
+ 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")
1256
+ ).action(
1257
+ async (planId, phaseId, verdict, opts) => {
1258
+ if (!TEST_VERDICTS.includes(verdict)) {
1259
+ throw new CliError(
1260
+ `Invalid verdict "${verdict}". Valid values: ${TEST_VERDICTS.join(", ")}`,
1261
+ 2
1262
+ );
1263
+ }
1264
+ let round;
1265
+ if (opts.round != null) {
1266
+ if (!/^[1-9]\d*$/.test(opts.round)) {
1267
+ throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
1268
+ }
1269
+ const parsed = Number(opts.round);
1270
+ if (!Number.isInteger(parsed) || parsed < 1) {
1271
+ throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
1272
+ }
1273
+ round = parsed;
1274
+ }
1275
+ const projectDir = resolveQueueDir({
1276
+ flagDir: opts.queueDir,
1277
+ env: process.env,
1278
+ cwd: process.cwd()
1279
+ });
1280
+ const args = {
1281
+ planId,
1282
+ phaseId,
1283
+ verdict
1284
+ };
1285
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1286
+ if (round != null) args["round"] = round;
1287
+ if (opts.summary != null) args["summary"] = opts.summary;
1288
+ const entry = await appendEntry(projectDir, { tool: "record_phase_test_result", args });
1289
+ if (deps.output.mode === "json") {
1290
+ printResult({ queued: true, id: entry.id }, "json");
1291
+ return;
1292
+ }
1293
+ process.stdout.write(`Queued record_phase_test_result.
1294
+ `);
1295
+ }
1296
+ );
1297
+ withQueueDir(
1298
+ queue.command("plan-status <planId> <status>").description("Queue a plan status update (offline, no token required).").option("--message <text>", "Optional message")
1299
+ ).action(
1300
+ async (planId, status, opts) => {
1301
+ if (!PLAN_STATUSES.includes(status)) {
1302
+ throw new CliError(
1303
+ `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
1304
+ 2
1305
+ );
1306
+ }
1307
+ const projectDir = resolveQueueDir({
1308
+ flagDir: opts.queueDir,
1309
+ env: process.env,
1310
+ cwd: process.cwd()
1311
+ });
1312
+ const args = {
1313
+ planId,
1314
+ status
1315
+ };
1316
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1317
+ if (opts.message != null) args["message"] = opts.message;
1318
+ const entry = await appendEntry(projectDir, { tool: "update_plan_status", args });
1319
+ if (deps.output.mode === "json") {
1320
+ printResult({ queued: true, id: entry.id }, "json");
1321
+ return;
1322
+ }
1323
+ process.stdout.write(`Queued update_plan_status.
1324
+ `);
1325
+ }
1326
+ );
1327
+ withQueueDir(
1328
+ queue.command("plan-review <planId> <verdict>").description("Queue a plan review (offline, no token required).").option("--summary <text>", "Review summary")
1329
+ ).action(
1330
+ async (planId, verdict, opts) => {
1331
+ if (!REVIEW_VERDICTS.includes(verdict)) {
1332
+ throw new CliError(
1333
+ `Invalid verdict "${verdict}". Valid values: ${REVIEW_VERDICTS.join(", ")}`,
1334
+ 2
1335
+ );
1336
+ }
1337
+ const projectDir = resolveQueueDir({
1338
+ flagDir: opts.queueDir,
1339
+ env: process.env,
1340
+ cwd: process.cwd()
1341
+ });
1342
+ const args = {
1343
+ planId,
1344
+ verdict
1345
+ };
1346
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
1347
+ if (opts.summary != null) args["summary"] = opts.summary;
1348
+ const entry = await appendEntry(projectDir, { tool: "record_plan_review", args });
1349
+ if (deps.output.mode === "json") {
1350
+ printResult({ queued: true, id: entry.id }, "json");
1351
+ return;
1352
+ }
1353
+ process.stdout.write(`Queued record_plan_review.
1354
+ `);
1355
+ }
1356
+ );
1357
+ }
1358
+
1359
+ // src/detach.ts
1360
+ import { spawn } from "child_process";
1361
+ function spawnDetachedFlush(scriptPath, queueDir) {
1362
+ const extraArgs = queueDir != null ? ["--queue-dir", queueDir] : [];
1363
+ const child = spawn(
1364
+ process.execPath,
1365
+ [scriptPath, "flush", ...extraArgs],
1366
+ {
1367
+ detached: true,
1368
+ stdio: "ignore",
1369
+ env: process.env
1370
+ }
1371
+ );
1372
+ child.unref();
1373
+ return child.pid;
1374
+ }
1375
+
1376
+ // src/commands/flush.ts
1377
+ async function runFlushWorker(deps, projectDir) {
1378
+ const lock = await acquireLock(projectDir);
1379
+ if (lock === null) {
1380
+ process.stdout.write("Another flush is running.\n");
1381
+ return { flushed: 0, remaining: 0, failed: 0 };
1382
+ }
1383
+ try {
1384
+ const entries = await readQueue(projectDir);
1385
+ if (entries.length === 0) {
1386
+ process.stdout.write("Nothing to flush.\n");
1387
+ return { flushed: 0, remaining: 0, failed: 0 };
1388
+ }
1389
+ if (deps.settings.token == null) {
1390
+ await appendLog(projectDir, "warn", "flush skipped: no token configured");
1391
+ return { flushed: 0, remaining: entries.length, failed: 0 };
1392
+ }
1393
+ let flushed = 0;
1394
+ let remaining = entries.slice();
1395
+ for (const entry of entries) {
1396
+ try {
1397
+ await deps.caller.call(entry.tool, entry.args);
1398
+ } catch (err) {
1399
+ const cliErr = err instanceof CliError ? err : new CliError(String(err), 5);
1400
+ await appendLog(
1401
+ projectDir,
1402
+ "error",
1403
+ `${entry.tool} id=${entry.id} exit=${cliErr.exitCode} ${cliErr.message}`
1404
+ );
1405
+ return { flushed, remaining: remaining.length, failed: 1 };
1406
+ }
1407
+ flushed++;
1408
+ remaining = remaining.slice(1);
1409
+ await atomicRewriteQueue(projectDir, remaining);
1410
+ }
1411
+ return { flushed, remaining: 0, failed: 0 };
1412
+ } finally {
1413
+ await lock.release();
1414
+ }
1415
+ }
1416
+ function register7(program, deps) {
1417
+ 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) => {
1418
+ const projectDir = resolveQueueDir({
1419
+ flagDir: opts.queueDir,
1420
+ env: process.env,
1421
+ cwd: process.cwd()
1422
+ });
1423
+ if (opts.detach) {
1424
+ spawnDetachedFlush(process.argv[1], opts.queueDir);
1425
+ return;
1426
+ }
1427
+ try {
1428
+ const summary = await runFlushWorker(deps, projectDir);
1429
+ if (deps.output.mode === "json") {
1430
+ process.stdout.write(JSON.stringify(summary) + "\n");
1431
+ } else {
1432
+ if (summary.flushed > 0 || summary.failed > 0) {
1433
+ process.stdout.write(
1434
+ `Flushed ${summary.flushed}, remaining ${summary.remaining}, failed ${summary.failed}.
1435
+ `
1436
+ );
1437
+ }
1438
+ }
1439
+ } catch (err) {
1440
+ const msg = err instanceof Error ? err.message : String(err);
1441
+ await appendLog(projectDir, "error", `unexpected flush error: ${msg}`);
1442
+ process.stderr.write(`Flush error (logged to .nolto/flush.log): ${msg}
1443
+ `);
1444
+ }
1445
+ });
1446
+ }
1447
+
1026
1448
  // src/program.ts
1027
1449
  function stripCommanderErrorPrefix(msg) {
1028
1450
  return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
@@ -1037,15 +1459,17 @@ function buildProgram(deps) {
1037
1459
  register4(program, deps);
1038
1460
  register5(program, deps);
1039
1461
  register6(program, deps);
1462
+ registerQueue(program, deps);
1463
+ register7(program, deps);
1040
1464
  return program;
1041
1465
  }
1042
1466
 
1043
1467
  // src/index.ts
1044
- var __dirname2 = path4.dirname(fileURLToPath2(import.meta.url));
1468
+ var __dirname2 = path5.dirname(fileURLToPath2(import.meta.url));
1045
1469
  var require2 = createRequire2(import.meta.url);
1046
1470
  function getVersion() {
1047
1471
  try {
1048
- const pkgPath = path4.resolve(__dirname2, "../package.json");
1472
+ const pkgPath = path5.resolve(__dirname2, "../package.json");
1049
1473
  const pkg = require2(pkgPath);
1050
1474
  return pkg.version ?? "0.0.0";
1051
1475
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for Nolto — register plans and update progress from your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",