neuvikon 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -0
  2. package/dist/index.mjs +322 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,6 +36,7 @@ as being worked on, by whom, with which tool.
36
36
  | `neuvikon login` | Sign in through the browser and set up the current project |
37
37
  | `neuvikon init` | Set up another project (credentials are per-machine, rules are per-project) |
38
38
  | `neuvikon start --ticket EWWQ-1` | Begin tracking. `--tool claude-code\|codex\|cursor\|other` |
39
+ | `neuvikon start --tickets EWWQ-1,EWWQ-2 --spawn` | Open one terminal per ticket instead of tracking one here |
39
40
  | `neuvikon stop` | Stop tracking |
40
41
  | `neuvikon status` | Login, project setup, current ticket |
41
42
  | `neuvikon logout` | Revoke this machine's token and forget it |
@@ -48,6 +49,26 @@ as being worked on, by whom, with which tool.
48
49
  pbpaste | npx neuvikon start
49
50
  ```
50
51
 
52
+ ## Kicking off several tickets at once
53
+
54
+ ```sh
55
+ npx neuvikon start --tickets EWWQ-1,EWWQ-2,EWWQ-3 --spawn
56
+ ```
57
+
58
+ Opens one terminal per ticket (a tmux window if you're already in tmux, an
59
+ iTerm2 or Terminal.app tab on macOS, a Windows Terminal tab on Windows), each
60
+ `cd`'d into this directory. If a coding agent CLI is installed (Claude Code,
61
+ Cursor, Codex) it's launched in each terminal with that ticket's full text as
62
+ its first prompt — same substance a manual paste would give it. With more
63
+ than one installed, you're asked once which to use and that choice is
64
+ remembered (`--agent <name>` overrides it any time). Nothing is tracked
65
+ automatically, even then: a session only scopes safely once an agent is
66
+ actually running, so the launched agent runs `npx neuvikon start --ticket
67
+ EWWQ-1` itself once it's up, via its rules file, same as a manual paste
68
+ always has. Without a detected agent, or on a platform/terminal this can't
69
+ drive, it just prints the ticket ref and that command for you to paste by
70
+ hand.
71
+
51
72
  ## Project pages
52
73
 
53
74
  An agent's most useful output often isn't the ticket, it's what it learned
package/dist/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { delimiter, dirname, join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ import { createInterface } from "node:readline/promises";
6
7
  //#region src/api.ts
7
8
  var ApiError = class extends Error {
8
9
  status;
@@ -79,6 +80,23 @@ function resolveDeployment(flags) {
79
80
  const DEFAULT_SITE_URL = process.env.NEUVIKON_URL ?? DEPLOYMENTS.production.api;
80
81
  const CONFIG_DIR = join(homedir(), ".neuvikon");
81
82
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
83
+ function readConfigFile() {
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
86
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+ function writeConfigFile(config) {
92
+ mkdirSync(CONFIG_DIR, {
93
+ recursive: true,
94
+ mode: 448
95
+ });
96
+ chmodSync(CONFIG_DIR, 448);
97
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
98
+ chmodSync(CONFIG_PATH, 384);
99
+ }
82
100
  /**
83
101
  * Somewhere a browser cannot open — CI, a container, a remote box — there is no
84
102
  * way to complete a device login, so a token handed in through the environment
@@ -116,16 +134,15 @@ function readCredentials() {
116
134
  /**
117
135
  * A token on disk is only as safe as its permissions, so directory and file are
118
136
  * locked to the owner on every write, not just on first create — an earlier
119
- * version may have left them readable.
137
+ * version may have left them readable. Merges over the existing file rather
138
+ * than replacing it outright, so a re-login doesn't silently drop unrelated
139
+ * settings this file has grown since (e.g. `preferredAgent`).
120
140
  */
121
141
  function writeCredentials(credentials) {
122
- mkdirSync(CONFIG_DIR, {
123
- recursive: true,
124
- mode: 448
142
+ writeConfigFile({
143
+ ...readConfigFile(),
144
+ ...credentials
125
145
  });
126
- chmodSync(CONFIG_DIR, 448);
127
- writeFileSync(CONFIG_PATH, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
128
- chmodSync(CONFIG_PATH, 384);
129
146
  }
130
147
  function clearCredentials() {
131
148
  try {
@@ -135,6 +152,22 @@ function clearCredentials() {
135
152
  return false;
136
153
  }
137
154
  }
155
+ /**
156
+ * Which agent CLI `start --spawn` should launch without asking, remembered
157
+ * across invocations once the user has answered the question once. Kept in
158
+ * the same config file as credentials rather than a separate one — it's the
159
+ * same "settings for this machine" concern.
160
+ */
161
+ function readPreferredAgent() {
162
+ const value = readConfigFile().preferredAgent;
163
+ return typeof value === "string" && value ? value : null;
164
+ }
165
+ function writePreferredAgent(id) {
166
+ writeConfigFile({
167
+ ...readConfigFile(),
168
+ preferredAgent: id
169
+ });
170
+ }
138
171
  //#endregion
139
172
  //#region src/session.ts
140
173
  /**
@@ -1134,6 +1167,210 @@ async function pages(flags, positional) {
1134
1167
  }
1135
1168
  }
1136
1169
  //#endregion
1170
+ //#region src/agentCli.ts
1171
+ const KNOWN_AGENT_CLIS = [
1172
+ {
1173
+ id: "claude-code",
1174
+ label: "Claude Code",
1175
+ binary: "claude",
1176
+ buildArgs: (prompt) => [prompt]
1177
+ },
1178
+ {
1179
+ id: "cursor",
1180
+ label: "Cursor",
1181
+ binary: "cursor-agent",
1182
+ buildArgs: (prompt) => [prompt]
1183
+ },
1184
+ {
1185
+ id: "codex",
1186
+ label: "Codex",
1187
+ binary: "codex",
1188
+ buildArgs: (prompt) => [prompt]
1189
+ }
1190
+ ];
1191
+ /**
1192
+ * Manual PATH scan instead of shelling out to `which`/`where`: keeps this
1193
+ * dependency-free and synchronous, and doesn't assume either helper exists
1194
+ * (minimal containers, and Windows has neither `which` nor always `where`
1195
+ * usable from a spawned child the same way).
1196
+ */
1197
+ function isOnPath(binary) {
1198
+ const pathEnv = process.env.PATH ?? "";
1199
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
1200
+ for (const dir of pathEnv.split(delimiter)) {
1201
+ if (!dir) continue;
1202
+ for (const ext of exts) if (existsSync(join(dir, binary + ext))) return true;
1203
+ }
1204
+ return false;
1205
+ }
1206
+ function detectAgentClis() {
1207
+ return KNOWN_AGENT_CLIS.filter((agent) => isOnPath(agent.binary));
1208
+ }
1209
+ function findById(id) {
1210
+ return KNOWN_AGENT_CLIS.find((agent) => agent.id === id);
1211
+ }
1212
+ /**
1213
+ * Only prompts when stdin is a real terminal: `--tickets`/`--spawn` never
1214
+ * reads stdin for the ticket itself (unlike plain `start`), so it's free for
1215
+ * this, but a non-interactive invocation (CI, a script) must never block on
1216
+ * input that will never arrive.
1217
+ */
1218
+ async function promptForAgent(candidates) {
1219
+ if (!process.stdin.isTTY) return candidates[0] ?? null;
1220
+ info("Multiple coding agents found on this machine:");
1221
+ candidates.forEach((agent, index) => info(` ${index + 1}. ${agent.label}`));
1222
+ info(dim("This choice is remembered — override any time with --agent <name>."));
1223
+ const rl = createInterface({
1224
+ input: process.stdin,
1225
+ output: process.stderr
1226
+ });
1227
+ try {
1228
+ for (;;) {
1229
+ const answer = (await rl.question(`Which one should Neuvikon launch? [1-${candidates.length}]: `)).trim();
1230
+ const picked = candidates[Number(answer) - 1];
1231
+ if (picked) return picked;
1232
+ info(dim(`Enter a number from 1 to ${candidates.length}.`));
1233
+ }
1234
+ } finally {
1235
+ rl.close();
1236
+ }
1237
+ }
1238
+ /**
1239
+ * Resolves which agent CLI (if any) `--spawn` should launch. `--agent` is a
1240
+ * one-off override and is never persisted, same as `--tool` never rewrites
1241
+ * anything. Otherwise: a stored preference wins as long as it's still on
1242
+ * this machine's PATH (an uninstalled tool shouldn't stay sticky forever);
1243
+ * failing that, one candidate is used silently, and more than one is asked
1244
+ * about exactly once and then remembered.
1245
+ */
1246
+ async function resolveAgentCli(flags) {
1247
+ const explicit = flags.get("agent");
1248
+ if (explicit) {
1249
+ const agent = findById(explicit);
1250
+ if (!agent) fail(`Unknown agent "${explicit}".`, `Expected one of: ${KNOWN_AGENT_CLIS.map((a) => a.id).join(", ")}.`);
1251
+ return agent;
1252
+ }
1253
+ const candidates = detectAgentClis();
1254
+ if (candidates.length === 0) return null;
1255
+ const preferredId = readPreferredAgent();
1256
+ if (preferredId) {
1257
+ const preferred = candidates.find((agent) => agent.id === preferredId);
1258
+ if (preferred) return preferred;
1259
+ }
1260
+ const picked = candidates.length === 1 ? candidates[0] : await promptForAgent(candidates);
1261
+ if (picked) writePreferredAgent(picked.id);
1262
+ return picked;
1263
+ }
1264
+ //#endregion
1265
+ //#region src/terminal.ts
1266
+ function shQuote(value) {
1267
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1268
+ }
1269
+ /**
1270
+ * Never `exec`s the agent in place: if it's the wrong binary, isn't actually
1271
+ * installed, or the user just exits it, the terminal must not go on to close
1272
+ * or hang — it drops to the same interactive shell either way, ticket ref
1273
+ * already printed above.
1274
+ *
1275
+ * Still deliberately does not run `neuvikon start` itself: that only scopes
1276
+ * safely once an agent is attached (see session.ts's instanceKey), and
1277
+ * nothing is attached yet in a terminal that just opened. Launching the
1278
+ * agent (rather than `start`) is safe precisely because the agent is the one
1279
+ * that calls `start`, once it's actually running, via the rules file — same
1280
+ * as if the ticket had been pasted in by hand.
1281
+ */
1282
+ function buildInnerCommand({ ref, cwd, launch }) {
1283
+ const lines = [`Ticket: ${ref}`];
1284
+ if (!launch) lines.push(`Paste it into your agent, or run: npx neuvikon start --ticket ${ref}`);
1285
+ const echoes = lines.map((line) => `echo ${shQuote(line)}`).join(" && ");
1286
+ const launchCmd = launch ? `${launch.binary} ${launch.args.map(shQuote).join(" ")}; ` : "";
1287
+ return `cd ${shQuote(cwd)} && ${echoes} && ${launchCmd}exec $SHELL -l`;
1288
+ }
1289
+ function escapeAppleScript(value) {
1290
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
1291
+ }
1292
+ /**
1293
+ * Same shape as openBrowser: fire-and-forget, never throws. A `false` return
1294
+ * means the spawn call itself failed synchronously (e.g. the binary isn't on
1295
+ * PATH) — the async case (binary exists but the app itself errors) is
1296
+ * swallowed by the "error" listener, same tradeoff openBrowser already makes.
1297
+ */
1298
+ function fireAndForget(command, args) {
1299
+ try {
1300
+ const child = spawn(command, args, {
1301
+ stdio: "ignore",
1302
+ detached: true
1303
+ });
1304
+ child.on("error", () => {});
1305
+ child.unref();
1306
+ return true;
1307
+ } catch {
1308
+ return false;
1309
+ }
1310
+ }
1311
+ function spawnViaTmux(target) {
1312
+ return fireAndForget("tmux", [
1313
+ "new-window",
1314
+ "-n",
1315
+ target.ref,
1316
+ "-c",
1317
+ target.cwd,
1318
+ buildInnerCommand(target)
1319
+ ]);
1320
+ }
1321
+ /**
1322
+ * iTerm2 needs its own dialect (Terminal.app's `do script` isn't understood
1323
+ * by it) — detected through TERM_PROGRAM rather than "is it running", because
1324
+ * a user who has iTerm2 installed but is currently in Terminal.app wants a
1325
+ * Terminal.app tab, not a background iTerm2 window they won't notice.
1326
+ */
1327
+ function spawnOnDarwin(target) {
1328
+ const inner = escapeAppleScript(buildInnerCommand(target));
1329
+ return fireAndForget("osascript", ["-e", process.env.TERM_PROGRAM === "iTerm.app" ? `tell application "iTerm2"
1330
+ activate
1331
+ try
1332
+ tell current window to create tab with default profile
1333
+ on error
1334
+ create window with default profile
1335
+ end try
1336
+ tell current window to tell current tab to tell current session to write text "${inner}"
1337
+ end tell` : `tell application "Terminal" to do script "${inner}"`]);
1338
+ }
1339
+ function spawnOnWindows(target) {
1340
+ const inner = buildInnerCommand(target).replace(/"/g, "\\\"");
1341
+ return fireAndForget("wt", [
1342
+ "-w",
1343
+ "0",
1344
+ "nt",
1345
+ "-d",
1346
+ target.cwd,
1347
+ "powershell",
1348
+ "-NoExit",
1349
+ "-Command",
1350
+ inner
1351
+ ]);
1352
+ }
1353
+ /**
1354
+ * `x-terminal-emulator` is the Debian/Ubuntu alternatives symlink to
1355
+ * whichever terminal the user has set as default — the closest thing Linux
1356
+ * has to a single common denominator. Anything else degrades to the plain
1357
+ * command list the caller already prints.
1358
+ */
1359
+ function spawnOnLinux(target) {
1360
+ return fireAndForget("x-terminal-emulator", [
1361
+ "-e",
1362
+ "bash",
1363
+ "-c",
1364
+ `${buildInnerCommand(target)}; exec bash`
1365
+ ]);
1366
+ }
1367
+ function spawnTerminal(target) {
1368
+ if (process.env.TMUX) return spawnViaTmux(target);
1369
+ if (process.platform === "darwin") return spawnOnDarwin(target);
1370
+ if (process.platform === "win32") return spawnOnWindows(target);
1371
+ return spawnOnLinux(target);
1372
+ }
1373
+ //#endregion
1137
1374
  //#region src/commands/start.ts
1138
1375
  const TOOLS = /* @__PURE__ */ new Set([
1139
1376
  "claude-code",
@@ -1141,6 +1378,78 @@ const TOOLS = /* @__PURE__ */ new Set([
1141
1378
  "cursor",
1142
1379
  "other"
1143
1380
  ]);
1381
+ /**
1382
+ * Explicit and comma-separated, not scraped from stdin like a single ref
1383
+ * would be: a ticket's own description can easily mention other refs
1384
+ * ("see NS-12"), and those must never trigger opening terminals for them.
1385
+ */
1386
+ function parseTicketsFlag(raw) {
1387
+ const seen = /* @__PURE__ */ new Set();
1388
+ for (const token of raw.split(",")) {
1389
+ const trimmed = token.trim();
1390
+ if (!trimmed) continue;
1391
+ const [ref] = extractTicketRefs(trimmed);
1392
+ if (!ref || ref !== trimmed.toUpperCase()) fail(`"${trimmed}" is not a ticket reference.`, "Expected something like EWWQ-1.");
1393
+ seen.add(ref);
1394
+ }
1395
+ if (seen.size === 0) fail("No ticket references found in --tickets.", "Expected something like NS-1,NS-2.");
1396
+ return [...seen];
1397
+ }
1398
+ async function resolveRefs(flags, positional) {
1399
+ const raw = flags.get("tickets");
1400
+ if (raw) return parseTicketsFlag(raw);
1401
+ return [await resolveRef(flags, positional)];
1402
+ }
1403
+ /**
1404
+ * Best effort, same shape as `spawnTerminal` itself: a ticket that can't be
1405
+ * fetched (offline, a typo'd ref, a revoked token) degrades that one terminal
1406
+ * back to the plain print-and-shell fallback rather than failing the whole
1407
+ * batch, or worse, launching an agent with nothing to work from.
1408
+ */
1409
+ async function fetchTicketText(credentials, ref) {
1410
+ try {
1411
+ return (await post(credentials.url, "/api/agent/ticket", {
1412
+ token: credentials.token,
1413
+ ticketRef: ref
1414
+ })).text;
1415
+ } catch {
1416
+ return null;
1417
+ }
1418
+ }
1419
+ async function buildLaunch(agent, credentials, ref) {
1420
+ const text = await fetchTicketText(credentials, ref);
1421
+ if (!text) return null;
1422
+ return {
1423
+ binary: agent.binary,
1424
+ args: agent.buildArgs(text)
1425
+ };
1426
+ }
1427
+ /**
1428
+ * Opens one terminal per ref and stops there — it deliberately never calls
1429
+ * `start` itself. A session file written outside of a running agent has no
1430
+ * CLAUDE_CODE_SESSION_ID to scope it, so two of these firing in the same
1431
+ * directory before either agent launches would each read the other's
1432
+ * not-yet-attached session as "existing" and stop it out from under it. That
1433
+ * still holds here: a detected agent CLI is launched with the ticket's text
1434
+ * as its first prompt, but this never calls `start` itself — the agent does,
1435
+ * once it's actually running, via the rules file, exactly as if the ticket
1436
+ * had been pasted in by hand.
1437
+ */
1438
+ async function spawnMode(flags, credentials, refs) {
1439
+ const cwd = process.cwd();
1440
+ const agent = await resolveAgentCli(flags);
1441
+ info(`Opening ${refs.length} terminal${refs.length === 1 ? "" : "s"} in ${cwd}...`);
1442
+ for (const ref of refs) {
1443
+ const launch = agent ? await buildLaunch(agent, credentials, ref) : null;
1444
+ const startingLabel = launch && agent ? agent.label : null;
1445
+ if (spawnTerminal({
1446
+ ref,
1447
+ cwd,
1448
+ launch
1449
+ })) success(startingLabel ? `${bold(ref)} — ${startingLabel} starting` : `${bold(ref)} — new terminal opened`);
1450
+ else info(`${bold(ref)} — couldn't open a terminal here, paste manually: ${dim(`npx neuvikon start --ticket ${ref}`)}`);
1451
+ }
1452
+ }
1144
1453
  async function resolveRef(flags, positional) {
1145
1454
  const explicit = flags.get("ticket") ?? positional[0];
1146
1455
  if (explicit) {
@@ -1162,6 +1471,8 @@ function detectTool() {
1162
1471
  async function start(flags, positional) {
1163
1472
  const credentials = readCredentials();
1164
1473
  if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
1474
+ if (flags.has("tickets") && !flags.has("spawn")) fail("--tickets needs --spawn.", "Opening more than one terminal is the only thing --tickets does.");
1475
+ if (flags.has("spawn")) return spawnMode(flags, credentials, await resolveRefs(flags, positional));
1165
1476
  const ref = await resolveRef(flags, positional);
1166
1477
  const tool = flags.get("tool") ?? detectTool();
1167
1478
  if (!TOOLS.has(tool)) fail(`Unknown tool "${tool}".`, `Expected one of: ${[...TOOLS].join(", ")}.`);
@@ -1235,6 +1546,8 @@ ${bold("Usage")}
1235
1546
  npx neuvikon login Sign in through the browser
1236
1547
  npx neuvikon init Teach this project's coding agents to report work
1237
1548
  npx neuvikon start --ticket EWWQ-1
1549
+ npx neuvikon start --tickets EWWQ-1,EWWQ-2 --spawn
1550
+ Open one terminal per ticket instead of tracking one here
1238
1551
  npx neuvikon stop
1239
1552
  npx neuvikon status Show login, project setup and current ticket
1240
1553
  npx neuvikon logout
@@ -1256,6 +1569,7 @@ ${bold("Project pages")}
1256
1569
 
1257
1570
  ${bold("Options")}
1258
1571
  --tool <name> claude-code | codex | cursor | other
1572
+ --agent <name> claude-code | codex | cursor — which agent CLI --spawn launches
1259
1573
  --transcript <path> Read token usage from an agent transcript
1260
1574
  --tokens-in <n> --tokens-out <n>
1261
1575
  --dev Use the development deployment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",