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 +21 -0
- package/README.md +84 -0
- package/dist/index.mjs +728 -0
- package/package.json +38 -0
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
|
+
}
|