neuvikon 0.2.17 → 0.2.19

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 +16 -8
  2. package/dist/index.mjs +314 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -55,14 +55,22 @@ pbpaste | npx neuvikon start
55
55
  npx neuvikon start --tickets EWWQ-1,EWWQ-2,EWWQ-3 --spawn
56
56
  ```
57
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 with the ticket ref printed and waiting — nothing
61
- is tracked automatically. That's deliberate: a session only scopes safely
62
- once an agent is actually running in that terminal, so paste the ticket into
63
- your agent (or run `npx neuvikon start --ticket EWWQ-1`) once it's up, same
64
- as any other ticket. On a platform or terminal this can't drive, it just
65
- prints the same command for you to paste by hand.
58
+ If [Herdr](https://herdr.dev) is installed, every ticket in the batch lands as
59
+ its own tab in one Herdr workspace instead of separate OS windows — Herdr's
60
+ own UI then shows each agent's live working/blocked/done state. Otherwise it
61
+ opens one terminal per ticket the usual way (a tmux window if you're already
62
+ in tmux, an iTerm2 or Terminal.app tab on macOS, a Windows Terminal tab on
63
+ Windows), each `cd`'d into this directory. Either way, if a coding agent CLI
64
+ is installed (Claude Code, Cursor, Codex) it's launched with that ticket's
65
+ full text as its first prompt — same substance a manual paste would give it.
66
+ With more than one installed, you're asked once which to use and that choice
67
+ is remembered (`--agent <name>` overrides it any time). Nothing is tracked
68
+ automatically, even then: a session only scopes safely once an agent is
69
+ actually running, so the launched agent runs `npx neuvikon start --ticket
70
+ EWWQ-1` itself once it's up, via its rules file, same as a manual paste
71
+ always has. Without a detected agent, or on a platform/terminal this can't
72
+ drive, it just prints the ticket ref and that command for you to paste by
73
+ hand.
66
74
 
67
75
  ## Project pages
68
76
 
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";
5
- import { spawn } from "node:child_process";
4
+ import { delimiter, dirname, join } from "node:path";
5
+ import { execFileSync, 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,20 +1167,234 @@ 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/herdr.ts
1266
+ /** Herdr's `--kind` values for the agents we already know how to pick between. */
1267
+ const HERDR_KIND = {
1268
+ "claude-code": "claude",
1269
+ codex: "codex",
1270
+ cursor: "cursor"
1271
+ };
1272
+ function herdrAvailable() {
1273
+ return isOnPath("herdr");
1274
+ }
1275
+ /** Every mutating Herdr socket-API command prints one JSON object; a failure prints to stderr instead. */
1276
+ function runHerdr(args) {
1277
+ try {
1278
+ const output = execFileSync("herdr", args, {
1279
+ encoding: "utf8",
1280
+ stdio: [
1281
+ "ignore",
1282
+ "pipe",
1283
+ "ignore"
1284
+ ]
1285
+ });
1286
+ return JSON.parse(output);
1287
+ } catch {
1288
+ return null;
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Herdr's `agent start` requires a name matching `[a-z][a-z0-9_-]{0,31}`,
1293
+ * unique among live agents. A ticket ref (`NS-60`) lowercases straight into
1294
+ * that shape, and reusing the ref as the name is what makes `herdr agent
1295
+ * get NS-60`-style lookups obvious later.
1296
+ */
1297
+ function agentName(ref) {
1298
+ return ref.toLowerCase();
1299
+ }
1300
+ /**
1301
+ * One Herdr workspace per `--spawn` call, one tab per ticket, so a whole
1302
+ * batch shows up together in Herdr's own UI — with its live
1303
+ * working/blocked/done state per agent — instead of scattered across OS
1304
+ * windows. The first successful ref seeds the workspace; every later ref in
1305
+ * the same batch adds a tab to it.
1306
+ */
1307
+ var HerdrBatch = class {
1308
+ workspaceId = null;
1309
+ openPane(ref, cwd) {
1310
+ if (!this.workspaceId) {
1311
+ const created = runHerdr([
1312
+ "workspace",
1313
+ "create",
1314
+ "--cwd",
1315
+ cwd,
1316
+ "--label",
1317
+ ref,
1318
+ "--focus"
1319
+ ]);
1320
+ if (!created) return null;
1321
+ this.workspaceId = created.result.workspace.workspace_id;
1322
+ return created.result.root_pane.pane_id;
1323
+ }
1324
+ return runHerdr([
1325
+ "tab",
1326
+ "create",
1327
+ "--workspace",
1328
+ this.workspaceId,
1329
+ "--cwd",
1330
+ cwd,
1331
+ "--label",
1332
+ ref,
1333
+ "--focus"
1334
+ ])?.result.root_pane.pane_id ?? null;
1335
+ }
1336
+ /**
1337
+ * True once this ref has a live Herdr pane — even if starting an agent in
1338
+ * it failed, in which case the pane still exists with the same
1339
+ * print-and-wait fallback a plain spawned terminal gets. False only means
1340
+ * Herdr itself couldn't be reached, and the caller should fall back to
1341
+ * opening a native OS terminal for this ref instead.
1342
+ */
1343
+ spawn(ref, cwd, agent, ticketText) {
1344
+ const paneId = this.openPane(ref, cwd);
1345
+ if (!paneId) return false;
1346
+ if (agent && ticketText) {
1347
+ if (runHerdr([
1348
+ "agent",
1349
+ "start",
1350
+ agentName(ref),
1351
+ "--kind",
1352
+ HERDR_KIND[agent.id],
1353
+ "--pane",
1354
+ paneId
1355
+ ])) {
1356
+ runHerdr([
1357
+ "agent",
1358
+ "prompt",
1359
+ agentName(ref),
1360
+ ticketText
1361
+ ]);
1362
+ return true;
1363
+ }
1364
+ }
1365
+ runHerdr([
1366
+ "pane",
1367
+ "run",
1368
+ paneId,
1369
+ `echo 'Ticket: ${ref}' && echo 'Paste it into your agent, or run: npx neuvikon start --ticket ${ref}'`
1370
+ ]);
1371
+ return true;
1372
+ }
1373
+ };
1374
+ //#endregion
1137
1375
  //#region src/terminal.ts
1138
1376
  function shQuote(value) {
1139
1377
  return `'${value.replace(/'/g, `'\\''`)}'`;
