neuvikon 0.2.14 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +232 -76
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -3,6 +3,49 @@ import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readF
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
//#region src/api.ts
|
|
7
|
+
var ApiError = class extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = "ApiError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/** A request that never reached the server: offline, DNS, TLS, timeout. */
|
|
16
|
+
var NetworkError = class extends Error {
|
|
17
|
+
constructor(cause) {
|
|
18
|
+
super("Could not reach Neuvikon", { cause });
|
|
19
|
+
this.name = "NetworkError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const TIMEOUT_MS = 15e3;
|
|
23
|
+
/**
|
|
24
|
+
* The one place an HTTP failure becomes a typed error. Callers decide what a
|
|
25
|
+
* failure means — a hook wrapping the agent's turn wants to swallow it, `login`
|
|
26
|
+
* wants to shout about it.
|
|
27
|
+
*/
|
|
28
|
+
async function post(url, path, body) {
|
|
29
|
+
let response;
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch(`${url}${path}`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "content-type": "application/json" },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new NetworkError(error);
|
|
39
|
+
}
|
|
40
|
+
const payload = await response.json().catch(() => ({}));
|
|
41
|
+
const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
|
|
42
|
+
if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
|
|
43
|
+
return payload;
|
|
44
|
+
}
|
|
45
|
+
function normalizeUrl(url) {
|
|
46
|
+
return url.trim().replace(/\/+$/, "");
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
6
49
|
//#region src/config.ts
|
|
7
50
|
/**
|
|
8
51
|
* The two deployments this CLI knows about. The browser origin is kept here
|
|
@@ -93,49 +136,6 @@ function clearCredentials() {
|
|
|
93
136
|
}
|
|
94
137
|
}
|
|
95
138
|
//#endregion
|
|
96
|
-
//#region src/api.ts
|
|
97
|
-
var ApiError = class extends Error {
|
|
98
|
-
status;
|
|
99
|
-
constructor(message, status) {
|
|
100
|
-
super(message);
|
|
101
|
-
this.status = status;
|
|
102
|
-
this.name = "ApiError";
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
/** A request that never reached the server: offline, DNS, TLS, timeout. */
|
|
106
|
-
var NetworkError = class extends Error {
|
|
107
|
-
constructor(cause) {
|
|
108
|
-
super("Could not reach Neuvikon", { cause });
|
|
109
|
-
this.name = "NetworkError";
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
const TIMEOUT_MS = 15e3;
|
|
113
|
-
/**
|
|
114
|
-
* The one place an HTTP failure becomes a typed error. Callers decide what a
|
|
115
|
-
* failure means — a hook wrapping the agent's turn wants to swallow it, `login`
|
|
116
|
-
* wants to shout about it.
|
|
117
|
-
*/
|
|
118
|
-
async function post(url, path, body) {
|
|
119
|
-
let response;
|
|
120
|
-
try {
|
|
121
|
-
response = await fetch(`${url}${path}`, {
|
|
122
|
-
method: "POST",
|
|
123
|
-
headers: { "content-type": "application/json" },
|
|
124
|
-
body: JSON.stringify(body),
|
|
125
|
-
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
126
|
-
});
|
|
127
|
-
} catch (error) {
|
|
128
|
-
throw new NetworkError(error);
|
|
129
|
-
}
|
|
130
|
-
const payload = await response.json().catch(() => ({}));
|
|
131
|
-
const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
|
|
132
|
-
if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
|
|
133
|
-
return payload;
|
|
134
|
-
}
|
|
135
|
-
function normalizeUrl(url) {
|
|
136
|
-
return url.trim().replace(/\/+$/, "");
|
|
137
|
-
}
|
|
138
|
-
//#endregion
|
|
139
139
|
//#region src/session.ts
|
|
140
140
|
/**
|
|
141
141
|
* Distinguishes concurrent agents working in the *same* checkout — two Claude
|
|
@@ -184,6 +184,74 @@ function clearSession() {
|
|
|
184
184
|
} catch {}
|
|
185
185
|
}
|
|
186
186
|
//#endregion
|
|
187
|
+
//#region src/ui.ts
|
|
188
|
+
/**
|
|
189
|
+
* Output helpers. Everything the agent shouldn't parse goes to stderr, so a
|
|
190
|
+
* command's stdout stays a clean, pipeable value.
|
|
191
|
+
*/
|
|
192
|
+
const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
|
|
193
|
+
function paint(code, text) {
|
|
194
|
+
return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
195
|
+
}
|
|
196
|
+
const dim = (text) => paint("2", text);
|
|
197
|
+
const bold = (text) => paint("1", text);
|
|
198
|
+
const green = (text) => paint("32", text);
|
|
199
|
+
const red = (text) => paint("31", text);
|
|
200
|
+
const cyan = (text) => paint("36", text);
|
|
201
|
+
function info(message) {
|
|
202
|
+
process.stderr.write(`${message}\n`);
|
|
203
|
+
}
|
|
204
|
+
function success(message) {
|
|
205
|
+
process.stderr.write(`${green("✓")} ${message}\n`);
|
|
206
|
+
}
|
|
207
|
+
function warn(message) {
|
|
208
|
+
process.stderr.write(`${red("!")} ${message}\n`);
|
|
209
|
+
}
|
|
210
|
+
/** Ends the process. Every user-facing failure funnels through here. */
|
|
211
|
+
function fail(message, hint) {
|
|
212
|
+
process.stderr.write(`${red("✗")} ${message}\n`);
|
|
213
|
+
if (hint) process.stderr.write(` ${dim(hint)}\n`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/commands/board.ts
|
|
218
|
+
function requireCredentials$3() {
|
|
219
|
+
const credentials = readCredentials();
|
|
220
|
+
if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
|
|
221
|
+
return credentials;
|
|
222
|
+
}
|
|
223
|
+
function resolveTicketRef$3(flags) {
|
|
224
|
+
const ref = flags.get("ticket") ?? readSession()?.ticket;
|
|
225
|
+
if (!ref) fail("No ticket to look up a board for.", "Pass --ticket EWWQ-1, or run this where `npx neuvikon start` is already tracking one.");
|
|
226
|
+
return ref;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Shows an agent the real shape of this project's board before it guesses at
|
|
230
|
+
* a column name — boards vary per project, and some have no "done" at all.
|
|
231
|
+
*/
|
|
232
|
+
async function board(flags) {
|
|
233
|
+
const credentials = requireCredentials$3();
|
|
234
|
+
const ticketRef = resolveTicketRef$3(flags);
|
|
235
|
+
let result;
|
|
236
|
+
try {
|
|
237
|
+
result = await post(credentials.url, "/api/agent/board", {
|
|
238
|
+
token: credentials.token,
|
|
239
|
+
ticketRef
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
|
|
243
|
+
if (error instanceof ApiError) {
|
|
244
|
+
if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
|
|
245
|
+
fail(error.message);
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
info(`${ticketRef} is on ${bold(result.currentColumn || "(no column)")}`);
|
|
250
|
+
info(dim("Columns, in board order:"));
|
|
251
|
+
for (const column of result.columns) info(` - ${column.name}${column.isDone ? dim(" (terminal)") : ""}`);
|
|
252
|
+
if (!result.columns.some((column) => column.isDone)) info(dim("No column is marked terminal on this board — use your judgment on what counts as done."));
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
187
255
|
//#region src/ticket.ts
|
|
188
256
|
/**
|
|
189
257
|
* Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
|
|
@@ -209,6 +277,40 @@ async function readStdin() {
|
|
|
209
277
|
return Buffer.concat(chunks).toString("utf8");
|
|
210
278
|
}
|
|
211
279
|
//#endregion
|
|
280
|
+
//#region src/commands/comment.ts
|
|
281
|
+
function requireCredentials$2() {
|
|
282
|
+
const credentials = readCredentials();
|
|
283
|
+
if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
|
|
284
|
+
return credentials;
|
|
285
|
+
}
|
|
286
|
+
function resolveTicketRef$2(flags) {
|
|
287
|
+
const ref = flags.get("ticket") ?? readSession()?.ticket;
|
|
288
|
+
if (!ref) fail("No ticket to comment on.", "Pass --ticket EWWQ-1, or run this where `npx neuvikon start` is already tracking one.");
|
|
289
|
+
return ref;
|
|
290
|
+
}
|
|
291
|
+
/** A note on a ticket that doesn't move it — e.g. explaining a handoff mid-ticket. */
|
|
292
|
+
async function comment(flags) {
|
|
293
|
+
const credentials = requireCredentials$2();
|
|
294
|
+
const ticketRef = resolveTicketRef$2(flags);
|
|
295
|
+
const text = (flags.get("text") || await readStdin()).trim();
|
|
296
|
+
if (!text) fail("No comment text given.", "Pipe the comment in on stdin, or pass --text.");
|
|
297
|
+
try {
|
|
298
|
+
await post(credentials.url, "/api/agent/comment", {
|
|
299
|
+
token: credentials.token,
|
|
300
|
+
ticketRef,
|
|
301
|
+
comment: text
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
|
|
305
|
+
if (error instanceof ApiError) {
|
|
306
|
+
if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
|
|
307
|
+
fail(error.message);
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
success(`Commented on ${ticketRef}`);
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
212
314
|
//#region src/usage.ts
|
|
213
315
|
/**
|
|
214
316
|
* Sums token usage out of a JSONL agent transcript, starting from `offset`.
|
|
@@ -336,36 +438,6 @@ function claudeTranscriptPath() {
|
|
|
336
438
|
}
|
|
337
439
|
}
|
|
338
440
|
//#endregion
|
|
339
|
-
//#region src/ui.ts
|
|
340
|
-
/**
|
|
341
|
-
* Output helpers. Everything the agent shouldn't parse goes to stderr, so a
|
|
342
|
-
* command's stdout stays a clean, pipeable value.
|
|
343
|
-
*/
|
|
344
|
-
const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
|
|
345
|
-
function paint(code, text) {
|
|
346
|
-
return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
347
|
-
}
|
|
348
|
-
const dim = (text) => paint("2", text);
|
|
349
|
-
const bold = (text) => paint("1", text);
|
|
350
|
-
const green = (text) => paint("32", text);
|
|
351
|
-
const red = (text) => paint("31", text);
|
|
352
|
-
const cyan = (text) => paint("36", text);
|
|
353
|
-
function info(message) {
|
|
354
|
-
process.stderr.write(`${message}\n`);
|
|
355
|
-
}
|
|
356
|
-
function success(message) {
|
|
357
|
-
process.stderr.write(`${green("✓")} ${message}\n`);
|
|
358
|
-
}
|
|
359
|
-
function warn(message) {
|
|
360
|
-
process.stderr.write(`${red("!")} ${message}\n`);
|
|
361
|
-
}
|
|
362
|
-
/** Ends the process. Every user-facing failure funnels through here. */
|
|
363
|
-
function fail(message, hint) {
|
|
364
|
-
process.stderr.write(`${red("✗")} ${message}\n`);
|
|
365
|
-
if (hint) process.stderr.write(` ${dim(hint)}\n`);
|
|
366
|
-
process.exit(1);
|
|
367
|
-
}
|
|
368
|
-
//#endregion
|
|
369
441
|
//#region src/commands/report.ts
|
|
370
442
|
/**
|
|
371
443
|
* Agent hooks hand their context to the command on stdin as JSON. Reading the
|
|
@@ -659,12 +731,42 @@ creating a duplicate, so reuse a title for anything you're extending.
|
|
|
659
731
|
Reference another page by writing its title in double brackets, e.g.
|
|
660
732
|
\`[[Auth Flow]]\` — it becomes a real link if that page already exists.
|
|
661
733
|
|
|
662
|
-
|
|
734
|
+
Before you change the ticket's status, look at what columns this project's
|
|
735
|
+
board actually has — they vary per project, and there may be no column
|
|
736
|
+
literally called "Done":
|
|
737
|
+
|
|
738
|
+
npx neuvikon board --ticket EWWQ-1
|
|
739
|
+
|
|
740
|
+
Then decide, based on what's actually true, not on finishing your turn:
|
|
741
|
+
|
|
742
|
+
- The work is really done — merged to main, deployed, whatever "done" means
|
|
743
|
+
for this project — move it to that board's terminal column (the one
|
|
744
|
+
\`board\` marks as terminal), and say why:
|
|
745
|
+
|
|
746
|
+
npx neuvikon move --ticket EWWQ-1 --column "Done" --comment "Merged to main in #142"
|
|
747
|
+
|
|
748
|
+
- The work needs a step before done — a PR is open, it's waiting on review,
|
|
749
|
+
it's blocked — move it to whichever real column matches that, with the
|
|
750
|
+
same reasoning:
|
|
751
|
+
|
|
752
|
+
npx neuvikon move --ticket EWWQ-1 --column "Code Review" --comment "PR up, waiting on review"
|
|
753
|
+
|
|
754
|
+
- If the board has no column that fits, or you're just leaving a note without
|
|
755
|
+
changing where the ticket sits (e.g. handing off mid-ticket), comment
|
|
756
|
+
instead of moving:
|
|
757
|
+
|
|
758
|
+
npx neuvikon comment --ticket EWWQ-1 --text "Handing off — blocked on design"
|
|
759
|
+
|
|
760
|
+
A ticket that isn't actually finished should never land on a terminal column
|
|
761
|
+
just because you're done for now. When you finish that ticket, or the user
|
|
762
|
+
moves you to a different one, also run:
|
|
663
763
|
|
|
664
764
|
npx neuvikon stop
|
|
665
765
|
|
|
666
|
-
|
|
667
|
-
|
|
766
|
+
\`stop\` only tells the team you're no longer working on it — it does not move
|
|
767
|
+
the ticket, since stopping and finishing are not the same thing. This is
|
|
768
|
+
bookkeeping that tells the user's team which ticket you are on. You do not
|
|
769
|
+
need to narrate these commands, and if one fails, carry on with the actual
|
|
668
770
|
work — never let it block you.`;
|
|
669
771
|
function instructionBlock() {
|
|
670
772
|
return `${START_MARKER}\n${INSTRUCTION_BODY}\n${END_MARKER}`;
|
|
@@ -926,6 +1028,49 @@ async function logout() {
|
|
|
926
1028
|
info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
|
|
927
1029
|
}
|
|
928
1030
|
//#endregion
|
|
1031
|
+
//#region src/commands/move.ts
|
|
1032
|
+
function requireCredentials$1() {
|
|
1033
|
+
const credentials = readCredentials();
|
|
1034
|
+
if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
|
|
1035
|
+
return credentials;
|
|
1036
|
+
}
|
|
1037
|
+
function resolveTicketRef$1(flags) {
|
|
1038
|
+
const ref = flags.get("ticket") ?? readSession()?.ticket;
|
|
1039
|
+
if (!ref) fail("No ticket to move.", "Pass --ticket EWWQ-1, or run this where `npx neuvikon start` is already tracking one.");
|
|
1040
|
+
return ref;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Explicit column change, e.g. `npx neuvikon move --column "Code Review" \
|
|
1044
|
+
* --comment "PR up, waiting on review"`. A comment is mandatory: this is the
|
|
1045
|
+
* command that lands a ticket on a done-like column, and it should never do
|
|
1046
|
+
* that silently.
|
|
1047
|
+
*/
|
|
1048
|
+
async function move(flags) {
|
|
1049
|
+
const credentials = requireCredentials$1();
|
|
1050
|
+
const ticketRef = resolveTicketRef$1(flags);
|
|
1051
|
+
const column = flags.get("column");
|
|
1052
|
+
if (!column) fail("A target column is required.", `Pass --column "Code Review" — run \`npx neuvikon board\` first if you're not sure what this project calls it.`);
|
|
1053
|
+
const comment = (flags.get("comment") || await readStdin()).trim();
|
|
1054
|
+
if (!comment) fail("A comment is required.", "Pass --comment \"why this ticket is moving\", e.g. \"Merged to main.\"");
|
|
1055
|
+
let result;
|
|
1056
|
+
try {
|
|
1057
|
+
result = await post(credentials.url, "/api/agent/move", {
|
|
1058
|
+
token: credentials.token,
|
|
1059
|
+
ticketRef,
|
|
1060
|
+
column,
|
|
1061
|
+
comment
|
|
1062
|
+
});
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
|
|
1065
|
+
if (error instanceof ApiError) {
|
|
1066
|
+
if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
|
|
1067
|
+
fail(error.message);
|
|
1068
|
+
}
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1071
|
+
success(`Moved ${ticketRef} to ${bold(result.column)}`);
|
|
1072
|
+
}
|
|
1073
|
+
//#endregion
|
|
929
1074
|
//#region src/commands/pages.ts
|
|
930
1075
|
function requireCredentials() {
|
|
931
1076
|
const credentials = readCredentials();
|
|
@@ -1094,6 +1239,14 @@ ${bold("Usage")}
|
|
|
1094
1239
|
npx neuvikon status Show login, project setup and current ticket
|
|
1095
1240
|
npx neuvikon logout
|
|
1096
1241
|
|
|
1242
|
+
${bold("Board")}
|
|
1243
|
+
npx neuvikon board Show this project's columns and where the ticket sits
|
|
1244
|
+
npx neuvikon move --column "Code Review" --comment "PR up, waiting on review"
|
|
1245
|
+
Move the ticket to a specific column, with why
|
|
1246
|
+
npx neuvikon comment --text "Handing off, blocked on design"
|
|
1247
|
+
Leave a note without changing the column
|
|
1248
|
+
All three accept --ticket EWWQ-1 instead of relying on a tracked session.
|
|
1249
|
+
|
|
1097
1250
|
${bold("Project pages")}
|
|
1098
1251
|
npx neuvikon pages List the current ticket's project pages
|
|
1099
1252
|
npx neuvikon pages update "Title" < notes.txt
|
|
@@ -1149,6 +1302,9 @@ async function main() {
|
|
|
1149
1302
|
case "stop": return stop(flags);
|
|
1150
1303
|
case "status": return status();
|
|
1151
1304
|
case "pages": return pages(flags, positional);
|
|
1305
|
+
case "board": return board(flags);
|
|
1306
|
+
case "move": return move(flags);
|
|
1307
|
+
case "comment": return comment(flags);
|
|
1152
1308
|
case void 0:
|
|
1153
1309
|
case "help":
|
|
1154
1310
|
case "--help":
|