neuvikon 0.2.17 → 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 +11 -6
  2. package/dist/index.mjs +196 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -57,12 +57,17 @@ npx neuvikon start --tickets EWWQ-1,EWWQ-2,EWWQ-3 --spawn
57
57
 
58
58
  Opens one terminal per ticket (a tmux window if you're already in tmux, an
59
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.
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.
66
71
 
67
72
  ## Project pages
68
73
 
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,20 +1167,124 @@ 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
1137
1265
  //#region src/terminal.ts
1138
1266
  function shQuote(value) {
1139
1267
  return `'${value.replace(/'/g, `'\\''`)}'`;
1140
1268
  }
1141
1269
  /**
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.
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.
1147
1281
  */
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`;
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`;
1151
1288
  }
1152
1289
  function escapeAppleScript(value) {
1153
1290
  return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
@@ -1264,22 +1401,54 @@ async function resolveRefs(flags, positional) {
1264
1401
  return [await resolveRef(flags, positional)];
1265
1402
  }
1266
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
+ /**
1267
1428
  * Opens one terminal per ref and stops there — it deliberately never calls
1268
1429
  * `start` itself. A session file written outside of a running agent has no
1269
1430
  * CLAUDE_CODE_SESSION_ID to scope it, so two of these firing in the same
1270
1431
  * 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.
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.
1274
1437
  */
1275
- async function spawnMode(refs) {
1438
+ async function spawnMode(flags, credentials, refs) {
1276
1439
  const cwd = process.cwd();
1440
+ const agent = await resolveAgentCli(flags);
1277
1441
  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}`)}`);
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
+ }
1283
1452
  }
1284
1453
  async function resolveRef(flags, positional) {
1285
1454
  const explicit = flags.get("ticket") ?? positional[0];
@@ -1303,7 +1472,7 @@ async function start(flags, positional) {
1303
1472
  const credentials = readCredentials();
1304
1473
  if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
1305
1474
  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));
1475
+ if (flags.has("spawn")) return spawnMode(flags, credentials, await resolveRefs(flags, positional));
1307
1476
  const ref = await resolveRef(flags, positional);
1308
1477
  const tool = flags.get("tool") ?? detectTool();
1309
1478
  if (!TOOLS.has(tool)) fail(`Unknown tool "${tool}".`, `Expected one of: ${[...TOOLS].join(", ")}.`);
@@ -1400,6 +1569,7 @@ ${bold("Project pages")}
1400
1569
 
1401
1570
  ${bold("Options")}
1402
1571
  --tool <name> claude-code | codex | cursor | other
1572
+ --agent <name> claude-code | codex | cursor — which agent CLI --spawn launches
1403
1573
  --transcript <path> Read token usage from an agent transcript
1404
1574
  --tokens-in <n> --tokens-out <n>
1405
1575
  --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.18",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",