neuvikon 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neuvikon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # neuvikon
2
+
3
+ Tells [Neuvikon](https://neuvikon.com) which ticket your coding agent is working
4
+ on, and how many tokens it spent doing it.
5
+
6
+ Works with any agent that reads a rules file and has a shell — Claude Code,
7
+ Cursor, Codex, Copilot, opencode — because the integration *is* the rules file.
8
+
9
+ ## Use
10
+
11
+ ```sh
12
+ npx neuvikon login
13
+ ```
14
+
15
+ A browser opens, you approve, and the CLI writes its instructions into the
16
+ project's agent rules (`AGENTS.md`, plus `CLAUDE.md` / `.cursor/rules/` when the
17
+ project already uses them).
18
+
19
+ After that, paste a ticket into whichever agent you like:
20
+
21
+ ```
22
+ EWWQ-1: Fix the invite expiry
23
+ Type: Bug
24
+ Priority: High
25
+ ...
26
+ ```
27
+
28
+ The agent reads its rules, sees the `EWWQ-1` reference, and runs
29
+ `npx neuvikon start --ticket EWWQ-1` before it begins. Neuvikon shows the ticket
30
+ as being worked on, by whom, with which tool.
31
+
32
+ ## Commands
33
+
34
+ | | |
35
+ |---|---|
36
+ | `neuvikon login` | Sign in through the browser and set up the current project |
37
+ | `neuvikon init` | Set up another project (credentials are per-machine, rules are per-project) |
38
+ | `neuvikon start --ticket EWWQ-1` | Begin tracking. `--tool claude-code\|codex\|cursor\|other` |
39
+ | `neuvikon stop` | Stop tracking |
40
+ | `neuvikon status` | Login, project setup, current ticket |
41
+ | `neuvikon logout` | Forget the stored token |
42
+
43
+ `start` also accepts the ticket on stdin, and pulls the reference out of it:
44
+
45
+ ```sh
46
+ pbpaste | npx neuvikon start
47
+ ```
48
+
49
+ ## Token usage
50
+
51
+ `start`/`stop` are all an agent can report about itself — no agent knows its own
52
+ token count. On Claude Code the transcript does, so a hook can report it:
53
+
54
+ ```jsonc
55
+ // .claude/settings.json
56
+ {
57
+ "hooks": {
58
+ "Stop": [{ "hooks": [{ "type": "command", "command": "npx neuvikon report" }] }],
59
+ "SessionEnd": [{ "hooks": [{ "type": "command", "command": "npx neuvikon stop" }] }]
60
+ }
61
+ }
62
+ ```
63
+
64
+ `report` reads only what was appended since the last call, so firing it every
65
+ turn adds that turn rather than re-counting the conversation. It is silent and
66
+ always exits 0 — a token count is never worth interrupting someone's work.
67
+
68
+ ## Configuration
69
+
70
+ | | |
71
+ |---|---|
72
+ | `~/.neuvikon/config.json` | Token and deployment URL, `0600` |
73
+ | `.neuvikon-session` | Which ticket this directory is tracking. Git-ignored by `init` |
74
+ | `NEUVIKON_URL` | Point at a self-hosted deployment. `--url` does the same per command |
75
+
76
+ ## Develop
77
+
78
+ ```sh
79
+ pnpm install
80
+ pnpm build # tsdown -> dist/index.mjs
81
+ pnpm typecheck
82
+ ```
83
+
84
+ No runtime dependencies, so `npx neuvikon` stays a fast cold start.
package/dist/index.mjs ADDED
@@ -0,0 +1,728 @@
1
+ #!/usr/bin/env node
2
+ import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { spawn } from "node:child_process";
6
+ //#region src/agents/instructions.ts
7
+ /**
8
+ * The bridge between "user pasted a ticket" and "Neuvikon knows about it".
9
+ *
10
+ * Coding agents don't share a hook system, but they all read a plain-text rules
11
+ * file and they all have a shell. So the instruction is the integration: any
12
+ * agent that can read its own rules can report its own work, and support for a
13
+ * new tool is a filename in TARGETS rather than a new adapter.
14
+ */
15
+ const START_MARKER = "<!-- neuvikon:start -->";
16
+ const END_MARKER = "<!-- neuvikon:end -->";
17
+ const INSTRUCTION_BODY = `## Neuvikon ticket tracking
18
+
19
+ When the user hands you a Neuvikon ticket — text beginning with a reference like
20
+ \`EWWQ-1:\` — run this before you start working on it:
21
+
22
+ npx neuvikon start --ticket EWWQ-1
23
+
24
+ When you finish that ticket, or the user moves you to a different one, run:
25
+
26
+ npx neuvikon stop
27
+
28
+ This is bookkeeping that tells the user's team which ticket you are on. You do
29
+ not need to narrate these commands, and if one fails, carry on with the actual
30
+ work — never let it block you.`;
31
+ function instructionBlock() {
32
+ return `${START_MARKER}\n${INSTRUCTION_BODY}\n${END_MARKER}`;
33
+ }
34
+ /**
35
+ * Replaces an existing block or appends a new one, so re-running `init` after
36
+ * an upgrade refreshes the wording instead of stacking duplicates.
37
+ */
38
+ function mergeInstructions(existing) {
39
+ const start = existing.indexOf(START_MARKER);
40
+ const end = existing.indexOf(END_MARKER);
41
+ if (start !== -1 && end !== -1 && end > start) {
42
+ const before = existing.slice(0, start);
43
+ const after = existing.slice(end + 21);
44
+ return `${before}${instructionBlock()}${after}`;
45
+ }
46
+ return `${existing}${existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n"}${instructionBlock()}\n`;
47
+ }
48
+ function hasInstructions(content) {
49
+ return content.includes(START_MARKER);
50
+ }
51
+ /** Cursor wants frontmatter, or the rule is never loaded. */
52
+ function cursorRuleFile() {
53
+ return [
54
+ "---",
55
+ "description: Report ticket work to Neuvikon",
56
+ "alwaysApply: true",
57
+ "---",
58
+ "",
59
+ instructionBlock(),
60
+ ""
61
+ ].join("\n");
62
+ }
63
+ //#endregion
64
+ //#region src/agents/install.ts
65
+ /**
66
+ * Files agents actually read. AGENTS.md is the cross-tool convention and is
67
+ * always written; the rest are only touched when that tool is already set up in
68
+ * the project, so `init` doesn't litter a repo with configs for tools nobody
69
+ * on the team uses.
70
+ */
71
+ const OPTIONAL_MARKDOWN_TARGETS = ["CLAUDE.md", ".github/copilot-instructions.md"];
72
+ function writeMarkdownTarget(root, relativePath) {
73
+ const path = join(root, relativePath);
74
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
75
+ const next = mergeInstructions(existing);
76
+ if (existing === next) return {
77
+ path: relativePath,
78
+ action: "unchanged"
79
+ };
80
+ mkdirSync(dirname(path), { recursive: true });
81
+ writeFileSync(path, next);
82
+ return {
83
+ path: relativePath,
84
+ action: existing ? "updated" : "created"
85
+ };
86
+ }
87
+ function writeCursorRule(root) {
88
+ const relativePath = join(".cursor", "rules", "neuvikon.mdc");
89
+ const path = join(root, relativePath);
90
+ const next = cursorRuleFile();
91
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
92
+ if (existing === next) return {
93
+ path: relativePath,
94
+ action: "unchanged"
95
+ };
96
+ mkdirSync(dirname(path), { recursive: true });
97
+ writeFileSync(path, next);
98
+ return {
99
+ path: relativePath,
100
+ action: existing ? "updated" : "created"
101
+ };
102
+ }
103
+ /** Keeps the per-directory session file out of the user's commits. */
104
+ function ignoreSessionFile(root) {
105
+ const path = join(root, ".gitignore");
106
+ if (!existsSync(join(root, ".git")) && !existsSync(path)) return null;
107
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
108
+ if (existing.split("\n").some((line) => line.trim() === ".neuvikon-session")) return {
109
+ path: ".gitignore",
110
+ action: "unchanged"
111
+ };
112
+ const prefix = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
113
+ writeFileSync(path, `${existing}${prefix}# neuvikon CLI session state\n.neuvikon-session\n`);
114
+ return {
115
+ path: ".gitignore",
116
+ action: existing ? "updated" : "created"
117
+ };
118
+ }
119
+ function installAgentInstructions(root) {
120
+ const results = [writeMarkdownTarget(root, "AGENTS.md")];
121
+ for (const target of OPTIONAL_MARKDOWN_TARGETS) {
122
+ const path = join(root, target);
123
+ if (existsSync(path)) results.push(writeMarkdownTarget(root, target));
124
+ }
125
+ if (existsSync(join(root, ".cursor"))) results.push(writeCursorRule(root));
126
+ const gitignore = ignoreSessionFile(root);
127
+ if (gitignore) results.push(gitignore);
128
+ return results;
129
+ }
130
+ /** Whether this project already carries the instruction, for `status`. */
131
+ function isInstalled(root) {
132
+ for (const target of [
133
+ "AGENTS.md",
134
+ "CLAUDE.md",
135
+ join(".cursor", "rules", "neuvikon.mdc")
136
+ ]) {
137
+ const path = join(root, target);
138
+ if (existsSync(path) && hasInstructions(readFileSync(path, "utf8"))) return true;
139
+ }
140
+ return false;
141
+ }
142
+ //#endregion
143
+ //#region src/config.ts
144
+ /**
145
+ * The deployment the CLI talks to. Overridable so self-hosted installs and
146
+ * local Convex dev deployments work without a separate build.
147
+ *
148
+ * MUST point at the production Convex deployment before publishing: every
149
+ * installed copy of this CLI hardcodes whatever ships here, and a dev
150
+ * deployment can be reset out from under them.
151
+ */
152
+ const DEFAULT_SITE_URL = process.env.NEUVIKON_URL ?? "https://calm-sturgeon-831.eu-west-1.convex.site";
153
+ const CONFIG_DIR = join(homedir(), ".neuvikon");
154
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
155
+ function readCredentials() {
156
+ let raw;
157
+ try {
158
+ raw = readFileSync(CONFIG_PATH, "utf8");
159
+ } catch {
160
+ return null;
161
+ }
162
+ try {
163
+ const parsed = JSON.parse(raw);
164
+ if (typeof parsed !== "object" || parsed === null) return null;
165
+ const { token, url } = parsed;
166
+ if (typeof token !== "string" || !token) return null;
167
+ if (typeof url !== "string" || !url) return null;
168
+ return parsed;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+ /**
174
+ * A token on disk is only as safe as its permissions, so directory and file are
175
+ * locked to the owner on every write, not just on first create — an earlier
176
+ * version may have left them readable.
177
+ */
178
+ function writeCredentials(credentials) {
179
+ mkdirSync(CONFIG_DIR, {
180
+ recursive: true,
181
+ mode: 448
182
+ });
183
+ chmodSync(CONFIG_DIR, 448);
184
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
185
+ chmodSync(CONFIG_PATH, 384);
186
+ }
187
+ function clearCredentials() {
188
+ try {
189
+ rmSync(CONFIG_PATH);
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+ //#endregion
196
+ //#region src/ui.ts
197
+ /**
198
+ * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
199
+ * command's stdout stays a clean, pipeable value.
200
+ */
201
+ const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
202
+ function paint(code, text) {
203
+ return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
204
+ }
205
+ const dim = (text) => paint("2", text);
206
+ const bold = (text) => paint("1", text);
207
+ const green = (text) => paint("32", text);
208
+ const red = (text) => paint("31", text);
209
+ const cyan = (text) => paint("36", text);
210
+ function info(message) {
211
+ process.stderr.write(`${message}\n`);
212
+ }
213
+ function success(message) {
214
+ process.stderr.write(`${green("✓")} ${message}\n`);
215
+ }
216
+ function warn(message) {
217
+ process.stderr.write(`${red("!")} ${message}\n`);
218
+ }
219
+ /** Ends the process. Every user-facing failure funnels through here. */
220
+ function fail(message, hint) {
221
+ process.stderr.write(`${red("✗")} ${message}\n`);
222
+ if (hint) process.stderr.write(` ${dim(hint)}\n`);
223
+ process.exit(1);
224
+ }
225
+ //#endregion
226
+ //#region src/commands/init.ts
227
+ /**
228
+ * Separate from `login` because instructions are per-project and credentials
229
+ * are per-machine: a second checkout needs this, not another login.
230
+ */
231
+ function init() {
232
+ const results = installAgentInstructions(process.cwd());
233
+ for (const result of results) if (result.action === "unchanged") info(dim(` ${result.path} — already up to date`));
234
+ else success(`${result.path} — ${result.action}`);
235
+ if (!readCredentials()) warn("Not logged in yet — run `npx neuvikon login`.");
236
+ }
237
+ //#endregion
238
+ //#region src/api.ts
239
+ var ApiError = class extends Error {
240
+ status;
241
+ constructor(message, status) {
242
+ super(message);
243
+ this.status = status;
244
+ this.name = "ApiError";
245
+ }
246
+ };
247
+ /** A request that never reached the server: offline, DNS, TLS, timeout. */
248
+ var NetworkError = class extends Error {
249
+ constructor(cause) {
250
+ super("Could not reach Neuvikon", { cause });
251
+ this.name = "NetworkError";
252
+ }
253
+ };
254
+ const TIMEOUT_MS = 15e3;
255
+ /**
256
+ * The one place an HTTP failure becomes a typed error. Callers decide what a
257
+ * failure means — a hook wrapping the agent's turn wants to swallow it, `login`
258
+ * wants to shout about it.
259
+ */
260
+ async function post(url, path, body) {
261
+ let response;
262
+ try {
263
+ response = await fetch(`${url}${path}`, {
264
+ method: "POST",
265
+ headers: { "content-type": "application/json" },
266
+ body: JSON.stringify(body),
267
+ signal: AbortSignal.timeout(TIMEOUT_MS)
268
+ });
269
+ } catch (error) {
270
+ throw new NetworkError(error);
271
+ }
272
+ const payload = await response.json().catch(() => ({}));
273
+ const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
274
+ if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
275
+ return payload;
276
+ }
277
+ function normalizeUrl(url) {
278
+ return url.trim().replace(/\/+$/, "");
279
+ }
280
+ //#endregion
281
+ //#region src/browser.ts
282
+ /**
283
+ * Best-effort. The URL is always printed too, so a headless box, an SSH session
284
+ * or a locked-down desktop degrades to "copy this link" instead of failing.
285
+ */
286
+ function openBrowser(url) {
287
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
288
+ try {
289
+ const child = spawn(command, [url], {
290
+ stdio: "ignore",
291
+ detached: true,
292
+ shell: process.platform === "win32"
293
+ });
294
+ child.on("error", () => {});
295
+ child.unref();
296
+ } catch {}
297
+ }
298
+ //#endregion
299
+ //#region src/commands/login.ts
300
+ function sleep(ms) {
301
+ return new Promise((resolve) => setTimeout(resolve, ms));
302
+ }
303
+ async function login(flags) {
304
+ const url = normalizeUrl(flags.get("url") ?? DEFAULT_SITE_URL);
305
+ let start;
306
+ try {
307
+ start = await post(url, "/api/cli/auth/start", {});
308
+ } catch (error) {
309
+ if (error instanceof NetworkError) fail(`Could not reach ${url}`, "Check your connection.");
310
+ if (error instanceof ApiError) fail(error.message);
311
+ throw error;
312
+ }
313
+ info("");
314
+ info(` Open ${cyan(start.verifyUrl)}`);
315
+ info(` and confirm this code: ${bold(start.userCode)}`);
316
+ info("");
317
+ info(dim(" Waiting for approval..."));
318
+ openBrowser(start.verifyUrl);
319
+ const interval = Math.max(1e3, start.intervalMs);
320
+ for (;;) {
321
+ if (Date.now() > start.expiresAt) fail("That login request expired.", "Run `npx neuvikon login` to get a fresh code.");
322
+ await sleep(interval);
323
+ let result;
324
+ try {
325
+ result = await post(url, "/api/cli/auth/poll", { deviceCode: start.deviceCode });
326
+ } catch (error) {
327
+ if (error instanceof NetworkError) continue;
328
+ if (error instanceof ApiError && error.status === 429) continue;
329
+ if (error instanceof ApiError) fail(error.message);
330
+ throw error;
331
+ }
332
+ if (result.status === "pending") continue;
333
+ if (result.status === "denied") fail("Login was denied in the browser.");
334
+ if (result.status === "expired") fail("That login request expired.", "Run `npx neuvikon login` to get a fresh code.");
335
+ writeCredentials({
336
+ token: result.token,
337
+ url,
338
+ userName: result.userName,
339
+ organizationName: result.organizationName
340
+ });
341
+ info("");
342
+ success(`Logged in as ${bold(result.userName)} · ${result.organizationName}`);
343
+ info(dim(` Token stored in ${CONFIG_PATH}`));
344
+ const touched = installAgentInstructions(process.cwd()).filter((entry) => entry.action !== "unchanged");
345
+ if (touched.length > 0) success(`Told your coding agent how to report work (${touched.map((e) => e.path).join(", ")})`);
346
+ else success("Your coding agent already knows how to report work");
347
+ info("");
348
+ info(dim(" Paste a ticket into any coding agent and it will do the rest."));
349
+ return;
350
+ }
351
+ }
352
+ //#endregion
353
+ //#region src/commands/logout.ts
354
+ function logout() {
355
+ if (clearCredentials()) {
356
+ success("Logged out.");
357
+ return;
358
+ }
359
+ info("You were not logged in.");
360
+ }
361
+ //#endregion
362
+ //#region src/session.ts
363
+ /**
364
+ * Scoped to the working directory rather than the machine: two checkouts driven
365
+ * in parallel are two sessions, which is what running two agents means.
366
+ */
367
+ function sessionPath() {
368
+ return join(process.cwd(), ".neuvikon-session");
369
+ }
370
+ function readSession() {
371
+ let raw;
372
+ try {
373
+ raw = readFileSync(sessionPath(), "utf8");
374
+ } catch {
375
+ return null;
376
+ }
377
+ try {
378
+ const parsed = JSON.parse(raw);
379
+ if (typeof parsed !== "object" || parsed === null) return null;
380
+ const { sessionKey, ticket } = parsed;
381
+ if (typeof sessionKey !== "string" || typeof ticket !== "string") return null;
382
+ return parsed;
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+ function writeSession(session) {
388
+ writeFileSync(sessionPath(), `${JSON.stringify(session, null, 2)}\n`);
389
+ }
390
+ function clearSession() {
391
+ try {
392
+ rmSync(sessionPath());
393
+ } catch {}
394
+ }
395
+ //#endregion
396
+ //#region src/ticket.ts
397
+ /**
398
+ * Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
399
+ * server's — it pulls *candidates* out of arbitrary pasted prose and lets the
400
+ * server decide which one is a real ticket in the caller's organization, so the
401
+ * authoritative rule lives in exactly one place.
402
+ */
403
+ const CANDIDATE = /\b([A-Z0-9]{2,8})-(\d{1,9})\b/g;
404
+ /** Every distinct ref-shaped token in `text`, in the order it appears. */
405
+ function extractTicketRefs(text) {
406
+ const seen = /* @__PURE__ */ new Set();
407
+ for (const match of text.toUpperCase().matchAll(CANDIDATE)) {
408
+ if (/^\d+$/.test(match[1])) continue;
409
+ seen.add(`${match[1]}-${match[2]}`);
410
+ }
411
+ return [...seen];
412
+ }
413
+ /** Reads piped input, if any. Returns "" when stdin is a terminal. */
414
+ async function readStdin() {
415
+ if (process.stdin.isTTY) return "";
416
+ const chunks = [];
417
+ for await (const chunk of process.stdin) chunks.push(chunk);
418
+ return Buffer.concat(chunks).toString("utf8");
419
+ }
420
+ //#endregion
421
+ //#region src/usage.ts
422
+ /**
423
+ * Sums token usage out of a JSONL agent transcript, starting from `offset`.
424
+ *
425
+ * Reading incrementally is what makes a per-turn hook safe to call repeatedly:
426
+ * each call reports only what was appended since the last one. Anything that
427
+ * doesn't parse is skipped rather than fatal — a transcript being appended to
428
+ * while we read it will have a torn final line, and that is normal, not an
429
+ * error worth failing a user's turn over.
430
+ */
431
+ function readUsageSince(path, offset) {
432
+ let fd;
433
+ try {
434
+ fd = openSync(path, "r");
435
+ } catch {
436
+ return null;
437
+ }
438
+ try {
439
+ const size = fstatSync(fd).size;
440
+ const from = offset > size ? 0 : offset;
441
+ if (size === from) return {
442
+ tokensIn: 0,
443
+ tokensOut: 0,
444
+ offset: size
445
+ };
446
+ const buffer = Buffer.alloc(size - from);
447
+ readSync(fd, buffer, 0, buffer.length, from);
448
+ const text = buffer.toString("utf8");
449
+ const lastNewline = text.lastIndexOf("\n");
450
+ if (lastNewline === -1) return {
451
+ tokensIn: 0,
452
+ tokensOut: 0,
453
+ offset: from
454
+ };
455
+ let tokensIn = 0;
456
+ let tokensOut = 0;
457
+ for (const line of text.slice(0, lastNewline).split("\n")) {
458
+ if (!line.trim()) continue;
459
+ let row;
460
+ try {
461
+ row = JSON.parse(line);
462
+ } catch {
463
+ continue;
464
+ }
465
+ const usage = extractUsage(row);
466
+ if (!usage) continue;
467
+ tokensIn += usage.tokensIn;
468
+ tokensOut += usage.tokensOut;
469
+ }
470
+ return {
471
+ tokensIn,
472
+ tokensOut,
473
+ offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1))
474
+ };
475
+ } catch {
476
+ return null;
477
+ } finally {
478
+ closeSync(fd);
479
+ }
480
+ }
481
+ function toCount$1(value) {
482
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
483
+ }
484
+ function extractUsage(row) {
485
+ if (typeof row !== "object" || row === null) return null;
486
+ const message = row.message;
487
+ if (typeof message !== "object" || message === null) return null;
488
+ const usage = message.usage;
489
+ if (typeof usage !== "object" || usage === null) return null;
490
+ const fields = usage;
491
+ return {
492
+ tokensIn: toCount$1(fields.input_tokens) + toCount$1(fields.cache_read_input_tokens) + toCount$1(fields.cache_creation_input_tokens),
493
+ tokensOut: toCount$1(fields.output_tokens)
494
+ };
495
+ }
496
+ //#endregion
497
+ //#region src/commands/report.ts
498
+ /**
499
+ * Agent hooks hand their context to the command on stdin as JSON. Reading the
500
+ * transcript path from there means a hook is a bare `npx neuvikon report` with
501
+ * nothing to wire up, and nothing to keep in sync when a tool renames a flag.
502
+ */
503
+ async function transcriptFromHookPayload() {
504
+ const raw = await readStdin();
505
+ if (!raw.trim()) return void 0;
506
+ try {
507
+ const payload = JSON.parse(raw);
508
+ if (typeof payload !== "object" || payload === null) return void 0;
509
+ const path = payload.transcript_path;
510
+ return typeof path === "string" && path ? path : void 0;
511
+ } catch {
512
+ return;
513
+ }
514
+ }
515
+ function toCount(raw) {
516
+ if (raw === void 0) return void 0;
517
+ const value = Number(raw);
518
+ return Number.isFinite(value) && value >= 0 ? value : void 0;
519
+ }
520
+ /**
521
+ * Called from an agent hook on every turn, so it is deliberately silent and
522
+ * never exits non-zero: a token count is not worth interrupting someone's work
523
+ * over, and a hook that fails loudly is a hook people uninstall.
524
+ */
525
+ async function report(flags) {
526
+ const credentials = readCredentials();
527
+ const session = readSession();
528
+ if (!credentials || !session) {
529
+ if (flags.has("verbose")) info(dim(credentials ? "No ticket is being tracked here." : "Not logged in."));
530
+ return;
531
+ }
532
+ let tokensIn = toCount(flags.get("tokens-in"));
533
+ let tokensOut = toCount(flags.get("tokens-out"));
534
+ let nextOffset;
535
+ const transcript = flags.get("transcript") || await transcriptFromHookPayload();
536
+ if (transcript) {
537
+ const usage = readUsageSince(transcript, session.transcriptOffset ?? 0);
538
+ if (usage) {
539
+ tokensIn = (tokensIn ?? 0) + usage.tokensIn;
540
+ tokensOut = (tokensOut ?? 0) + usage.tokensOut;
541
+ nextOffset = usage.offset;
542
+ }
543
+ }
544
+ if (!tokensIn && !tokensOut) {
545
+ if (flags.has("verbose")) info(dim("No new usage to report."));
546
+ return;
547
+ }
548
+ try {
549
+ await post(credentials.url, "/api/agent/events", {
550
+ token: credentials.token,
551
+ event: "report",
552
+ sessionKey: session.sessionKey,
553
+ tokensIn,
554
+ tokensOut
555
+ });
556
+ } catch {
557
+ return;
558
+ }
559
+ if (nextOffset !== void 0) writeSession({
560
+ ...session,
561
+ transcriptOffset: nextOffset
562
+ });
563
+ if (flags.has("verbose")) info(dim(`Reported ${tokensIn ?? 0} in / ${tokensOut ?? 0} out`));
564
+ }
565
+ //#endregion
566
+ //#region src/commands/start.ts
567
+ const TOOLS = /* @__PURE__ */ new Set([
568
+ "claude-code",
569
+ "codex",
570
+ "cursor",
571
+ "other"
572
+ ]);
573
+ async function resolveRef(flags, positional) {
574
+ const explicit = flags.get("ticket") ?? positional[0];
575
+ if (explicit) {
576
+ const [ref] = extractTicketRefs(explicit);
577
+ if (!ref) fail(`"${explicit}" is not a ticket reference.`, "Expected something like EWWQ-1.");
578
+ return ref;
579
+ }
580
+ const refs = extractTicketRefs(await readStdin());
581
+ if (refs.length === 0) fail("No ticket reference found.", "Pass one with --ticket EWWQ-1, or pipe the ticket text in.");
582
+ return refs[0];
583
+ }
584
+ async function start(flags, positional) {
585
+ const credentials = readCredentials();
586
+ if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
587
+ const ref = await resolveRef(flags, positional);
588
+ const tool = flags.get("tool") ?? "other";
589
+ if (!TOOLS.has(tool)) fail(`Unknown tool "${tool}".`, `Expected one of: ${[...TOOLS].join(", ")}.`);
590
+ const existing = readSession();
591
+ if (existing?.ticket === ref) {
592
+ info(dim(`Already tracking ${ref}.`));
593
+ return;
594
+ }
595
+ let result;
596
+ try {
597
+ result = await post(credentials.url, "/api/agent/events", {
598
+ token: credentials.token,
599
+ event: "start",
600
+ ticketRef: ref,
601
+ tool
602
+ });
603
+ } catch (error) {
604
+ if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
605
+ if (error instanceof ApiError) {
606
+ if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
607
+ fail(error.message);
608
+ }
609
+ throw error;
610
+ }
611
+ if (existing) await post(credentials.url, "/api/agent/events", {
612
+ token: credentials.token,
613
+ event: "stop",
614
+ sessionKey: existing.sessionKey
615
+ }).catch(() => void 0);
616
+ writeSession({
617
+ sessionKey: result.sessionKey,
618
+ ticket: ref,
619
+ startedAt: Date.now()
620
+ });
621
+ success(`Tracking work on ${bold(ref)}`);
622
+ }
623
+ //#endregion
624
+ //#region src/commands/status.ts
625
+ function status() {
626
+ const credentials = readCredentials();
627
+ const session = readSession();
628
+ info("");
629
+ if (credentials) {
630
+ const who = credentials.userName ?? "unknown user";
631
+ const org = credentials.organizationName ?? credentials.url;
632
+ info(` Logged in ${bold(who)} ${dim(`· ${org}`)}`);
633
+ } else info(` Logged in ${dim("no — run `npx neuvikon login`")}`);
634
+ info(` This repo ${isInstalled(process.cwd()) ? "agent instructions installed" : dim("not set up — run `npx neuvikon init`")}`);
635
+ if (session) {
636
+ const minutes = Math.round((Date.now() - session.startedAt) / 6e4);
637
+ info(` Tracking ${bold(session.ticket)} ${dim(`· started ${minutes}m ago`)}`);
638
+ } else info(` Tracking ${dim("nothing right now")}`);
639
+ info("");
640
+ }
641
+ //#endregion
642
+ //#region src/commands/stop.ts
643
+ async function stop() {
644
+ const session = readSession();
645
+ if (!session) {
646
+ info(dim("No ticket is being tracked here."));
647
+ return;
648
+ }
649
+ const credentials = readCredentials();
650
+ if (credentials) await post(credentials.url, "/api/agent/events", {
651
+ token: credentials.token,
652
+ event: "stop",
653
+ sessionKey: session.sessionKey
654
+ }).catch(() => void 0);
655
+ clearSession();
656
+ success(`Stopped tracking ${session.ticket}`);
657
+ }
658
+ //#endregion
659
+ //#region src/index.ts
660
+ const HELP = `${bold("neuvikon")} — tell Neuvikon which ticket your coding agent is on
661
+
662
+ ${bold("Usage")}
663
+ npx neuvikon login Sign in through the browser
664
+ npx neuvikon init Teach this project's coding agents to report work
665
+ npx neuvikon start --ticket EWWQ-1
666
+ npx neuvikon stop
667
+ npx neuvikon status Show login, project setup and current ticket
668
+ npx neuvikon logout
669
+
670
+ ${bold("Options")}
671
+ --tool <name> claude-code | codex | cursor | other
672
+ --transcript <path> Read token usage from an agent transcript
673
+ --tokens-in <n> --tokens-out <n>
674
+ --url <site-url> Point at a self-hosted deployment
675
+
676
+ ${dim("Paste a ticket into any coding agent after logging in — it takes it from there.")}`;
677
+ /**
678
+ * Flags are `--name value`, or bare `--name` for a switch. Kept hand-rolled so
679
+ * the package has no runtime dependencies and `npx neuvikon` stays instant.
680
+ */
681
+ function parseArgs(argv) {
682
+ const flags = /* @__PURE__ */ new Map();
683
+ const positional = [];
684
+ for (let index = 0; index < argv.length; index++) {
685
+ const arg = argv[index];
686
+ if (!arg.startsWith("--")) {
687
+ positional.push(arg);
688
+ continue;
689
+ }
690
+ const name = arg.slice(2);
691
+ const next = argv[index + 1];
692
+ if (next === void 0 || next.startsWith("--")) {
693
+ flags.set(name, "");
694
+ continue;
695
+ }
696
+ flags.set(name, next);
697
+ index++;
698
+ }
699
+ return {
700
+ flags,
701
+ positional
702
+ };
703
+ }
704
+ async function main() {
705
+ const [command, ...rest] = process.argv.slice(2);
706
+ const { flags, positional } = parseArgs(rest);
707
+ switch (command) {
708
+ case "login": return login(flags);
709
+ case "logout": return logout();
710
+ case "init": return init();
711
+ case "start": return start(flags, positional);
712
+ case "report": return report(flags);
713
+ case "stop": return stop();
714
+ case "status": return status();
715
+ case void 0:
716
+ case "help":
717
+ case "--help":
718
+ case "-h":
719
+ info(HELP);
720
+ return;
721
+ default: fail(`Unknown command "${command}".`, "Run `npx neuvikon help` to see what's available.");
722
+ }
723
+ }
724
+ main().catch((error) => {
725
+ fail(error instanceof Error ? error.message : "Something went wrong.");
726
+ });
727
+ //#endregion
728
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "neuvikon",
3
+ "version": "0.1.0",
4
+ "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
+ "keywords": [
6
+ "neuvikon",
7
+ "cli",
8
+ "ai-agent",
9
+ "kanban",
10
+ "ticket"
11
+ ],
12
+ "homepage": "https://neuvikon.com",
13
+ "author": "Neuvikon <neuvikon@gmail.com>",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "bin": {
17
+ "neuvikon": "dist/index.mjs"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "scripts": {
28
+ "build": "tsdown",
29
+ "dev": "tsdown --watch",
30
+ "typecheck": "tsc --noEmit",
31
+ "prepublishOnly": "pnpm run typecheck && pnpm run build"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^26.2.0",
35
+ "tsdown": "^0.22.14",
36
+ "typescript": "^7.0.2"
37
+ }
38
+ }