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.
- package/README.md +11 -6
- package/dist/index.mjs +196 -26
- 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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
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
|
|
1150
|
-
|
|
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
|
-
*
|
|
1273
|
-
*
|
|
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)
|
|
1279
|
-
ref
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|