@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.
- package/README.md +140 -0
- package/dist/index.js +1087 -0
- 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
|
+
}
|