@nolto/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +140 -0
  2. package/dist/index.js +1087 -0
  3. package/package.json +51 -0
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # @nolto/cli
2
+
3
+ A thin MCP client CLI for [Nolto](https://nolto.app) — register plans and update progress from your terminal. The installed command is `nolto`.
4
+
5
+ ## Quick Start
6
+
7
+ Run once without installing:
8
+
9
+ ```bash
10
+ npx @nolto/cli init
11
+ ```
12
+
13
+ Or install it (globally or as a devDependency) to get the `nolto` command:
14
+
15
+ ```bash
16
+ npm install -g @nolto/cli
17
+ nolto init
18
+ ```
19
+
20
+ Follow the prompts to configure your API token and default project. Then verify:
21
+
22
+ ```bash
23
+ nolto whoami
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ ### Setup
29
+
30
+ ```bash
31
+ nolto init # Interactive setup
32
+ nolto whoami # Show current auth/config state
33
+ ```
34
+
35
+ ### Project Management
36
+
37
+ ```bash
38
+ nolto project list
39
+ nolto project register <name> [--description <text>] [--repository-url <url>]
40
+ nolto project set-default <projectId>
41
+ nolto project set-default <projectId> --local # Config file only, no MCP call
42
+ ```
43
+
44
+ ### Plan Management
45
+
46
+ ```bash
47
+ nolto plan list [--status not_started|in_progress|done|discarded]
48
+ nolto plan get <planId>
49
+ nolto plan register --file PLAN.md [--title <text>] [--status <status>] \
50
+ [--planned-start <ISO>] [--planned-end <ISO>] \
51
+ [--phases <json>] [--doc kind=path ...] \
52
+ [--source-url <url>] [--source-hash <hex>] [--no-git]
53
+ nolto plan status <planId> <status> [--message <text>]
54
+ nolto plan review <planId> go|no_go [--summary <text>]
55
+ ```
56
+
57
+ ### Phase Management
58
+
59
+ ```bash
60
+ nolto phase status <planId> <phaseId> <status> [--message <text>]
61
+ nolto phase test <planId> <phaseId> passed|failed|skipped \
62
+ [--round <n>] [--summary <text>]
63
+ ```
64
+
65
+ ### Document Upload
66
+
67
+ ```bash
68
+ nolto doc upload <planId> --file <path> --kind plan|final_report|review_report|test_report|other \
69
+ [--phase <phaseId>] [--filename <name>]
70
+ ```
71
+
72
+ ## Configuration
73
+
74
+ ### Config File
75
+
76
+ Location: `~/.config/nolto/config.json` (or `$XDG_CONFIG_HOME/nolto/config.json`), mode 0600.
77
+
78
+ ```json
79
+ {
80
+ "token": "nolto_user_...",
81
+ "baseUrl": "https://nolto.app",
82
+ "defaultProjectId": "00000000-0000-0000-0000-000000000001"
83
+ }
84
+ ```
85
+
86
+ ### Environment Variables
87
+
88
+ | Variable | Description |
89
+ |---|---|
90
+ | `NOLTO_TOKEN` | API token |
91
+ | `NOLTO_BASE_URL` | Base URL (default: `https://nolto.app`) |
92
+ | `NOLTO_PROJECT` | Default project ID |
93
+
94
+ **Precedence**: CLI flags > environment variables > config file > defaults
95
+
96
+ ## Exit Codes
97
+
98
+ | Code | Meaning |
99
+ |---|---|
100
+ | 0 | Success |
101
+ | 1 | MCP tool error (`result.isError: true` or JSON-RPC error) |
102
+ | 2 | Local input validation error (bad args, file errors, etc.) |
103
+ | 3 | Auth error (no token / 401 / 403) |
104
+ | 4 | Rate limit (429) — message includes Retry-After seconds |
105
+ | 5 | Network / DNS / connection error |
106
+
107
+ ## JSON Output
108
+
109
+ Add `--json` for machine-readable output (results to stdout, errors to stderr):
110
+
111
+ ```bash
112
+ nolto plan list --json | jq '.[].id'
113
+ ```
114
+
115
+ Errors are written to stderr as a structured envelope:
116
+
117
+ ```json
118
+ {
119
+ "error": {
120
+ "message": "Unauthorized",
121
+ "exitCode": 3,
122
+ "status": 401,
123
+ "hint": "Run `nolto init` to configure a token, or set the NOLTO_TOKEN environment variable."
124
+ }
125
+ }
126
+ ```
127
+
128
+ Fields: `message` (string), `exitCode` (number), `status` (HTTP status when known, optional), `hint` (optional guidance).
129
+
130
+ ## Rate Limits
131
+
132
+ The MCP server enforces **60 requests/minute per user**. Each CLI command uses approximately 3 HTTP round-trips (initialize + initialized + tools/call), giving an effective throughput of **~20 commands/minute**.
133
+
134
+ ## SDK Note
135
+
136
+ This CLI uses `@modelcontextprotocol/sdk ^1.29.0` for Streamable HTTP transport. Keep in lockstep with the server SDK version for protocol compatibility.
137
+
138
+ ## License
139
+
140
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,1087 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createRequire } from "module";
5
+ import { fileURLToPath } from "url";
6
+ import path3 from "path";
7
+ import { CommanderError } from "commander";
8
+
9
+ // src/config.ts
10
+ import { readFile, writeFile, mkdir, stat } from "fs/promises";
11
+ import os from "os";
12
+ import path from "path";
13
+ import { z } from "zod";
14
+
15
+ // src/errors.ts
16
+ var CliError = class extends Error {
17
+ constructor(message, exitCode, hint, status) {
18
+ super(message);
19
+ this.exitCode = exitCode;
20
+ this.hint = hint;
21
+ this.status = status;
22
+ this.name = "CliError";
23
+ }
24
+ exitCode;
25
+ hint;
26
+ status;
27
+ };
28
+ function mapHttpStatusToCliError(status, retryAfter, wwwAuthenticate) {
29
+ if (status === 401) {
30
+ const detail = wwwAuthenticate ? ` (${wwwAuthenticate})` : "";
31
+ return new CliError(
32
+ `Unauthorized${detail}`,
33
+ 3,
34
+ "Run `nolto init` to configure a token, or set the NOLTO_TOKEN environment variable.",
35
+ status
36
+ );
37
+ }
38
+ if (status === 403) {
39
+ return new CliError(
40
+ "Forbidden \u2014 insufficient scope",
41
+ 3,
42
+ "Ensure your API token has mcp:read and mcp:write scopes.",
43
+ status
44
+ );
45
+ }
46
+ if (status === 429) {
47
+ const waitMsg = retryAfter ? ` Retry-After: ${retryAfter}s` : "";
48
+ return new CliError(`Rate limit exceeded.${waitMsg}`, 4, void 0, status);
49
+ }
50
+ return new CliError(`Server returned HTTP ${status}`, 5, void 0, status);
51
+ }
52
+ function isNetworkError(err) {
53
+ if (!(err instanceof Error)) {
54
+ return false;
55
+ }
56
+ const cause = err.cause;
57
+ const causeCode = cause != null && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
58
+ const networkCodes = ["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EAI_AGAIN"];
59
+ if (networkCodes.some((c) => causeCode === c)) {
60
+ return true;
61
+ }
62
+ if (err.message.includes("fetch failed") || err.message.includes("ECONNREFUSED") || err.message.includes("ENOTFOUND")) {
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // src/constants.ts
69
+ var PLAN_STATUSES = ["not_started", "in_progress", "done", "discarded"];
70
+ var TEST_VERDICTS = ["passed", "failed", "skipped"];
71
+ var REVIEW_VERDICTS = ["go", "no_go"];
72
+ var PLAN_DOCUMENT_KINDS = ["plan", "final_report", "review_report", "test_report", "other"];
73
+ var PLAN_TITLE_MAX = 500;
74
+ var PLAN_CONTENT_MAX = 5e4;
75
+ var PHASES_MAX = 50;
76
+ var DOCUMENT_MAX_BYTES = 2 * 1024 * 1024;
77
+ var DOCUMENT_FILENAME_MAX = 255;
78
+ var DEFAULT_BASE_URL = "https://nolto.app";
79
+ var CLI_USER_AGENT_NAME = "nolto-cli";
80
+
81
+ // src/config.ts
82
+ var configSchema = z.object({
83
+ token: z.string().min(1).optional(),
84
+ baseUrl: z.string().url().optional(),
85
+ defaultProjectId: z.string().uuid().optional()
86
+ }).strict();
87
+ function getConfigDir(env) {
88
+ const xdg = env["XDG_CONFIG_HOME"];
89
+ const base = xdg != null && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
90
+ return path.join(base, "nolto");
91
+ }
92
+ function getConfigPath(env) {
93
+ return path.join(getConfigDir(env), "config.json");
94
+ }
95
+ async function loadConfigFile(filePath) {
96
+ try {
97
+ const info = await stat(filePath);
98
+ if ((info.mode & 63) !== 0) {
99
+ process.stderr.write(`Warning: ${filePath} has loose permissions (mode ${(info.mode & 511).toString(8)}). Consider running: chmod 600 "${filePath}"
100
+ `);
101
+ }
102
+ } catch (err) {
103
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
104
+ if (code === "ENOENT") {
105
+ return null;
106
+ }
107
+ throw new CliError(`Cannot stat config file: ${filePath}: ${String(err)}`, 2);
108
+ }
109
+ let raw;
110
+ try {
111
+ raw = await readFile(filePath, "utf8");
112
+ } catch (err) {
113
+ throw new CliError(`Cannot read config file: ${filePath}: ${String(err)}`, 2);
114
+ }
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(raw);
118
+ } catch {
119
+ throw new CliError(`Malformed config at ${filePath}`, 2);
120
+ }
121
+ const result = configSchema.safeParse(parsed);
122
+ if (!result.success) {
123
+ const issue = result.error.issues[0];
124
+ let fieldDesc;
125
+ if (issue?.code === "unrecognized_keys" && "keys" in issue) {
126
+ const keys = issue.keys;
127
+ fieldDesc = `unrecognized key(s): ${keys.join(", ")}`;
128
+ } else {
129
+ const fieldPath = issue?.path.join(".") ?? "";
130
+ fieldDesc = fieldPath.length > 0 ? `field "${fieldPath}"` : "unknown field";
131
+ }
132
+ throw new CliError(`Invalid config at ${filePath}: ${fieldDesc}`, 2);
133
+ }
134
+ return result.data;
135
+ }
136
+ async function saveConfigFile(filePath, value) {
137
+ await mkdir(path.dirname(filePath), { recursive: true, mode: 448 });
138
+ await writeFile(filePath, JSON.stringify(value, null, 2) + "\n", { mode: 384 });
139
+ }
140
+ function resolveSettings(args) {
141
+ const { flags, env, file } = args;
142
+ let token;
143
+ let tokenSource = "none";
144
+ if (flags.token != null && flags.token.length > 0) {
145
+ token = flags.token;
146
+ tokenSource = "flag";
147
+ } else if (env["NOLTO_TOKEN"] != null && env["NOLTO_TOKEN"].length > 0) {
148
+ token = env["NOLTO_TOKEN"];
149
+ tokenSource = "env";
150
+ } else if (file?.token != null && file.token.length > 0) {
151
+ token = file.token;
152
+ tokenSource = "file";
153
+ }
154
+ let baseUrl;
155
+ let baseUrlSource = "default";
156
+ if (flags.baseUrl != null && flags.baseUrl.length > 0) {
157
+ baseUrl = flags.baseUrl;
158
+ baseUrlSource = "flag";
159
+ } else if (env["NOLTO_BASE_URL"] != null && env["NOLTO_BASE_URL"].length > 0) {
160
+ baseUrl = env["NOLTO_BASE_URL"];
161
+ baseUrlSource = "env";
162
+ } else if (file?.baseUrl != null && file.baseUrl.length > 0) {
163
+ baseUrl = file.baseUrl;
164
+ baseUrlSource = "file";
165
+ } else {
166
+ baseUrl = DEFAULT_BASE_URL;
167
+ }
168
+ let defaultProjectId;
169
+ let projectSource = "none";
170
+ if (flags.project != null && flags.project.length > 0) {
171
+ defaultProjectId = flags.project;
172
+ projectSource = "flag";
173
+ } else if (env["NOLTO_PROJECT"] != null && env["NOLTO_PROJECT"].length > 0) {
174
+ defaultProjectId = env["NOLTO_PROJECT"];
175
+ projectSource = "env";
176
+ } else if (file?.defaultProjectId != null && file.defaultProjectId.length > 0) {
177
+ defaultProjectId = file.defaultProjectId;
178
+ projectSource = "file";
179
+ }
180
+ return {
181
+ token,
182
+ baseUrl,
183
+ defaultProjectId,
184
+ source: { token: tokenSource, baseUrl: baseUrlSource, project: projectSource }
185
+ };
186
+ }
187
+ function maskToken(token) {
188
+ return "\u2026" + token.slice(-4);
189
+ }
190
+
191
+ // src/mcp.ts
192
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
193
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
194
+ function buildMcpEndpoint(baseUrl) {
195
+ const normalized = baseUrl.replace(/\/+$/, "");
196
+ return new URL(normalized + "/mcp");
197
+ }
198
+ function createMcpCaller(opts) {
199
+ const { baseUrl, token, version } = opts;
200
+ return {
201
+ async call(toolName, args) {
202
+ const capture = { lastNonOk: null };
203
+ const capturingFetch = async (input, init) => {
204
+ const res = await fetch(input, init);
205
+ if (!res.ok) {
206
+ capture.lastNonOk = {
207
+ status: res.status,
208
+ retryAfter: res.headers.get("retry-after") ?? void 0,
209
+ wwwAuthenticate: res.headers.get("www-authenticate") ?? void 0
210
+ };
211
+ }
212
+ return res;
213
+ };
214
+ const transport = new StreamableHTTPClientTransport(buildMcpEndpoint(baseUrl), {
215
+ requestInit: {
216
+ headers: { Authorization: `Bearer ${token}` }
217
+ },
218
+ fetch: capturingFetch
219
+ });
220
+ const client = new Client({ name: CLI_USER_AGENT_NAME, version });
221
+ try {
222
+ await client.connect(transport);
223
+ const result = await client.callTool({ name: toolName, arguments: args });
224
+ if (result.isError === true) {
225
+ const content2 = result.content;
226
+ const firstText = Array.isArray(content2) && content2.length > 0 ? content2[0]?.text : void 0;
227
+ throw new CliError(firstText ?? "Tool error", 1);
228
+ }
229
+ const content = result.content;
230
+ const firstItem = Array.isArray(content) && content.length > 0 ? content[0] : void 0;
231
+ const rawText = firstItem?.text;
232
+ if (rawText == null) {
233
+ return result;
234
+ }
235
+ try {
236
+ return JSON.parse(rawText);
237
+ } catch {
238
+ return rawText;
239
+ }
240
+ } catch (err) {
241
+ if (err instanceof CliError) {
242
+ throw err;
243
+ }
244
+ if (capture.lastNonOk != null) {
245
+ throw mapHttpStatusToCliError(
246
+ capture.lastNonOk.status,
247
+ capture.lastNonOk.retryAfter,
248
+ capture.lastNonOk.wwwAuthenticate
249
+ );
250
+ }
251
+ if (isNetworkError(err)) {
252
+ throw new CliError(`Cannot reach ${baseUrl}`, 5);
253
+ }
254
+ if (err instanceof Error && err.message) {
255
+ throw new CliError(err.message, 1);
256
+ }
257
+ throw new CliError(String(err), 1);
258
+ } finally {
259
+ try {
260
+ await client.close();
261
+ } catch {
262
+ }
263
+ }
264
+ }
265
+ };
266
+ }
267
+
268
+ // src/program.ts
269
+ import { Command } from "commander";
270
+
271
+ // src/commands/init.ts
272
+ import readline from "readline/promises";
273
+ function register(program, _deps) {
274
+ program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
275
+ const configPath = getConfigPath(process.env);
276
+ if (!opts.force) {
277
+ let existing = null;
278
+ try {
279
+ existing = await loadConfigFile(configPath);
280
+ } catch {
281
+ }
282
+ if (existing != null) {
283
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
284
+ try {
285
+ const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
286
+ if (answer.trim().toLowerCase() !== "y") {
287
+ process.stdout.write("Cancelled.\n");
288
+ return;
289
+ }
290
+ } finally {
291
+ rl2.close();
292
+ }
293
+ }
294
+ }
295
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
296
+ let token = "";
297
+ try {
298
+ const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
299
+ const baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
300
+ token = await promptHidden(rl, "API token: ");
301
+ if (token.length === 0) {
302
+ throw new CliError("Token is required.", 2);
303
+ }
304
+ const caller = createMcpCaller({ baseUrl, token, version: "0.1.0" });
305
+ let projects = [];
306
+ try {
307
+ const result = await caller.call("list_projects", {});
308
+ if (Array.isArray(result)) {
309
+ projects = result;
310
+ }
311
+ } catch (err) {
312
+ if (err instanceof CliError && err.exitCode === 3) {
313
+ throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
314
+ }
315
+ throw err;
316
+ }
317
+ let defaultProjectId;
318
+ let defaultProjectName;
319
+ if (projects.length > 0) {
320
+ process.stdout.write("\nProjects:\n");
321
+ projects.forEach((p, i) => {
322
+ process.stdout.write(` (${i + 1}) ${p.name} \u2014 ${p.id}
323
+ `);
324
+ });
325
+ const pick = await rl.question("Default project number (or skip): ");
326
+ const num = parseInt(pick.trim(), 10);
327
+ if (!isNaN(num) && num >= 1 && num <= projects.length) {
328
+ defaultProjectId = projects[num - 1].id;
329
+ defaultProjectName = projects[num - 1].name;
330
+ }
331
+ }
332
+ await saveConfigFile(configPath, {
333
+ token,
334
+ baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
335
+ defaultProjectId
336
+ });
337
+ const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
338
+ process.stdout.write(`
339
+ Saved ${configPath}
340
+ `);
341
+ process.stdout.write(`baseUrl: ${baseUrl}
342
+ `);
343
+ process.stdout.write(`token: ${maskToken(token)} (verified)
344
+ `);
345
+ process.stdout.write(`defaultProject: ${projectDisplay}
346
+ `);
347
+ } finally {
348
+ rl.close();
349
+ }
350
+ });
351
+ }
352
+ async function promptHidden(rl, prompt) {
353
+ const iface = rl;
354
+ const originalWrite = iface._writeToOutput?.bind(rl);
355
+ let muted = false;
356
+ iface._writeToOutput = (str) => {
357
+ if (muted) {
358
+ if (str !== "\r\n" && str !== "\n" && str !== "\r" && !str.startsWith("\x1B")) {
359
+ process.stdout.write("*");
360
+ } else {
361
+ originalWrite?.(str);
362
+ }
363
+ return;
364
+ }
365
+ originalWrite?.(str);
366
+ };
367
+ muted = true;
368
+ const value = await rl.question(prompt);
369
+ muted = false;
370
+ process.stdout.write("\n");
371
+ iface._writeToOutput = originalWrite;
372
+ return value;
373
+ }
374
+
375
+ // src/output.ts
376
+ function printResult(value, mode2, opts = {}) {
377
+ const out = opts.stream ?? process.stdout;
378
+ if (mode2 === "json") {
379
+ out.write(JSON.stringify(value, null, 2) + "\n");
380
+ } else {
381
+ out.write(formatValue(value) + "\n");
382
+ }
383
+ }
384
+ function printError(err, mode2) {
385
+ if (mode2 === "json") {
386
+ const envelope = {
387
+ error: {
388
+ message: err.message,
389
+ exitCode: err.exitCode,
390
+ ...err.status != null ? { status: err.status } : {},
391
+ ...err.hint != null ? { hint: err.hint } : {}
392
+ }
393
+ };
394
+ process.stderr.write(JSON.stringify(envelope, null, 2) + "\n");
395
+ } else {
396
+ process.stderr.write(`Error: ${err.message}
397
+ `);
398
+ if (err.hint != null) {
399
+ process.stderr.write(`Hint: ${err.hint}
400
+ `);
401
+ }
402
+ }
403
+ }
404
+ function formatTable(rows, columns) {
405
+ if (rows.length === 0) {
406
+ return "(empty)";
407
+ }
408
+ const maxKeyLen = columns.reduce((m, c) => Math.max(m, c.length), 0);
409
+ return rows.map(
410
+ (row) => columns.map((col) => {
411
+ const val = row[col] ?? "";
412
+ return ` ${col.padEnd(maxKeyLen)}: ${val}`;
413
+ }).join("\n")
414
+ ).join("\n\n");
415
+ }
416
+ function formatRecord(record) {
417
+ const keys = Object.keys(record);
418
+ const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
419
+ return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${record[k] ?? ""}`).join("\n");
420
+ }
421
+ function formatValue(value) {
422
+ if (value === null || value === void 0) {
423
+ return "";
424
+ }
425
+ if (typeof value === "string") {
426
+ return value;
427
+ }
428
+ if (typeof value === "number" || typeof value === "boolean") {
429
+ return String(value);
430
+ }
431
+ if (Array.isArray(value)) {
432
+ return value.map((v) => formatValue(v)).join("\n");
433
+ }
434
+ if (typeof value === "object") {
435
+ const obj = value;
436
+ const keys = Object.keys(obj);
437
+ const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
438
+ return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${formatValue(obj[k])}`).join("\n");
439
+ }
440
+ return JSON.stringify(value, null, 2);
441
+ }
442
+
443
+ // src/commands/whoami.ts
444
+ function register2(program, deps) {
445
+ program.command("whoami").description("Show the current authentication and configuration state.").action(async () => {
446
+ const { settings, output, configPath } = deps;
447
+ const mode2 = output.mode;
448
+ let projectCount;
449
+ if (settings.token != null) {
450
+ try {
451
+ const result = await deps.caller.call("list_projects", {});
452
+ if (Array.isArray(result)) {
453
+ projectCount = result.length;
454
+ }
455
+ } catch {
456
+ }
457
+ }
458
+ if (mode2 === "json") {
459
+ printResult(
460
+ {
461
+ baseUrl: settings.baseUrl,
462
+ baseUrlSource: settings.source.baseUrl,
463
+ configPath,
464
+ tokenLast4: settings.token != null ? settings.token.slice(-4) : null,
465
+ tokenSource: settings.source.token,
466
+ defaultProject: settings.defaultProjectId ?? null,
467
+ defaultProjectSource: settings.source.project,
468
+ projects: projectCount ?? null
469
+ },
470
+ mode2
471
+ );
472
+ } else {
473
+ const tokenDisplay = settings.token != null ? `${maskToken(settings.token)} (source: ${settings.source.token})` : "(not configured)";
474
+ const lines = [
475
+ ["baseUrl", `${settings.baseUrl} (source: ${settings.source.baseUrl})`],
476
+ ["configPath", configPath],
477
+ ["token", tokenDisplay],
478
+ ["defaultProject", `${settings.defaultProjectId ?? "none"} (source: ${settings.source.project})`],
479
+ ["projects", projectCount != null ? String(projectCount) : "(no token)"]
480
+ ];
481
+ const maxKey = lines.reduce((m, [k]) => Math.max(m, k.length), 0);
482
+ for (const [key, val] of lines) {
483
+ process.stdout.write(`${key.padEnd(maxKey)}: ${val}
484
+ `);
485
+ }
486
+ }
487
+ });
488
+ }
489
+
490
+ // src/commands/project.ts
491
+ function assertToken(token) {
492
+ if (token == null) {
493
+ throw new CliError(
494
+ "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
495
+ 3
496
+ );
497
+ }
498
+ }
499
+ function register3(program, deps) {
500
+ const project = program.command("project").description("Manage projects.");
501
+ project.command("list").description("List all projects.").action(async () => {
502
+ assertToken(deps.settings.token);
503
+ const result = await deps.caller.call("list_projects", {});
504
+ if (deps.output.mode === "json") {
505
+ printResult(result, "json");
506
+ return;
507
+ }
508
+ const rows = Array.isArray(result) ? result : [];
509
+ const defaultId = deps.settings.defaultProjectId;
510
+ const tableRows = rows.map((p) => ({
511
+ id: p.id ?? "",
512
+ name: p.name ?? "",
513
+ role: p.role ?? "",
514
+ default: p.id === defaultId ? "yes" : ""
515
+ }));
516
+ process.stdout.write(formatTable(tableRows, ["id", "name", "role", "default"]) + "\n");
517
+ });
518
+ project.command("register <name>").description("Register a new project.").option("--description <text>", "Project description").option("--repository-url <url>", "Repository URL").action(async (name, opts) => {
519
+ assertToken(deps.settings.token);
520
+ const args = { name };
521
+ if (opts.description != null) args["description"] = opts.description;
522
+ if (opts.repositoryUrl != null) args["repositoryUrl"] = opts.repositoryUrl;
523
+ const result = await deps.caller.call("register_project", args);
524
+ if (deps.output.mode === "json") {
525
+ printResult(result, "json");
526
+ return;
527
+ }
528
+ const r = result;
529
+ const id = r?.id ?? "";
530
+ const registeredName = r?.name ?? name;
531
+ process.stdout.write(`Registered ${registeredName} (${id})
532
+ `);
533
+ });
534
+ 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) => {
535
+ if (opts.local) {
536
+ const configPath = getConfigPath(process.env);
537
+ const existing = await loadConfigFile(configPath).catch(() => null);
538
+ await saveConfigFile(configPath, { ...existing ?? {}, defaultProjectId: projectId });
539
+ } else {
540
+ assertToken(deps.settings.token);
541
+ await deps.caller.call("set_default_project", { projectId });
542
+ }
543
+ process.stdout.write(`Default project set to ${projectId}.
544
+ `);
545
+ });
546
+ }
547
+
548
+ // src/commands/plan-register.ts
549
+ import { execFile } from "child_process";
550
+ import { promisify } from "util";
551
+ import { z as z2 } from "zod";
552
+
553
+ // src/fsx.ts
554
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
555
+ import path2 from "path";
556
+ async function readPlanFile(filePath) {
557
+ const absPath = path2.resolve(filePath);
558
+ let content;
559
+ try {
560
+ content = await readFile2(absPath, "utf8");
561
+ } catch (err) {
562
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
563
+ if (code === "ENOENT") {
564
+ throw new CliError(`File not found: ${absPath}`, 2);
565
+ }
566
+ throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
567
+ }
568
+ const trimmed = content.trim();
569
+ if (trimmed.length === 0) {
570
+ throw new CliError(`Plan file is empty: ${absPath}`, 2);
571
+ }
572
+ if (trimmed.length > PLAN_CONTENT_MAX) {
573
+ throw new CliError(
574
+ `Plan file exceeds ${PLAN_CONTENT_MAX} characters (${trimmed.length}): ${absPath}`,
575
+ 2
576
+ );
577
+ }
578
+ const stem = path2.basename(absPath, path2.extname(absPath));
579
+ return { content: trimmed, sourcePath: absPath, titleFallback: stem };
580
+ }
581
+ async function readDocFile(filePath) {
582
+ const absPath = path2.resolve(filePath);
583
+ let fileSize;
584
+ try {
585
+ const info = await stat2(absPath);
586
+ fileSize = info.size;
587
+ } catch (err) {
588
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
589
+ if (code === "ENOENT") {
590
+ throw new CliError(`File not found: ${absPath}`, 2);
591
+ }
592
+ throw new CliError(`Cannot stat file: ${absPath}: ${String(err)}`, 2);
593
+ }
594
+ if (fileSize > DOCUMENT_MAX_BYTES) {
595
+ throw new CliError(`Document exceeds 2 MiB: ${absPath}`, 2);
596
+ }
597
+ let buf;
598
+ try {
599
+ buf = await readFile2(absPath);
600
+ } catch (err) {
601
+ throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
602
+ }
603
+ const isBinary = buf.includes(0) || !isUtf8RoundTrip(buf);
604
+ const rawFilename = path2.basename(absPath);
605
+ const filename = rawFilename.slice(0, DOCUMENT_FILENAME_MAX);
606
+ if (isBinary) {
607
+ return { content: buf.toString("base64"), encoding: "base64", filename };
608
+ }
609
+ return { content: buf.toString("utf8"), encoding: "utf8", filename };
610
+ }
611
+ function isUtf8RoundTrip(buf) {
612
+ try {
613
+ const str = buf.toString("utf8");
614
+ const reEncoded = Buffer.from(str, "utf8");
615
+ if (reEncoded.length !== buf.length) {
616
+ return false;
617
+ }
618
+ for (let i = 0; i < buf.length; i++) {
619
+ if (buf[i] !== reEncoded[i]) {
620
+ return false;
621
+ }
622
+ }
623
+ return true;
624
+ } catch {
625
+ return false;
626
+ }
627
+ }
628
+
629
+ // src/markdown.ts
630
+ function extractTitle(md) {
631
+ const lines = md.split("\n");
632
+ let inFence = false;
633
+ let fenceChar = "";
634
+ for (const line of lines) {
635
+ const trimmed = line.trimStart();
636
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
637
+ const ch = trimmed[0];
638
+ if (!inFence) {
639
+ inFence = true;
640
+ fenceChar = ch;
641
+ } else if (ch === fenceChar) {
642
+ inFence = false;
643
+ fenceChar = "";
644
+ }
645
+ continue;
646
+ }
647
+ if (inFence) {
648
+ continue;
649
+ }
650
+ if (trimmed.startsWith("# ")) {
651
+ const title = trimmed.slice(2).trim();
652
+ if (title.length > 0) {
653
+ return title;
654
+ }
655
+ }
656
+ }
657
+ return null;
658
+ }
659
+
660
+ // src/commands/plan-register.ts
661
+ var execFileAsync = promisify(execFile);
662
+ function assertToken2(token) {
663
+ if (token == null) {
664
+ throw new CliError(
665
+ "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
666
+ 3
667
+ );
668
+ }
669
+ }
670
+ var phaseArraySchema = z2.array(
671
+ z2.object({
672
+ title: z2.string().min(1),
673
+ content: z2.string().min(1),
674
+ status: z2.string().optional(),
675
+ plannedStartAt: z2.string().optional(),
676
+ plannedEndAt: z2.string().optional()
677
+ })
678
+ ).max(PHASES_MAX);
679
+ function collect(val, prev) {
680
+ return [...prev, val];
681
+ }
682
+ function resolveUrl(detailUrl, baseUrl) {
683
+ if (detailUrl == null) return "";
684
+ try {
685
+ return new URL(detailUrl, baseUrl).href;
686
+ } catch {
687
+ return detailUrl;
688
+ }
689
+ }
690
+ async function getGitContributor() {
691
+ const result = {};
692
+ try {
693
+ const { stdout: name } = await execFileAsync("git", ["config", "user.name"], { timeout: 3e3 });
694
+ const trimmed = name.trim();
695
+ if (trimmed.length > 0) result["userName"] = trimmed;
696
+ } catch {
697
+ }
698
+ try {
699
+ const { stdout: email } = await execFileAsync("git", ["config", "user.email"], { timeout: 3e3 });
700
+ const trimmed = email.trim();
701
+ if (trimmed.length > 0) result["userEmail"] = trimmed;
702
+ } catch {
703
+ }
704
+ return result;
705
+ }
706
+ function registerPlanRegisterSubcommand(plan, deps) {
707
+ 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) => {
708
+ assertToken2(deps.settings.token);
709
+ if (opts.status != null && !PLAN_STATUSES.includes(opts.status)) {
710
+ throw new CliError(
711
+ `Invalid status "${opts.status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
712
+ 2
713
+ );
714
+ }
715
+ const { content, sourcePath, titleFallback } = await readPlanFile(opts.file);
716
+ const title = opts.title ?? extractTitle(content) ?? titleFallback;
717
+ if (title.length > PLAN_TITLE_MAX) {
718
+ throw new CliError(
719
+ `Plan title is too long (${title.length} chars, max ${PLAN_TITLE_MAX}).`,
720
+ 2
721
+ );
722
+ }
723
+ let parsedPhases;
724
+ if (opts.phases != null) {
725
+ try {
726
+ parsedPhases = JSON.parse(opts.phases);
727
+ } catch (err) {
728
+ throw new CliError(
729
+ `Invalid --phases JSON: ${err instanceof Error ? err.message : String(err)}`,
730
+ 2
731
+ );
732
+ }
733
+ const phaseResult = phaseArraySchema.safeParse(parsedPhases);
734
+ if (!phaseResult.success) {
735
+ const field = phaseResult.error.issues[0]?.path.join(".") ?? "unknown";
736
+ throw new CliError(`Invalid --phases JSON: field "${field}"`, 2);
737
+ }
738
+ parsedPhases = phaseResult.data;
739
+ }
740
+ if (opts.doc.length > 10) {
741
+ throw new CliError("Too many documents (max 10).", 2);
742
+ }
743
+ const documents = [];
744
+ for (const docSpec of opts.doc) {
745
+ const match = /^([a-z_]+)=(.+)$/.exec(docSpec);
746
+ if (match == null) {
747
+ throw new CliError(`Invalid --doc format "${docSpec}". Expected kind=path.`, 2);
748
+ }
749
+ const kind = match[1];
750
+ const docPath = match[2];
751
+ if (!PLAN_DOCUMENT_KINDS.includes(kind)) {
752
+ throw new CliError(
753
+ `Invalid document kind "${kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
754
+ 2
755
+ );
756
+ }
757
+ const { content: docContent, encoding, filename } = await readDocFile(docPath);
758
+ documents.push({ kind, filename, content: docContent, encoding });
759
+ }
760
+ const source = { kind: "file", path: sourcePath };
761
+ if (opts.sourceUrl != null) source["url"] = opts.sourceUrl;
762
+ if (opts.sourceHash != null) source["hash"] = opts.sourceHash;
763
+ const git = opts.git !== false ? await getGitContributor() : {};
764
+ const planPayload = {
765
+ title,
766
+ content,
767
+ ...opts.status != null ? { status: opts.status } : {},
768
+ ...opts.plannedStart != null ? { plannedStartAt: opts.plannedStart } : {},
769
+ ...opts.plannedEnd != null ? { plannedEndAt: opts.plannedEnd } : {},
770
+ ...parsedPhases != null ? { phases: parsedPhases } : {},
771
+ ...documents.length > 0 ? { documents } : {}
772
+ };
773
+ const mcpArgs = { plan: planPayload, source, git };
774
+ if (deps.settings.defaultProjectId != null) {
775
+ mcpArgs["projectId"] = deps.settings.defaultProjectId;
776
+ }
777
+ const result = await deps.caller.call("register_plan", mcpArgs);
778
+ if (deps.output.mode === "json") {
779
+ printResult(result, "json");
780
+ return;
781
+ }
782
+ const r = result;
783
+ const detailUrl = resolveUrl(r?.detailUrl, deps.settings.baseUrl);
784
+ process.stdout.write(
785
+ formatRecord({
786
+ planId: r?.planId ?? "",
787
+ transformStatus: r?.transformStatus ?? "",
788
+ url: detailUrl
789
+ }) + "\n"
790
+ );
791
+ });
792
+ }
793
+
794
+ // src/commands/plan.ts
795
+ function assertToken3(token) {
796
+ if (token == null) {
797
+ throw new CliError(
798
+ "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
799
+ 3
800
+ );
801
+ }
802
+ }
803
+ function register4(program, deps) {
804
+ const plan = program.command("plan").description("Manage plans.");
805
+ plan.command("list").description("List plans.").option("--status <status>", "Filter by status").action(async (opts) => {
806
+ assertToken3(deps.settings.token);
807
+ if (opts.status != null && !PLAN_STATUSES.includes(opts.status)) {
808
+ throw new CliError(
809
+ `Invalid status "${opts.status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
810
+ 2
811
+ );
812
+ }
813
+ const args = {};
814
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
815
+ if (opts.status != null) args["status"] = opts.status;
816
+ const result = await deps.caller.call("list_plans", args);
817
+ if (deps.output.mode === "json") {
818
+ printResult(result, "json");
819
+ return;
820
+ }
821
+ const rows = Array.isArray(result) ? result : [];
822
+ const tableRows = rows.map((p) => ({
823
+ id: p.id ?? "",
824
+ title: p.title ?? "",
825
+ status: p.status ?? "",
826
+ createdAt: p.createdAt ?? ""
827
+ }));
828
+ process.stdout.write(formatTable(tableRows, ["id", "title", "status", "createdAt"]) + "\n");
829
+ });
830
+ plan.command("get <planId>").description("Get plan details.").action(async (planId) => {
831
+ assertToken3(deps.settings.token);
832
+ const args = { planId };
833
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
834
+ const result = await deps.caller.call("get_plan", args);
835
+ if (deps.output.mode === "json") {
836
+ printResult(result, "json");
837
+ return;
838
+ }
839
+ printResult(result, "human");
840
+ });
841
+ registerPlanRegisterSubcommand(plan, deps);
842
+ plan.command("status <planId> <status>").description("Update plan status.").option("--message <text>", "Optional message").action(async (planId, status, opts) => {
843
+ assertToken3(deps.settings.token);
844
+ if (!PLAN_STATUSES.includes(status)) {
845
+ throw new CliError(
846
+ `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
847
+ 2
848
+ );
849
+ }
850
+ const args = { planId, status };
851
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
852
+ if (opts.message != null) args["message"] = opts.message;
853
+ const result = await deps.caller.call("update_plan_status", args);
854
+ if (deps.output.mode === "json") {
855
+ printResult(result, "json");
856
+ return;
857
+ }
858
+ process.stdout.write(`Plan ${planId} \u2192 ${status}.
859
+ `);
860
+ });
861
+ plan.command("review <planId> <verdict>").description("Record a plan review.").option("--summary <text>", "Review summary").action(async (planId, verdict, opts) => {
862
+ assertToken3(deps.settings.token);
863
+ if (!REVIEW_VERDICTS.includes(verdict)) {
864
+ throw new CliError(
865
+ `Invalid verdict "${verdict}". Valid values: ${REVIEW_VERDICTS.join(", ")}`,
866
+ 2
867
+ );
868
+ }
869
+ const args = { planId, verdict };
870
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
871
+ if (opts.summary != null) args["summary"] = opts.summary;
872
+ const result = await deps.caller.call("record_plan_review", args);
873
+ if (deps.output.mode === "json") {
874
+ printResult(result, "json");
875
+ return;
876
+ }
877
+ process.stdout.write(`Review recorded: ${verdict}.
878
+ `);
879
+ });
880
+ }
881
+
882
+ // src/commands/phase.ts
883
+ function assertToken4(token) {
884
+ if (token == null) {
885
+ throw new CliError(
886
+ "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
887
+ 3
888
+ );
889
+ }
890
+ }
891
+ function register5(program, deps) {
892
+ const phase = program.command("phase").description("Manage plan phases.");
893
+ phase.command("status <planId> <phaseId> <status>").description("Update a phase status.").option("--message <text>", "Optional message").action(async (planId, phaseId, status, opts) => {
894
+ assertToken4(deps.settings.token);
895
+ if (!PLAN_STATUSES.includes(status)) {
896
+ throw new CliError(
897
+ `Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
898
+ 2
899
+ );
900
+ }
901
+ const args = {
902
+ planId,
903
+ phaseId,
904
+ status
905
+ };
906
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
907
+ if (opts.message != null) args["message"] = opts.message;
908
+ const result = await deps.caller.call("update_phase_status", args);
909
+ if (deps.output.mode === "json") {
910
+ printResult(result, "json");
911
+ return;
912
+ }
913
+ const r = result;
914
+ const planStatus = r?.planStatus ?? "";
915
+ process.stdout.write(`Phase ${phaseId} \u2192 ${status} (plan now ${planStatus}).
916
+ `);
917
+ });
918
+ 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) => {
919
+ assertToken4(deps.settings.token);
920
+ if (!TEST_VERDICTS.includes(verdict)) {
921
+ throw new CliError(
922
+ `Invalid verdict "${verdict}". Valid values: ${TEST_VERDICTS.join(", ")}`,
923
+ 2
924
+ );
925
+ }
926
+ let round;
927
+ if (opts.round != null) {
928
+ if (!/^\d+$/.test(opts.round)) {
929
+ throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
930
+ }
931
+ const parsed = Number(opts.round);
932
+ if (!Number.isInteger(parsed) || parsed < 1) {
933
+ throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
934
+ }
935
+ round = parsed;
936
+ }
937
+ const args = {
938
+ planId,
939
+ phaseId,
940
+ verdict
941
+ };
942
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
943
+ if (round != null) args["round"] = round;
944
+ if (opts.summary != null) args["summary"] = opts.summary;
945
+ const result = await deps.caller.call("record_phase_test_result", args);
946
+ if (deps.output.mode === "json") {
947
+ printResult(result, "json");
948
+ return;
949
+ }
950
+ const roundDisplay = round != null ? String(round) : "\u2014";
951
+ process.stdout.write(`Recorded ${verdict} (round ${roundDisplay}).
952
+ `);
953
+ });
954
+ }
955
+
956
+ // src/commands/doc.ts
957
+ function assertToken5(token) {
958
+ if (token == null) {
959
+ throw new CliError(
960
+ "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
961
+ 3
962
+ );
963
+ }
964
+ }
965
+ function register6(program, deps) {
966
+ const doc = program.command("doc").description("Manage plan documents.");
967
+ 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) => {
968
+ assertToken5(deps.settings.token);
969
+ if (!PLAN_DOCUMENT_KINDS.includes(opts.kind)) {
970
+ throw new CliError(
971
+ `Invalid kind "${opts.kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
972
+ 2
973
+ );
974
+ }
975
+ const { content, encoding, filename: autoFilename } = await readDocFile(opts.file);
976
+ const filename = opts.filename != null ? opts.filename.slice(0, DOCUMENT_FILENAME_MAX) : autoFilename;
977
+ const args = {
978
+ planId,
979
+ kind: opts.kind,
980
+ filename,
981
+ content,
982
+ encoding
983
+ };
984
+ if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
985
+ if (opts.phase != null) args["phaseId"] = opts.phase;
986
+ const result = await deps.caller.call("upload_plan_document", args);
987
+ if (deps.output.mode === "json") {
988
+ printResult(result, "json");
989
+ return;
990
+ }
991
+ const byteCount = encoding === "base64" ? Math.floor(content.length * 3 / 4) : Buffer.byteLength(content, "utf8");
992
+ process.stdout.write(`Uploaded ${filename} (${encoding}, ${byteCount} bytes).
993
+ `);
994
+ });
995
+ }
996
+
997
+ // src/program.ts
998
+ function buildProgram(deps) {
999
+ const isJson = deps.output.mode === "json";
1000
+ const writeErr = isJson ? (_msg) => {
1001
+ } : void 0;
1002
+ const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput(writeErr != null ? { 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");
1003
+ register(program, deps);
1004
+ register2(program, deps);
1005
+ register3(program, deps);
1006
+ register4(program, deps);
1007
+ register5(program, deps);
1008
+ register6(program, deps);
1009
+ return program;
1010
+ }
1011
+
1012
+ // src/index.ts
1013
+ var __dirname = path3.dirname(fileURLToPath(import.meta.url));
1014
+ var require2 = createRequire(import.meta.url);
1015
+ function getVersion() {
1016
+ try {
1017
+ const pkgPath = path3.resolve(__dirname, "../package.json");
1018
+ const pkg = require2(pkgPath);
1019
+ return pkg.version ?? "0.0.0";
1020
+ } catch {
1021
+ return "0.0.0";
1022
+ }
1023
+ }
1024
+ var mode = "human";
1025
+ async function main() {
1026
+ const argv = process.argv.slice(2);
1027
+ const flagToken = extractFlag(argv, "--token");
1028
+ const flagBaseUrl = extractFlag(argv, "--base-url");
1029
+ const flagProject = extractFlag(argv, "--project");
1030
+ mode = argv.includes("--json") ? "json" : "human";
1031
+ const configPath = getConfigPath(process.env);
1032
+ const configFile = await loadConfigFile(configPath).catch((err) => {
1033
+ if (err instanceof CliError) {
1034
+ printError(err, mode);
1035
+ process.exitCode = err.exitCode;
1036
+ process.exit();
1037
+ }
1038
+ throw err;
1039
+ });
1040
+ if (flagBaseUrl != null) {
1041
+ try {
1042
+ new URL(flagBaseUrl);
1043
+ } catch {
1044
+ const err = new CliError(`Invalid base URL: ${flagBaseUrl}`, 2);
1045
+ printError(err, mode);
1046
+ process.exitCode = 2;
1047
+ process.exit();
1048
+ }
1049
+ }
1050
+ const settings = resolveSettings({
1051
+ flags: { token: flagToken, baseUrl: flagBaseUrl, project: flagProject },
1052
+ env: process.env,
1053
+ file: configFile
1054
+ });
1055
+ const version = getVersion();
1056
+ const caller = settings.token != null ? createMcpCaller({ baseUrl: settings.baseUrl, token: settings.token, version }) : {
1057
+ call: async (_toolName, _args) => {
1058
+ throw new CliError(
1059
+ "No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
1060
+ 3
1061
+ );
1062
+ }
1063
+ };
1064
+ const program = buildProgram({ caller, settings, output: { mode }, version, configPath });
1065
+ await program.parseAsync(process.argv);
1066
+ }
1067
+ function extractFlag(argv, name) {
1068
+ const idx = argv.indexOf(name);
1069
+ if (idx !== -1 && idx + 1 < argv.length) {
1070
+ return argv[idx + 1];
1071
+ }
1072
+ return void 0;
1073
+ }
1074
+ main().catch((err) => {
1075
+ if (err instanceof CommanderError) {
1076
+ if (err.exitCode === 0) {
1077
+ process.exit(0);
1078
+ }
1079
+ const cliErr2 = new CliError(err.message, 2);
1080
+ printError(cliErr2, mode);
1081
+ process.exitCode = 2;
1082
+ return;
1083
+ }
1084
+ const cliErr = err instanceof CliError ? err : new CliError(String(err?.message ?? err), 1);
1085
+ printError(cliErr, mode);
1086
+ process.exitCode = cliErr.exitCode;
1087
+ });
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@nolto/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Nolto — register plans and update progress from your terminal.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "nolto": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20.11.0"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/uruca-kk/nolto.git",
23
+ "directory": "packages/cli"
24
+ },
25
+ "homepage": "https://nolto.app",
26
+ "keywords": [
27
+ "nolto",
28
+ "mcp",
29
+ "cli",
30
+ "plan",
31
+ "claude"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "typecheck": "tsc -p tsconfig.json",
36
+ "test": "vitest run --coverage",
37
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.29.0",
41
+ "commander": "^14.0.0",
42
+ "zod": "^3.24.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.0.0",
46
+ "tsup": "^8.3.5",
47
+ "typescript": "^5.7.2",
48
+ "vitest": "^4.1.7",
49
+ "@vitest/coverage-v8": "^4.1.7"
50
+ }
51
+ }