traintrack 2.0.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.
@@ -0,0 +1,218 @@
1
+ // Dependency-free interactive prompts. A real terminal gets a proper keyboard
2
+ // navigable checkbox selector (↑/↓ move, space toggle, a all/none, enter
3
+ // confirm, esc cancel) via raw-mode keypress events. Non-TTY input (pipes,
4
+ // tests, CI) falls back to a simple line-based reader. Streams are injectable.
5
+ import { createInterface, emitKeypressEvents } from 'node:readline';
6
+ function resolveIo(io) {
7
+ return {
8
+ input: io?.input ?? process.stdin,
9
+ output: io?.output ?? process.stdout,
10
+ };
11
+ }
12
+ const DIM = '\x1b[2m';
13
+ const CYAN = '\x1b[36m';
14
+ const RST = '\x1b[0m';
15
+ export function keyToAction(key) {
16
+ if (!key)
17
+ return 'ignore';
18
+ if (key.ctrl && key.name === 'c')
19
+ return 'cancel';
20
+ switch (key.name) {
21
+ case 'up':
22
+ case 'k':
23
+ return 'up';
24
+ case 'down':
25
+ case 'j':
26
+ return 'down';
27
+ case 'space':
28
+ return 'toggle';
29
+ case 'a':
30
+ return 'all';
31
+ case 'return':
32
+ case 'enter':
33
+ return 'confirm';
34
+ case 'escape':
35
+ return 'cancel';
36
+ default:
37
+ return 'ignore';
38
+ }
39
+ }
40
+ /** Pure state transition for the selector. `selected` is mutated in place and
41
+ * the new cursor is returned. Tested directly. */
42
+ export function applySelect(action, cursor, selected) {
43
+ const n = selected.length;
44
+ switch (action) {
45
+ case 'up':
46
+ return (cursor - 1 + n) % n;
47
+ case 'down':
48
+ return (cursor + 1) % n;
49
+ case 'toggle':
50
+ selected[cursor] = !selected[cursor];
51
+ return cursor;
52
+ case 'all': {
53
+ const allOn = selected.every(Boolean);
54
+ for (let i = 0; i < n; i++)
55
+ selected[i] = !allOn;
56
+ return cursor;
57
+ }
58
+ default:
59
+ return cursor;
60
+ }
61
+ }
62
+ /** Raw-mode checkbox selector for real terminals. All items start selected. */
63
+ function rawModeSelect(title, items, res) {
64
+ const input = res.input;
65
+ const out = res.output;
66
+ return new Promise((resolve) => {
67
+ let cursor = 0;
68
+ const selected = items.map(() => true);
69
+ emitKeypressEvents(input);
70
+ const wasRaw = Boolean(input.isRaw);
71
+ input.setRawMode?.(true);
72
+ input.resume?.();
73
+ let prevLines = 0;
74
+ const draw = (final) => {
75
+ if (prevLines > 0)
76
+ out.write(`\x1b[${prevLines}A\x1b[0J`);
77
+ const rows = [title];
78
+ items.forEach((it, i) => {
79
+ const here = i === cursor && !final;
80
+ const pointer = here ? `${CYAN}›${RST}` : ' ';
81
+ const box = selected[i] ? `${CYAN}◉${RST}` : '◯';
82
+ const label = here ? `${CYAN}${it.label}${RST}` : it.label;
83
+ rows.push(` ${pointer} ${box} ${label} ${DIM}${it.hint}${RST}`);
84
+ });
85
+ if (!final) {
86
+ rows.push(`${DIM} ↑/↓ move · space toggle · a all/none · enter confirm · esc cancel${RST}`);
87
+ }
88
+ out.write(rows.join('\n') + '\n');
89
+ prevLines = rows.length;
90
+ };
91
+ const finish = (result) => {
92
+ draw(true);
93
+ input.off('keypress', onKey);
94
+ input.setRawMode?.(wasRaw);
95
+ input.pause?.();
96
+ resolve(result);
97
+ };
98
+ const chosen = () => items.filter((_, i) => selected[i]).map((it) => it.id);
99
+ const onKey = (_s, key) => {
100
+ const action = keyToAction(key);
101
+ if (action === 'confirm')
102
+ return finish(chosen());
103
+ if (action === 'cancel')
104
+ return finish([]);
105
+ if (action === 'ignore')
106
+ return;
107
+ cursor = applySelect(action, cursor, selected);
108
+ draw(false);
109
+ };
110
+ input.on('keypress', onKey);
111
+ out.write('\n');
112
+ draw(false);
113
+ });
114
+ }
115
+ /** A line reader backed by ONE readline interface over the input stream. Used
116
+ * for the non-TTY fallback and for confirm(). Buffers early lines. */
117
+ class LineReader {
118
+ rl;
119
+ buffered = [];
120
+ waiters = [];
121
+ closed = false;
122
+ constructor(input) {
123
+ this.rl = createInterface({ input });
124
+ this.rl.on('line', (line) => {
125
+ const waiter = this.waiters.shift();
126
+ if (waiter)
127
+ waiter(line);
128
+ else
129
+ this.buffered.push(line);
130
+ });
131
+ this.rl.on('close', () => {
132
+ this.closed = true;
133
+ while (this.waiters.length > 0) {
134
+ const waiter = this.waiters.shift();
135
+ waiter('');
136
+ }
137
+ });
138
+ }
139
+ next() {
140
+ const queued = this.buffered.shift();
141
+ if (queued !== undefined)
142
+ return Promise.resolve(queued);
143
+ if (this.closed)
144
+ return Promise.resolve('');
145
+ return new Promise((resolve) => this.waiters.push(resolve));
146
+ }
147
+ close() {
148
+ this.rl.close();
149
+ }
150
+ }
151
+ /** Non-TTY fallback: numbered list, type number(s) to toggle, blank to confirm. */
152
+ async function lineModeSelect(title, items, res) {
153
+ const reader = new LineReader(res.input);
154
+ const selected = new Set();
155
+ const render = () => {
156
+ res.output.write(`\n${title}\n`);
157
+ items.forEach((it, i) => {
158
+ res.output.write(` ${selected.has(i) ? '[x]' : '[ ]'} ${i + 1}) ${it.label} (${it.hint})\n`);
159
+ });
160
+ res.output.write('Type number(s) to toggle (e.g. "1 3"), or "all" / "none". Press Enter to confirm.\n> ');
161
+ };
162
+ try {
163
+ for (;;) {
164
+ render();
165
+ const line = (await reader.next()).trim();
166
+ if (line === '')
167
+ break;
168
+ const lower = line.toLowerCase();
169
+ if (lower === 'all') {
170
+ items.forEach((_, i) => selected.add(i));
171
+ continue;
172
+ }
173
+ if (lower === 'none') {
174
+ selected.clear();
175
+ continue;
176
+ }
177
+ for (const tok of line.split(/[\s,]+/).filter((t) => t.length > 0)) {
178
+ const n = Number.parseInt(tok, 10);
179
+ if (Number.isInteger(n) && n >= 1 && n <= items.length) {
180
+ const idx = n - 1;
181
+ if (selected.has(idx))
182
+ selected.delete(idx);
183
+ else
184
+ selected.add(idx);
185
+ }
186
+ }
187
+ }
188
+ }
189
+ finally {
190
+ reader.close();
191
+ }
192
+ return items.filter((_, i) => selected.has(i)).map((it) => it.id);
193
+ }
194
+ /** Multi-select. Real terminals get an arrow-key checkbox UI; non-TTY input
195
+ * (pipes, tests) gets the line-based fallback. Returns the chosen ids. */
196
+ export async function multiSelect(title, items, io) {
197
+ const res = resolveIo(io);
198
+ if (items.length === 0)
199
+ return [];
200
+ const input = res.input;
201
+ if (input.isTTY && typeof input.setRawMode === 'function') {
202
+ return rawModeSelect(title, items, res);
203
+ }
204
+ return lineModeSelect(title, items, res);
205
+ }
206
+ /** Yes/no confirm. Empty input → defaults to false. */
207
+ export async function confirm(question, io) {
208
+ const res = resolveIo(io);
209
+ const reader = new LineReader(res.input);
210
+ res.output.write(`${question} [y/N] `);
211
+ try {
212
+ const line = (await reader.next()).trim().toLowerCase();
213
+ return line === 'y' || line === 'yes';
214
+ }
215
+ finally {
216
+ reader.close();
217
+ }
218
+ }
@@ -0,0 +1,106 @@
1
+ // runSetup / runUninstall orchestrators: detect harnesses, let the user choose
2
+ // (or --all), configure (or --uninstall) each, and print a summary. Never throws
3
+ // on a single harness failing — that harness becomes an 'error' outcome.
4
+ import { homedir } from 'node:os';
5
+ import { resolveServerPath } from './types.js';
6
+ import { detectHarnesses, defaultOnPath } from './detect.js';
7
+ import { configureHarness, unconfigureHarness } from './configure.js';
8
+ import { multiSelect, confirm } from './prompt.js';
9
+ /** Resolve the output sink — defaults to process.stdout. */
10
+ function out(opts) {
11
+ return opts.io?.output ?? process.stdout;
12
+ }
13
+ /** Run one harness through configure/unconfigure, capturing any throw as an
14
+ * 'error' outcome so a single bad harness never aborts the whole run. */
15
+ function applyOne(spec, ctx, uninstall) {
16
+ try {
17
+ return uninstall ? unconfigureHarness(spec, ctx) : configureHarness(spec, ctx);
18
+ }
19
+ catch (err) {
20
+ return {
21
+ harness: spec.id,
22
+ mcp: 'error',
23
+ awareness: 'error',
24
+ files: [],
25
+ detail: err instanceof Error ? err.message : String(err),
26
+ };
27
+ }
28
+ }
29
+ /** Print a per-harness summary table plus a closing note. */
30
+ function printSummary(io, outcomes, ctx, uninstall) {
31
+ io.write('\n');
32
+ for (const o of outcomes) {
33
+ io.write(` ${o.harness.padEnd(8)} mcp:${o.mcp.padEnd(10)} awareness:${o.awareness}\n`);
34
+ if (o.detail)
35
+ io.write(` ${o.detail}\n`);
36
+ for (const f of o.files)
37
+ io.write(` → ${f}\n`);
38
+ }
39
+ io.write('\n');
40
+ if (ctx.dryRun) {
41
+ io.write('Dry run — no files were written.\n');
42
+ return;
43
+ }
44
+ if (uninstall) {
45
+ io.write('Removed traintrack from the selected tools.\n');
46
+ return;
47
+ }
48
+ const names = outcomes.map((o) => o.harness).join(', ');
49
+ io.write(`Done. Open ${names} and ask it to spawn a worker — e.g. "spawn a codex worker to write tests".\n`);
50
+ }
51
+ /**
52
+ * Detect installed harnesses, choose targets (interactively unless --all),
53
+ * and configure (or uninstall) traintrack for each. Returns the outcomes.
54
+ */
55
+ export async function runSetup(opts) {
56
+ const io = out(opts);
57
+ const ctx = {
58
+ home: opts.home ?? homedir(),
59
+ serverPath: resolveServerPath(import.meta.url),
60
+ nodePath: process.execPath,
61
+ dryRun: opts.dryRun ?? false,
62
+ injectAwareness: !opts.toolsOnly,
63
+ };
64
+ const detected = detectHarnesses({ home: ctx.home, onPath: opts.onPath ?? defaultOnPath });
65
+ const present = detected.filter((d) => d.present);
66
+ if (present.length === 0) {
67
+ io.write('No supported agent CLIs detected (Claude Code, Codex, Cursor, OpenCode).\n' +
68
+ 'Install one and re-run `traintrack setup`.\n');
69
+ return [];
70
+ }
71
+ // Choose targets.
72
+ let chosen;
73
+ if (opts.all) {
74
+ chosen = present.map((d) => d.spec.id);
75
+ }
76
+ else {
77
+ const promptIo = { input: opts.io?.input, output: io };
78
+ const verb = opts.uninstall ? 'remove traintrack from' : 'wire traintrack into';
79
+ const ids = await multiSelect(`Which tools should we ${verb}?`, present.map((d) => ({ id: d.spec.id, label: d.spec.displayName, hint: d.reason })), promptIo);
80
+ chosen = ids;
81
+ if (chosen.length === 0) {
82
+ io.write('Nothing selected — exiting.\n');
83
+ return [];
84
+ }
85
+ if (!opts.yes) {
86
+ const ok = await confirm(`${opts.uninstall ? 'Remove from' : 'Configure'} ${chosen.join(', ')}?`, promptIo);
87
+ if (!ok) {
88
+ io.write('Cancelled.\n');
89
+ return [];
90
+ }
91
+ }
92
+ }
93
+ const chosenSpecs = present
94
+ .filter((d) => chosen.includes(d.spec.id))
95
+ .map((d) => d.spec);
96
+ const outcomes = [];
97
+ for (const spec of chosenSpecs) {
98
+ outcomes.push(applyOne(spec, ctx, opts.uninstall ?? false));
99
+ }
100
+ printSummary(io, outcomes, ctx, opts.uninstall ?? false);
101
+ return outcomes;
102
+ }
103
+ /** Convenience wrapper for the uninstall path. */
104
+ export async function runUninstall(opts) {
105
+ return runSetup({ ...opts, uninstall: true });
106
+ }
@@ -0,0 +1,9 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ /** Resolve the abs path to this package's dist/mcp-server.js from the module's
4
+ * own location (works for a global npm install). Exported for setup.ts.
5
+ * The module lives at dist/setup/types.js, so up two to package root, then
6
+ * dist/mcp-server.js. */
7
+ export function resolveServerPath(fromUrl) {
8
+ return resolve(join(dirname(fileURLToPath(fromUrl)), '..', '..', 'dist', 'mcp-server.js'));
9
+ }
@@ -0,0 +1,90 @@
1
+ import { execFile as nodeExecFile, spawn as nodeSpawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ // ── helpers ───────────────────────────────────────────────────────────────────
6
+ /**
7
+ * Resolve the CLI path. Prefer the TRAINTRACK_CLI env var; otherwise resolve
8
+ * relative to this module file so it works both in-repo and when installed.
9
+ */
10
+ function resolveCliPath() {
11
+ if (process.env.TRAINTRACK_CLI) {
12
+ return process.env.TRAINTRACK_CLI;
13
+ }
14
+ // __dirname equivalent for ESM: resolve from this file up two levels then into dist/cli.js
15
+ const thisFile = fileURLToPath(import.meta.url);
16
+ return resolve(thisFile, '../../..', 'dist/cli.js');
17
+ }
18
+ // ── public API ────────────────────────────────────────────────────────────────
19
+ /**
20
+ * Build the command + args needed to launch a worker process.
21
+ * Uses `process.execPath` (node binary) so the worker runs under the same
22
+ * Node version as the current process.
23
+ */
24
+ export function buildWorkerCommand(opts) {
25
+ const { agent, role, handle, channel } = opts;
26
+ const cliPath = resolveCliPath();
27
+ return {
28
+ command: process.execPath,
29
+ args: [cliPath, 'worker', '--agent', agent, '--role', role, '--handle', handle, '--channel', channel],
30
+ };
31
+ }
32
+ /**
33
+ * Spawn a worker in a fresh git worktree.
34
+ *
35
+ * Steps:
36
+ * 1. Validate that repoRoot is a git repo (has .git).
37
+ * 2. Mint a unique handle.
38
+ * 3. Create a git worktree at <repoRoot>/.traintrack/worktrees/<handle> on branch traintrack/<handle>.
39
+ * 4. Register the worker as a channel member.
40
+ * 5. Send the seed task message to the worker handle.
41
+ * 6. Spawn the worker process in the worktree directory (detached, unreffed).
42
+ * 7. Return { handle }.
43
+ */
44
+ export async function spawnWorker(opts) {
45
+ const { channel, repoRoot, agent, role, task, leadHandle, execFileImpl = (cmd, args, optsCb, cb) => nodeExecFile(cmd, args, optsCb, (err) => cb(err)), spawnImpl = (command, args, spawnOpts) => nodeSpawn(command, args, spawnOpts), } = opts;
46
+ // Guard: ensure repoRoot is a git repository
47
+ if (!existsSync(join(repoRoot, '.git'))) {
48
+ throw new Error(`spawnWorker: ${repoRoot} is not a git repo (no .git found)`);
49
+ }
50
+ // Mint a unique worker handle
51
+ const handle = `worker_${crypto.randomUUID().slice(0, 8)}`;
52
+ // Determine worktree path
53
+ const worktreePath = join(repoRoot, '.traintrack', 'worktrees', handle);
54
+ // Create the git worktree
55
+ await new Promise((resolveP, rejectP) => {
56
+ execFileImpl('git', ['worktree', 'add', worktreePath, '-b', `traintrack/${handle}`], { cwd: repoRoot }, (err) => {
57
+ if (err) {
58
+ rejectP(err);
59
+ }
60
+ else {
61
+ resolveP();
62
+ }
63
+ });
64
+ });
65
+ // Register the member in the channel
66
+ channel.addMember({
67
+ handle,
68
+ agent,
69
+ role,
70
+ kind: 'headless',
71
+ status: 'active',
72
+ worktree: worktreePath,
73
+ });
74
+ // Send the seed task message
75
+ channel.insertMessage({
76
+ from: leadHandle ?? 'lead',
77
+ to: handle,
78
+ body: task,
79
+ type: 'task',
80
+ });
81
+ // Build and launch the worker command (detached, unreffed — runs in background)
82
+ const { command, args } = buildWorkerCommand({ agent, role, handle, channel: channel.dbPath });
83
+ const child = spawnImpl(command, args, {
84
+ cwd: worktreePath,
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ detached: true,
87
+ });
88
+ child.unref();
89
+ return { handle };
90
+ }
@@ -0,0 +1,76 @@
1
+ // A dependency-free branded banner for the traintrack CLI: a block-letter
2
+ // wordmark with a horizontal truecolor gradient (blue → purple → pink), plus a
3
+ // dim tagline. Color is applied only when the caller says the terminal supports
4
+ // it; without color the same ASCII renders plain (pipes, NO_COLOR, non-TTY).
5
+ /** 5-row block glyphs for the letters in "TRAINTRACK". Each glyph's rows share
6
+ * a fixed width so the composed rows line up. */
7
+ const GLYPHS = {
8
+ T: ['█████', ' █ ', ' █ ', ' █ ', ' █ '],
9
+ R: ['████ ', '█ █', '████ ', '█ █ ', '█ █'],
10
+ A: [' ███ ', '█ █', '█████', '█ █', '█ █'],
11
+ I: ['███', ' █ ', ' █ ', ' █ ', '███'],
12
+ N: ['█ █', '██ █', '█ █ █', '█ ██', '█ █'],
13
+ C: [' ████', '█ ', '█ ', '█ ', ' ████'],
14
+ K: ['█ █', '█ █ ', '███ ', '█ █ ', '█ █'],
15
+ };
16
+ const BLANK = [' ', ' ', ' ', ' ', ' '];
17
+ // Gradient stops, Gemini-ish: blue → purple → pink.
18
+ const STOPS = [
19
+ [79, 140, 255],
20
+ [160, 108, 255],
21
+ [255, 95, 158],
22
+ ];
23
+ const RESET = '\x1b[0m';
24
+ const DIM = '\x1b[2m';
25
+ function lerp(a, b, t) {
26
+ return Math.round(a + (b - a) * t);
27
+ }
28
+ /** Color at position t∈[0,1] across the two-segment gradient. */
29
+ function gradientAt(t) {
30
+ const seg = t < 0.5 ? 0 : 1;
31
+ const lt = t < 0.5 ? t / 0.5 : (t - 0.5) / 0.5;
32
+ const c0 = STOPS[seg];
33
+ const c1 = STOPS[seg + 1];
34
+ return [lerp(c0[0], c1[0], lt), lerp(c0[1], c1[1], lt), lerp(c0[2], c1[2], lt)];
35
+ }
36
+ function fg([r, g, b]) {
37
+ return `\x1b[38;2;${r};${g};${b}m`;
38
+ }
39
+ /** Render a block-letter wordmark, optionally with the horizontal gradient. */
40
+ export function renderWordmark(word, color) {
41
+ const letters = word.toUpperCase().split('');
42
+ const rows = [];
43
+ for (let r = 0; r < 5; r++) {
44
+ rows.push(letters.map((ch) => (GLYPHS[ch] ?? BLANK)[r]).join(' '));
45
+ }
46
+ if (!color)
47
+ return rows.join('\n');
48
+ const width = rows[0].length;
49
+ return rows
50
+ .map((row) => {
51
+ let out = '';
52
+ for (let i = 0; i < row.length; i++) {
53
+ const ch = row[i];
54
+ if (ch === ' ') {
55
+ out += ' ';
56
+ continue;
57
+ }
58
+ out += fg(gradientAt(width > 1 ? i / (width - 1) : 0)) + ch;
59
+ }
60
+ return out + RESET;
61
+ })
62
+ .join('\n');
63
+ }
64
+ /** The full banner: a blank line, the gradient wordmark, and a dim tagline with
65
+ * the version. Returns a ready-to-write string (trailing blank line included). */
66
+ export function renderBanner(opts) {
67
+ const art = renderWordmark(opts.word ?? 'TRAINTRACK', opts.color);
68
+ const sub = opts.subtitle ?? 'multi-agent coordination for your coding agents';
69
+ const dim = opts.color ? DIM : '';
70
+ const reset = opts.color ? RESET : '';
71
+ return `\n${art}\n\n ${dim}${sub} · v${opts.version}${reset}\n\n`;
72
+ }
73
+ /** Whether to colorize: a real terminal and NO_COLOR not set. */
74
+ export function bannerColorEnabled(stream) {
75
+ return Boolean(stream.isTTY) && process.env['NO_COLOR'] == null;
76
+ }