1140
1378
  }
1141
1379
  /**
1142
- * Deliberately does not run `neuvikon start` itself: that only scopes safely
1143
- * once an agent is attached (see session.ts's instanceKey), and nothing is
1144
- * attached yet in a terminal that just opened. Printing the ticket and
1145
- * dropping to a shell lets the user launch their agent first, same as typing
1146
- * the ticket by hand always has.
1380
+ * Never `exec`s the agent in place: if it's the wrong binary, isn't actually
1381
+ * installed, or the user just exits it, the terminal must not go on to close
1382
+ * or hang — it drops to the same interactive shell either way, ticket ref
1383
+ * already printed above.
1384
+ *
1385
+ * Still deliberately does not run `neuvikon start` itself: that only scopes
1386
+ * safely once an agent is attached (see session.ts's instanceKey), and
1387
+ * nothing is attached yet in a terminal that just opened. Launching the
1388
+ * agent (rather than `start`) is safe precisely because the agent is the one
1389
+ * that calls `start`, once it's actually running, via the rules file — same
1390
+ * as if the ticket had been pasted in by hand.
1147
1391
  */
1148
- function buildInnerCommand({ ref, cwd }) {
1149
- const echoes = [`Ticket: ${ref}`, `Paste it into your agent, or run: npx neuvikon start --ticket ${ref}`].map((line) => `echo ${shQuote(line)}`).join(" && ");
1150
- return `cd ${shQuote(cwd)} && ${echoes} && exec $SHELL -l`;
1392
+ function buildInnerCommand({ ref, cwd, launch }) {
1393
+ const lines = [`Ticket: ${ref}`];
1394
+ if (!launch) lines.push(`Paste it into your agent, or run: npx neuvikon start --ticket ${ref}`);
1395
+ const echoes = lines.map((line) => `echo ${shQuote(line)}`).join(" && ");
1396
+ const launchCmd = launch ? `${launch.binary} ${launch.args.map(shQuote).join(" ")}; ` : "";
1397
+ return `cd ${shQuote(cwd)} && ${echoes} && ${launchCmd}exec $SHELL -l`;
1151
1398
  }
1152
1399
  function escapeAppleScript(value) {
1153
1400
  return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
@@ -1264,22 +1511,60 @@ async function resolveRefs(flags, positional) {
1264
1511
  return [await resolveRef(flags, positional)];
1265
1512
  }
1266
1513
  /**
1514
+ * Best effort, same shape as `spawnTerminal` itself: a ticket that can't be
1515
+ * fetched (offline, a typo'd ref, a revoked token) degrades that one terminal
1516
+ * back to the plain print-and-shell fallback rather than failing the whole
1517
+ * batch, or worse, launching an agent with nothing to work from.
1518
+ */
1519
+ async function fetchTicketText(credentials, ref) {
1520
+ try {
1521
+ return (await post(credentials.url, "/api/agent/ticket", {
1522
+ token: credentials.token,
1523
+ ticketRef: ref
1524
+ })).text;
1525
+ } catch {
1526
+ return null;
1527
+ }
1528
+ }
1529
+ /**
1267
1530
  * Opens one terminal per ref and stops there — it deliberately never calls
1268
1531
  * `start` itself. A session file written outside of a running agent has no
1269
1532
  * CLAUDE_CODE_SESSION_ID to scope it, so two of these firing in the same
1270
1533
  * directory before either agent launches would each read the other's
1271
- * not-yet-attached session as "existing" and stop it out from under it.
1272
- * Letting the user (or their agent) run `start` once it's actually running
1273
- * is what keeps concurrent tracking safe.
1534
+ * not-yet-attached session as "existing" and stop it out from under it. That
1535
+ * still holds here: a detected agent CLI is launched with the ticket's text
1536
+ * as its first prompt, but this never calls `start` itself — the agent does,
1537
+ * once it's actually running, via the rules file, exactly as if the ticket
1538
+ * had been pasted in by hand.
1539
+ *
1540
+ * Herdr, when installed, wins over a native OS terminal: it's a multiplexer
1541
+ * built for exactly this (multiple coding agents, one UI, live
1542
+ * working/blocked/done state), so a whole `--tickets` batch lands as one
1543
+ * Herdr workspace with one tab per ticket instead of scattered app windows.
1274
1544
  */
1275
- async function spawnMode(refs) {
1545
+ async function spawnMode(flags, credentials, refs) {
1276
1546
  const cwd = process.cwd();
1277
- info(`Opening ${refs.length} terminal${refs.length === 1 ? "" : "s"} in ${cwd}...`);
1278
- for (const ref of refs) if (spawnTerminal({
1279
- ref,
1280
- cwd
1281
- })) success(`${bold(ref)} — new terminal opened`);
1282
- else info(`${bold(ref)} — couldn't open a terminal here, paste manually: ${dim(`npx neuvikon start --ticket ${ref}`)}`);
1547
+ const agent = await resolveAgentCli(flags);
1548
+ const herdr = herdrAvailable() ? new HerdrBatch() : null;
1549
+ info(`Opening ${refs.length} terminal${refs.length === 1 ? "" : "s"}${herdr ? " in Herdr" : ` in ${cwd}`}...`);
1550
+ for (const ref of refs) {
1551
+ const ticketText = agent ? await fetchTicketText(credentials, ref) : null;
1552
+ if (herdr?.spawn(ref, cwd, agent, ticketText)) {
1553
+ success(`${bold(ref)} — ${agent && ticketText ? `${agent.label} starting in Herdr` : "opened in Herdr"}`);
1554
+ continue;
1555
+ }
1556
+ const launch = agent && ticketText ? {
1557
+ binary: agent.binary,
1558
+ args: agent.buildArgs(ticketText)
1559
+ } : null;
1560
+ const startingLabel = launch && agent ? agent.label : null;
1561
+ if (spawnTerminal({
1562
+ ref,
1563
+ cwd,
1564
+ launch
1565
+ })) success(startingLabel ? `${bold(ref)} — ${startingLabel} starting` : `${bold(ref)} — new terminal opened`);
1566
+ else info(`${bold(ref)} — couldn't open a terminal here, paste manually: ${dim(`npx neuvikon start --ticket ${ref}`)}`);
1567
+ }
1283
1568
  }
1284
1569
  async function resolveRef(flags, positional) {
1285
1570
  const explicit = flags.get("ticket") ?? positional[0];
@@ -1303,7 +1588,7 @@ async function start(flags, positional) {
1303
1588
  const credentials = readCredentials();
1304
1589
  if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
1305
1590
  if (flags.has("tickets") && !flags.has("spawn")) fail("--tickets needs --spawn.", "Opening more than one terminal is the only thing --tickets does.");
1306
- if (flags.has("spawn")) return spawnMode(await resolveRefs(flags, positional));
1591
+ if (flags.has("spawn")) return spawnMode(flags, credentials, await resolveRefs(flags, positional));
1307
1592
  const ref = await resolveRef(flags, positional);
1308
1593
  const tool = flags.get("tool") ?? detectTool();
1309
1594
  if (!TOOLS.has(tool)) fail(`Unknown tool "${tool}".`, `Expected one of: ${[...TOOLS].join(", ")}.`);
@@ -1400,6 +1685,7 @@ ${bold("Project pages")}
1400
1685
 
1401
1686
  ${bold("Options")}
1402
1687
  --tool <name> claude-code | codex | cursor | other
1688
+ --agent <name> claude-code | codex | cursor — which agent CLI --spawn launches
1403
1689
  --transcript <path> Read token usage from an agent transcript
1404
1690
  --tokens-in <n> --tokens-out <n>
1405
1691
  --dev Use the development deployment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",