gitroots 0.0.0-stage → 0.6.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,71 @@
1
+ // Shared bits for commands: loading spinners, hints, empty states, time.
2
+ import { c, h, pad, fit, width } from '../ui/ansi.js';
3
+ import { line, spinner, size } from '../ui/term.js';
4
+ import { ApiError } from '../api.js';
5
+
6
+ export { c, h, pad, fit, width, line };
7
+
8
+ export function cols() { return Math.max(60, Math.min(size().cols, 160)); }
9
+
10
+ // "/status" inside the session, "gitroots status" outside.
11
+ export function cmdText(ctx, name, args = '') {
12
+ return ctx.session ? `/${name}${args ? ` ${args}` : ''}` : `gitroots ${name}${args ? ` ${args}` : ''}`;
13
+ }
14
+
15
+ export function next(ctx, name, args, alt) {
16
+ const tail = alt ? ` ${c.m('or')} ${c.s(alt)}` : '';
17
+ line(`${c.g(`→ ${cmdText(ctx, name, args)}`)}${tail}`);
18
+ }
19
+
20
+ export function needSignIn(ctx) {
21
+ if (ctx.api.signedIn) return;
22
+ throw new ApiError(`not signed in · ${cmdText(ctx, 'login')} connects your gitroots account`, 401);
23
+ }
24
+
25
+ export function needWorkspace(ctx) {
26
+ needSignIn(ctx);
27
+ if (ctx.api.orgId === null || ctx.api.orgId === undefined) throw new ApiError(`no workspace chosen · ${cmdText(ctx, 'workspace')}`, 400);
28
+ }
29
+
30
+ /** Await work behind a one-line spinner. */
31
+ export async function load(ctx, label, fn) {
32
+ if (ctx.json) return fn();
33
+ const sp = spinner((f) => `${c.i(f)} ${c.s(label)}`);
34
+ try { return await fn(); } finally { sp.stop(); }
35
+ }
36
+
37
+ export function ago(iso) {
38
+ if (!iso) return '';
39
+ const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
40
+ if (s < 60) return `${s}s ago`;
41
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
42
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
43
+ return `${Math.floor(s / 86400)}d ago`;
44
+ }
45
+
46
+ export function empty(text) { line(` ${c.m(text)}`); }
47
+
48
+ // A table that fits the terminal: the last column takes what's left.
49
+ export function table(columns, rows) {
50
+ const total = cols() - 2;
51
+ const fixed = columns.slice(0, -1).reduce((n, col) => n + col.w, 0);
52
+ const lastW = Math.max(10, total - fixed);
53
+ line(` ${h(columns.map((col, i) => (i === columns.length - 1 ? col.label : pad(col.label, col.w))).join(''))}`);
54
+ for (const r of rows) {
55
+ line(` ${columns.map((col, i) => {
56
+ const v = col.fmt ? col.fmt(r) : String(r[col.key] ?? '');
57
+ return i === columns.length - 1 ? fit(v, lastW).trimEnd() : fit(v, col.w - 1) + ' ';
58
+ }).join('')}`);
59
+ }
60
+ }
61
+
62
+ export const STATUS_MARK = {
63
+ live: (w = 'live') => c.g(`● ${w}`),
64
+ waiting: (w = 'needs you') => c.w(`◐ ${w}`),
65
+ blocked: (w = 'blocked') => c.r(`✕ ${w}`),
66
+ idle: (w = 'idle') => c.s(`○ ${w}`),
67
+ done: (w = 'finished') => c.m(`✓ ${w}`),
68
+ stopped: (w = 'stopped') => c.m(`■ ${w}`),
69
+ failed: (w = 'failed') => c.r(`✕ ${w}`),
70
+ };
71
+ export function statusMark(s) { return (STATUS_MARK[s] || STATUS_MARK.idle)(); }
@@ -0,0 +1,40 @@
1
+ // The gitroots mark (4×4 green grid) drawn with block characters, so it shows in every terminal.
2
+ import { state as colorState, c, width, fit } from '../ui/ansi.js';
3
+
4
+ // Cell colors sampled from assets/logo.png, light lime (top left) to deep green (bottom right).
5
+ const GRID = [
6
+ ['#B8DB4A', '#9CCF3A', '#5BB33A', '#22954E'],
7
+ ['#7DC035', '#63B535', '#258C35', '#0A743F'],
8
+ ['#1F8932', '#11713F', '#0A6435', '#0A4F33'],
9
+ ['#0A5835', '#0B4F27', '#0A3A27', '#0A3328'],
10
+ ];
11
+ const FALLBACK = [[149, 112, 70, 29], [106, 70, 28, 23], [28, 23, 22, 22], [22, 22, 22, 22]];
12
+
13
+ function cell(hex, r, col) {
14
+ if (!colorState.color) return '██';
15
+ const [R, G, B] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
16
+ const code = colorState.truecolor ? `\x1b[38;2;${R};${G};${B}m` : `\x1b[38;5;${FALLBACK[r][col]}m`;
17
+ return `${code}██\x1b[39m`;
18
+ }
19
+
20
+ // Four rows, 11 columns: "██ ██ ██ ██"
21
+ export function logoRows() {
22
+ return GRID.map((row, r) => row.map((hex, col) => cell(hex, r, col)).join(' '));
23
+ }
24
+
25
+ // Claude Code-style welcome box: logo on the left, lines of text on the right.
26
+ export function welcomeBox(lines, cols) {
27
+ const inner = Math.max(40, Math.min(cols - 2, 100));
28
+ const logo = logoRows();
29
+ const rows = Math.max(logo.length, lines.length);
30
+ const out = [];
31
+ const b = (s) => c.g(s);
32
+ out.push(b(`╭${'─'.repeat(inner)}╮`));
33
+ for (let i = 0; i < rows; i++) {
34
+ const left = logo[i] || ' '.repeat(11);
35
+ const right = lines[i] || '';
36
+ out.push(`${b('│')} ${left} ${fit(right, inner - 16)}${' '.repeat(Math.max(0, inner - 16 - width(fit(right, inner - 16))))} ${b('│')}`);
37
+ }
38
+ out.push(b(`╰${'─'.repeat(inner)}╯`));
39
+ return out;
40
+ }
@@ -0,0 +1,61 @@
1
+ // Every command, used by the session's / picker and by one-shot `gitroots <command>`.
2
+ import * as ws from './workspace.js';
3
+ import * as ag from './agents.js';
4
+ import * as acct from './account.js';
5
+ import * as setup from './setup.js';
6
+
7
+ export const COMMANDS = [
8
+ { name: 'status', group: 'Workspace', desc: 'What needs you, posture, agents', run: ws.status },
9
+ { name: 'approvals', group: 'Workspace', args: '[waiting|approved|declined]', desc: 'Review merge and access requests · approve or decline', run: ws.approvals },
10
+ { name: 'repos', group: 'Workspace', desc: 'Repositories in this workspace', run: ws.repos },
11
+ { name: 'repo', group: 'Workspace', args: '[name]', desc: 'One repository: branch rules, environments, people', run: ws.repo },
12
+ { name: 'pushes', group: 'Workspace', args: '[--blocked]', desc: 'Recent pushes and the ones that were blocked', run: ws.pushes },
13
+ { name: 'audit', group: 'Workspace', args: '[blocked|merges|access] [search]', desc: 'Who did what, when', run: ws.audit },
14
+ { name: 'activity', group: 'Workspace', desc: 'Team activity over the last 20 weeks', run: ws.activity },
15
+ { name: 'members', group: 'Workspace', desc: 'People in this workspace and their roles', run: ws.members },
16
+
17
+ { name: 'agents', group: 'Agents', desc: 'Agent sessions on your machines and this computer', run: ag.agents },
18
+ { name: 'watch', group: 'Agents', desc: 'Live dashboard of every agent session', run: ag.watch },
19
+ { name: 'attach', group: 'Agents', args: '[session]', desc: 'Open one session live · approve, message, stop, resume', run: ag.attach },
20
+ { name: 'machines', group: 'Agents', desc: 'Machines the gitroots app manages for you', run: ag.machines },
21
+
22
+ { name: 'hooks', group: 'This repository', args: '[install|uninstall]', desc: 'Push checks for this repository', run: setup.hooks },
23
+ { name: 'check', group: 'This repository', desc: 'Run the push checks on your local commits', run: setup.check },
24
+
25
+ { name: 'login', group: 'Account', desc: 'Sign in with your browser', run: acct.login },
26
+ { name: 'logout', group: 'Account', desc: 'Sign out on this computer', run: acct.logout },
27
+ { name: 'whoami', group: 'Account', aliases: ['account'], desc: 'Who you are signed in as', run: acct.whoami },
28
+ { name: 'workspace', group: 'Account', args: '[slug]', desc: 'Switch workspace', run: acct.workspace },
29
+
30
+ { name: 'doctor', group: 'gitroots', desc: 'Check the install, sign-in, git and hooks', run: setup.doctor },
31
+ { name: 'update', group: 'gitroots', desc: 'Update gitroots', run: async (ctx, args, flags) => (await import('../commands/update.js')).run('update', args, flags) },
32
+ { name: 'help', group: 'gitroots', desc: 'All commands and keys', run: null },
33
+ { name: 'clear', group: 'gitroots', session: true, desc: 'Clear the screen', run: null },
34
+ { name: 'exit', group: 'gitroots', session: true, aliases: ['quit'], desc: 'Leave gitroots', run: null },
35
+ ];
36
+
37
+ export function find(name) {
38
+ const n = String(name || '').replace(/^\//, '').toLowerCase();
39
+ return COMMANDS.find((c) => c.name === n || (c.aliases || []).includes(n)) || null;
40
+ }
41
+
42
+ /** Commands for the / menu: prefix matches first, then names or descriptions containing the text. */
43
+ export function match(query, { session = true } = {}) {
44
+ const q = query.toLowerCase();
45
+ const list = COMMANDS.filter((c) => session || !c.session);
46
+ if (!q) return list;
47
+ const starts = list.filter((c) => c.name.startsWith(q) || (c.aliases || []).some((a) => a.startsWith(q)));
48
+ const contains = list.filter((c) => !starts.includes(c) && (c.name.includes(q) || c.desc.toLowerCase().includes(q)));
49
+ return [...starts, ...contains];
50
+ }
51
+
52
+ export function suggest(word) {
53
+ const w = word.toLowerCase();
54
+ const dist = (a, b) => {
55
+ const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
56
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
57
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
58
+ return m[a.length][b.length];
59
+ };
60
+ return COMMANDS.map((c) => [c, dist(w, c.name)]).filter(([, d]) => d <= 2).sort((a, b) => a[1] - b[1]).map(([c]) => c).slice(0, 3);
61
+ }
@@ -0,0 +1,332 @@
1
+ // The gitroots session: run `gitroots`, get a welcome box and an input; type / to pick a command.
2
+ import fs from 'node:fs';
3
+ import { c, h, pad, fit, width } from '../ui/ansi.js';
4
+ import { out, write, size, parseKeys } from '../ui/term.js';
5
+ import { Api, ApiError } from '../api.js';
6
+ import { readConfig, historyFile } from '../config.js';
7
+ import { VERSION } from '../runtime.js';
8
+ import { tryGit } from '../git/checks.js';
9
+ import { localSessions, shortPath } from '../agents/local.js';
10
+ import { remoteFullName } from '../rules.js';
11
+ import { welcomeBox } from './logo.js';
12
+ import { COMMANDS, find, match, suggest } from './registry.js';
13
+
14
+ const MENU_ROWS = 8;
15
+
16
+ export function makeCtx({ session }) {
17
+ const ctx = {
18
+ session,
19
+ json: false,
20
+ api: Api.fromConfig(),
21
+ signal: null,
22
+ reload() { ctx.api = Api.fromConfig(); },
23
+ };
24
+ return ctx;
25
+ }
26
+
27
+ // Split a line into words, keeping "quoted text" together.
28
+ export function words(text) {
29
+ const outw = [];
30
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
31
+ let m;
32
+ while ((m = re.exec(text))) outw.push(m[1] ?? m[2] ?? m[3]);
33
+ return outw;
34
+ }
35
+
36
+ function header(ctx) {
37
+ const cfg = readConfig();
38
+ const { cols } = size();
39
+ const top = tryGit(['rev-parse', '--show-toplevel']);
40
+ const branch = top ? tryGit(['rev-parse', '--abbrev-ref', 'HEAD']) : '';
41
+ const repo = top ? remoteFullName(tryGit(['remote', 'get-url', 'origin'])) || top.split('/').pop() : '';
42
+ const who = ctx.api.signedIn
43
+ ? `${c.b(cfg.user?.name || cfg.username)} ${c.m('·')} ${cfg.org ? `${cfg.org.name} ${c.m(`(${cfg.org.role})`)}` : c.w('no workspace · /workspace')} ${c.m(`· ${ctx.api.host}`)}`
44
+ : `${c.w('Not signed in')} ${c.m('· /login to connect your gitroots account')}`;
45
+ return welcomeBox([
46
+ `${c.b('gitroots')} ${c.m(`v${VERSION}`)}`,
47
+ c.m('code governance and agent sessions'),
48
+ who,
49
+ c.m(`${shortPath(process.cwd())}${repo ? ` · ${repo}${branch ? ` @ ${branch}` : ''}` : ''}`),
50
+ ], cols);
51
+ }
52
+
53
+ class Repl {
54
+ constructor(ctx) {
55
+ this.ctx = ctx;
56
+ this.input = '';
57
+ this.pos = 0;
58
+ this.menuIdx = 0;
59
+ this.menuDismissed = false;
60
+ this.drawn = 0;
61
+ this.cursorRow = 0;
62
+ this.notice = null;
63
+ this.lastCtrlC = 0;
64
+ this.running = false;
65
+ this.history = [];
66
+ this.hIdx = -1;
67
+ this.draft = '';
68
+ try { this.history = fs.readFileSync(historyFile(), 'utf8').split('\n').filter(Boolean).slice(-300); } catch { /* none */ }
69
+ this.onData = (buf) => { for (const k of parseKeys(buf)) this.key(k); };
70
+ this.onResize = () => { if (!this.running) { this.drawn = 0; write('\r\x1b[J'); this.draw(); } };
71
+ }
72
+
73
+ menuItems() {
74
+ if (this.menuDismissed || !this.input.startsWith('/') || /\s/.test(this.input)) return null;
75
+ return match(this.input.slice(1));
76
+ }
77
+
78
+ render() {
79
+ const { cols } = size();
80
+ const W = Math.max(30, cols - 1);
81
+ const inner = W - 2;
82
+ const vis = inner - 4;
83
+ let shown = this.input;
84
+ let cur = this.pos;
85
+ if (width(shown) > vis - 1) {
86
+ const start = Math.max(0, this.pos - (vis - 1));
87
+ shown = shown.slice(start, start + vis - 1);
88
+ cur = this.pos - start;
89
+ }
90
+ const body = this.input ? shown : c.m('Type / for commands');
91
+ const lines = [
92
+ c.m(`╭${'─'.repeat(inner)}╮`),
93
+ `${c.m('│')} ${c.g('>')} ${fit(body, vis)} ${c.m('│')}`,
94
+ c.m(`╰${'─'.repeat(inner)}╯`),
95
+ ];
96
+ const items = this.menuItems();
97
+ if (items && items.length) {
98
+ this.menuIdx = Math.min(this.menuIdx, items.length - 1);
99
+ const start = Math.max(0, Math.min(this.menuIdx - MENU_ROWS + 1, items.length - MENU_ROWS));
100
+ for (let i = start; i < Math.min(items.length, start + MENU_ROWS); i++) {
101
+ const it = items[i];
102
+ const sel = i === this.menuIdx;
103
+ const name = `/${it.name}`;
104
+ const text = `${pad(sel ? c.g(c.b(name)) : name, 16)}${sel ? it.desc : c.s(it.desc)}${it.args ? c.m(` ${it.args}`) : ''}`;
105
+ lines.push(fit(`${sel ? c.g(' ❯ ') : ' '}${text}`, W));
106
+ }
107
+ if (items.length > MENU_ROWS) lines.push(c.m(fit(` ${items.length} commands · ↑↓ choose · tab complete · enter run · esc close`, W)));
108
+ } else if (items && !items.length) {
109
+ lines.push(c.m(fit(` no command matches “${this.input.slice(1)}” · /help lists them all`, W)));
110
+ } else {
111
+ const cfg = readConfig();
112
+ const left = this.notice || c.m(' / commands · ↑↓ history · esc clear · ctrl-c twice to exit');
113
+ const right = this.ctx.api.signedIn && cfg.org ? c.m(`${cfg.org.name} `) : '';
114
+ lines.push(`${fit(left, Math.max(0, W - width(right)))}${right}`);
115
+ }
116
+ return { lines, cursorRow: 1, cursorCol: 4 + width(this.input ? shown.slice(0, cur) : '') };
117
+ }
118
+
119
+ draw() {
120
+ if (this.running) return;
121
+ const { lines, cursorRow, cursorCol } = this.render();
122
+ let s = '';
123
+ if (this.drawn) s += `${this.cursorRow ? `\x1b[${this.cursorRow}A` : ''}\r\x1b[J`;
124
+ s += lines.join('\n');
125
+ const up = lines.length - 1 - cursorRow;
126
+ if (up) s += `\x1b[${up}A`;
127
+ s += `\r${cursorCol ? `\x1b[${cursorCol}C` : ''}`;
128
+ write(s);
129
+ this.drawn = lines.length;
130
+ this.cursorRow = cursorRow;
131
+ }
132
+
133
+ clearPrompt() {
134
+ if (!this.drawn) return;
135
+ write(`${this.cursorRow ? `\x1b[${this.cursorRow}A` : ''}\r\x1b[J`);
136
+ this.drawn = 0;
137
+ }
138
+
139
+ // Print above the prompt, e.g. a background notice.
140
+ print(text) {
141
+ if (this.running) { write(`${text}\n`); return; }
142
+ this.clearPrompt();
143
+ write(`${text}\n`);
144
+ this.draw();
145
+ }
146
+
147
+ setInput(text) { this.input = text; this.pos = text.length; this.menuIdx = 0; this.menuDismissed = false; }
148
+
149
+ key(k) {
150
+ if (this.running) return;
151
+ this.notice = null;
152
+ const items = this.menuItems();
153
+ const n = k.name;
154
+ if (items && items.length) {
155
+ if (n === 'up') { this.menuIdx = (this.menuIdx - 1 + items.length) % items.length; return this.draw(); }
156
+ if (n === 'down') { this.menuIdx = (this.menuIdx + 1) % items.length; return this.draw(); }
157
+ if (n === 'tab') { const it = items[this.menuIdx]; this.setInput(`/${it.name}${it.args ? ' ' : ''}`); return this.draw(); }
158
+ if (n === 'enter') { this.setInput(`/${items[this.menuIdx].name}`); return this.submit(); }
159
+ if (n === 'escape') { this.menuDismissed = true; return this.draw(); }
160
+ }
161
+ switch (n) {
162
+ case 'enter': return this.submit();
163
+ case 'escape': this.setInput(''); break;
164
+ case 'tab': if (!this.input) this.setInput('/'); break;
165
+ case 'ctrl-c':
166
+ if (this.input) { this.setInput(''); break; }
167
+ if (Date.now() - this.lastCtrlC < 1500) return this.exit();
168
+ this.lastCtrlC = Date.now();
169
+ this.notice = c.w(' press ctrl-c again to exit');
170
+ break;
171
+ case 'ctrl-d': if (!this.input) return this.exit(); this.input = this.input.slice(0, this.pos) + this.input.slice(this.pos + 1); break;
172
+ case 'ctrl-l': write('\x1b[2J\x1b[H'); this.drawn = 0; write(`${header(this.ctx).join('\n')}\n\n`); break;
173
+ case 'left': this.pos = Math.max(0, this.pos - 1); break;
174
+ case 'right': this.pos = Math.min(this.input.length, this.pos + 1); break;
175
+ case 'home': case 'ctrl-a': this.pos = 0; break;
176
+ case 'end': case 'ctrl-e': this.pos = this.input.length; break;
177
+ case 'word-left': { const m = /\S*\s*$/.exec(this.input.slice(0, this.pos)); this.pos -= m ? m[0].length : 0; break; }
178
+ case 'word-right': { const m = /^\s*\S*/.exec(this.input.slice(this.pos)); this.pos += m ? m[0].length : 0; break; }
179
+ case 'backspace': if (this.pos) { this.input = this.input.slice(0, this.pos - 1) + this.input.slice(this.pos); this.pos--; this.menuDismissed = false; this.menuIdx = 0; } break;
180
+ case 'delete': this.input = this.input.slice(0, this.pos) + this.input.slice(this.pos + 1); break;
181
+ case 'ctrl-u': this.input = this.input.slice(this.pos); this.pos = 0; break;
182
+ case 'ctrl-k': this.input = this.input.slice(0, this.pos); break;
183
+ case 'ctrl-w': { const m = /\S*\s*$/.exec(this.input.slice(0, this.pos)); const cut = m ? m[0].length : 0; this.input = this.input.slice(0, this.pos - cut) + this.input.slice(this.pos); this.pos -= cut; break; }
184
+ case 'up':
185
+ if (!this.history.length) break;
186
+ if (this.hIdx === -1) { this.draft = this.input; this.hIdx = this.history.length; }
187
+ this.hIdx = Math.max(0, this.hIdx - 1);
188
+ this.input = this.history[this.hIdx]; this.pos = this.input.length; this.menuDismissed = true;
189
+ break;
190
+ case 'down':
191
+ if (this.hIdx === -1) break;
192
+ this.hIdx++;
193
+ if (this.hIdx >= this.history.length) { this.hIdx = -1; this.input = this.draft; } else this.input = this.history[this.hIdx];
194
+ this.pos = this.input.length; this.menuDismissed = true;
195
+ break;
196
+ default:
197
+ if (k.ch && !k.ctrl) {
198
+ this.input = this.input.slice(0, this.pos) + k.ch + this.input.slice(this.pos);
199
+ this.pos += k.ch.length;
200
+ this.menuDismissed = false;
201
+ this.menuIdx = 0;
202
+ this.hIdx = -1;
203
+ }
204
+ }
205
+ this.draw();
206
+ }
207
+
208
+ remember(lineText) {
209
+ if (this.history[this.history.length - 1] !== lineText) {
210
+ this.history.push(lineText);
211
+ try { fs.appendFileSync(historyFile(), `${lineText}\n`, { mode: 0o600 }); } catch { /* read-only */ }
212
+ }
213
+ this.hIdx = -1;
214
+ }
215
+
216
+ async submit() {
217
+ const text = this.input.trim();
218
+ this.clearPrompt();
219
+ this.setInput('');
220
+ if (!text) { this.draw(); return; }
221
+ this.remember(text);
222
+ write(`${c.m('>')} ${c.b(text)}\n`);
223
+ const [first, ...rest] = words(text);
224
+ const cmd = find(first);
225
+ if (!cmd) {
226
+ const near = suggest(first.replace(/^\//, ''));
227
+ write(`${c.r('✕')} ${first.startsWith('/') ? 'No command' : 'Not a command:'} ${c.b(first)}${near.length ? ` ${c.m('did you mean')} ${near.map((x) => c.g(`/${x.name}`)).join(c.m(', '))}${c.m('?')}` : ''} ${c.m('· type / to pick one')}\n\n`);
228
+ this.draw();
229
+ return;
230
+ }
231
+ if (cmd.name === 'exit') return this.exit();
232
+ if (cmd.name === 'clear') { write('\x1b[2J\x1b[3J\x1b[H'); write(`${header(this.ctx).join('\n')}\n\n`); this.draw(); return; }
233
+ if (cmd.name === 'help') { printHelp(true); write('\n'); this.draw(); return; }
234
+ await this.run(cmd, rest);
235
+ }
236
+
237
+ async run(cmd, rest) {
238
+ const { parseArgs } = await import('../cli.js');
239
+ const parsed = parseArgs(rest);
240
+ this.running = true;
241
+ process.stdin.off('data', this.onData);
242
+ process.stdin.setRawMode(false);
243
+ const ac = new AbortController();
244
+ this.ctx.signal = ac.signal;
245
+ const onInt = () => ac.abort();
246
+ process.on('SIGINT', onInt);
247
+ try {
248
+ await cmd.run(this.ctx, parsed._, parsed.flags);
249
+ } catch (e) {
250
+ const msg = e instanceof ApiError || e instanceof Error ? e.message : String(e);
251
+ write(`${ac.signal.aborted ? c.m('○ cancelled') : `${c.r('✕')} ${msg}`}\n`);
252
+ if (process.env.GITROOTS_DEBUG && e && e.stack) write(`${e.stack}\n`);
253
+ } finally {
254
+ process.off('SIGINT', onInt);
255
+ this.ctx.signal = null;
256
+ this.running = false;
257
+ write('\n');
258
+ process.stdin.setRawMode(true);
259
+ process.stdin.resume();
260
+ process.stdin.on('data', this.onData);
261
+ this.draw();
262
+ }
263
+ }
264
+
265
+ exit() {
266
+ this.clearPrompt();
267
+ write(`${c.m('bye ·')} ${c.g('gitroots')} ${c.m('to come back')}\n`);
268
+ process.stdin.setRawMode(false);
269
+ process.exit(0);
270
+ }
271
+
272
+ async greet() {
273
+ // What needs you, fetched after the prompt is up so the session opens instantly.
274
+ const ctx = this.ctx;
275
+ const local = localSessions({ limit: 40 });
276
+ const liveLocal = local.filter((s) => s.status === 'live').length;
277
+ const parts = [];
278
+ let hint = '';
279
+ if (ctx.api.signedIn && ctx.api.orgId != null) {
280
+ const [home, agents] = await Promise.all([
281
+ ctx.api.home({ timeout: 8000 }).catch((e) => ({ error: e.message })),
282
+ ctx.api.agents({ timeout: 8000 }).catch(() => null),
283
+ ]);
284
+ if (home.error) { this.print(` ${c.w(`◐ couldn't reach gitroots: ${home.error}`)}\n`); return; }
285
+ if (home.pending_count) { parts.push(c.w(`◐ ${home.pending_count} approval${home.pending_count > 1 ? 's' : ''} waiting`)); hint = '/approvals'; }
286
+ const waiting = (agents?.sessions || []).filter((s) => s.needs_you).length;
287
+ if (waiting) { parts.push(c.w(`◐ ${waiting} agent${waiting > 1 ? 's' : ''} need${waiting > 1 ? '' : 's'} you`)); hint = hint || '/agents'; }
288
+ if (home.blocked_24h) { parts.push(c.r(`✕ ${home.blocked_24h} blocked push${home.blocked_24h > 1 ? 'es' : ''} today`)); hint = hint || '/pushes --blocked'; }
289
+ if (!parts.length) parts.push(c.g('✓ nothing waiting on you'));
290
+ }
291
+ if (liveLocal) parts.push(c.g(`● ${liveLocal} agent session${liveLocal > 1 ? 's' : ''} live on this computer`));
292
+ if (parts.length) this.print(`${fit(` ${parts.join(c.m(' · '))}${hint ? ` ${c.g(`→ ${hint}`)}` : ''}`, size().cols - 1)}\n`);
293
+ }
294
+
295
+ start() {
296
+ write(`\n${header(this.ctx).join('\n')}\n`);
297
+ write(` ${c.m('Type')} ${c.g('/')} ${c.m('to pick a command, or')} ${c.g('/help')} ${c.m('for all of them.')}${this.ctx.api.signedIn ? '' : ` ${c.m('Start with')} ${c.g('/login')}${c.m('.')}`}\n\n`);
298
+ process.stdin.setRawMode(true);
299
+ process.stdin.resume();
300
+ process.stdin.on('data', this.onData);
301
+ out.on('resize', this.onResize);
302
+ this.draw();
303
+ this.greet().catch(() => {});
304
+ return new Promise(() => {}); // runs until exit
305
+ }
306
+ }
307
+
308
+ export function printHelp(session) {
309
+ const p = session ? '/' : 'gitroots ';
310
+ const groups = [...new Set(COMMANDS.map((x) => x.group))];
311
+ for (const g of groups) {
312
+ write(`${h(g)}\n`);
313
+ for (const x of COMMANDS.filter((y) => y.group === g && (session || !y.session))) {
314
+ const left = c.g(`${p}${x.name}`) + (x.args ? ` ${c.m(x.args)}` : '');
315
+ write(` ${width(left) >= 48 ? `${left} ` : pad(left, 50)}${x.desc}\n`);
316
+ }
317
+ write('\n');
318
+ }
319
+ if (session) {
320
+ write(`${h('Keys')}\n`);
321
+ write(` ${pad(c.g('/'), 14)}open the command list · type to filter · ↑↓ choose · tab complete · enter run\n`);
322
+ write(` ${pad(c.g('↑ ↓'), 14)}history · ${c.g('esc')} clear · ${c.g('ctrl-l')} clear screen · ${c.g('ctrl-c')} cancel a running command, twice to exit\n`);
323
+ } else {
324
+ write(` ${c.m('Run gitroots with no command to open a session. --json on list commands.')}\n`);
325
+ }
326
+ }
327
+
328
+ export async function startSession() {
329
+ const ctx = makeCtx({ session: true });
330
+ const repl = new Repl(ctx);
331
+ return repl.start();
332
+ }
@@ -0,0 +1,101 @@
1
+ // /hooks · /check · /doctor
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { json } from '../ui/components.js';
6
+ import { state as colorState } from '../ui/ansi.js';
7
+ import { motion } from '../ui/term.js';
8
+ import { installHook, uninstallHook, hookStatus, checkLocal } from '../commands/push.js';
9
+ import { loadRules } from '../rules.js';
10
+ import { tryGit } from '../git/checks.js';
11
+ import { VERSION, DOWNLOAD_BASE, installMethod, nativePaths, platformKey, isBinary } from '../runtime.js';
12
+ import { latestVersion, newer, onPath } from '../installer.js';
13
+ import { home, readConfig } from '../config.js';
14
+ import { localSessions } from '../agents/local.js';
15
+ import { c, line, pad, next, load, cmdText } from './helpers.js';
16
+
17
+ export async function hooks(ctx, args, flags) {
18
+ const sub = args[0] || 'status';
19
+ if (sub === 'install') {
20
+ const r = installHook('.', { force: flags.force });
21
+ if (ctx.json) { json(r); return 0; }
22
+ line(`${c.g('✓')} Push checks installed in ${c.b(path.basename(tryGit(['rev-parse', '--show-toplevel'])))}${r.chained ? c.m(' · your previous hook still runs first') : ''}`);
23
+ line(` ${c.m('every git push now checks branch rules, secrets, authors and migrations first')}`);
24
+ next(ctx, 'check');
25
+ return 0;
26
+ }
27
+ if (sub === 'uninstall') {
28
+ const r = uninstallHook('.');
29
+ if (ctx.json) { json(r); return 0; }
30
+ line(r.removed ? `${c.g('✓')} Push checks removed from this repository` : c.m('○ no gitroots hook in this repository'));
31
+ return 0;
32
+ }
33
+ const st = hookStatus('.');
34
+ if (!st.repo) throw new Error('not inside a git repository · cd into one first');
35
+ const set = await load(ctx, 'Reading branch rules…', () => loadRules(process.cwd(), { api: ctx.api }));
36
+ if (ctx.json) { json({ ...st, rules: set }); return 0; }
37
+ line(`${c.b(set.repo || path.basename(st.repo))} ${st.installed ? c.g('● push checks on') : c.w('○ push checks off')}`);
38
+ const src = { gitroots: `from gitroots (${ctx.api.host})`, cache: 'from gitroots, cached', '.gitroots.json': 'from .gitroots.json', none: 'none for this repo' }[set.source];
39
+ line(` ${c.s(pad('rules', 9))}${src}`);
40
+ for (const r of set.rules) line(` ${' '.repeat(9)}${c.b(pad(r.pattern, 20))}${r.allow_direct_push === false ? c.r('✕ no direct push') : c.g('✓ direct push')} ${c.m(r.description || r.level)}`);
41
+ if (!st.installed) next(ctx, 'hooks', 'install');
42
+ else next(ctx, 'check');
43
+ return 0;
44
+ }
45
+
46
+ export async function check(ctx, args, flags) {
47
+ return checkLocal(args, { api: ctx.api, branch: flags.branch });
48
+ }
49
+
50
+ export async function doctor(ctx) {
51
+ const rows = [];
52
+ const add = (group, name, status, detail, fix) => rows.push({ group, name, status, detail, fix });
53
+ add('install', 'gitroots', 'ok', `${VERSION} · ${installMethod()}${isBinary ? ` · ${process.execPath}` : ''}`);
54
+ add('install', 'platform', 'ok', platformKey());
55
+ if (!isBinary) {
56
+ const major = Number(process.versions.node.split('.')[0]);
57
+ add('install', 'node', major >= 18 ? 'ok' : 'fail', process.version, major >= 18 ? null : 'install Node 18+ or the standalone build');
58
+ }
59
+ if (installMethod() === 'native') {
60
+ const { bin } = nativePaths();
61
+ add('install', 'PATH', onPath(bin) ? 'ok' : 'warn', onPath(bin) ? `${bin} is on PATH` : `${bin} is not on PATH`, onPath(bin) ? null : `export PATH="${bin}:$PATH"`);
62
+ }
63
+ try {
64
+ const latest = await latestVersion(DOWNLOAD_BASE, 4000);
65
+ add('install', 'updates', newer(latest, VERSION) ? 'warn' : 'ok', newer(latest, VERSION) ? `${latest} is available` : 'up to date', newer(latest, VERSION) ? cmdText(ctx, 'update') : null);
66
+ } catch (e) { add('install', 'updates', 'warn', `couldn't check (${e.message})`); }
67
+
68
+ const cfg = readConfig();
69
+ if (ctx.api.signedIn) {
70
+ try {
71
+ const me = await ctx.api.me({ timeout: 6000 });
72
+ add('account', 'signed in', 'ok', `${me.user.username} · ${ctx.api.host}`);
73
+ add('account', 'workspace', cfg.org ? 'ok' : 'warn', cfg.org ? `${cfg.org.name} (${cfg.org.role})` : 'none chosen', cfg.org ? null : cmdText(ctx, 'workspace'));
74
+ } catch (e) { add('account', 'signed in', 'fail', e.message, cmdText(ctx, 'login')); }
75
+ } else add('account', 'signed in', 'warn', 'no', cmdText(ctx, 'login'));
76
+ try { fs.mkdirSync(home(), { recursive: true }); fs.accessSync(home(), fs.constants.W_OK); add('account', 'settings', 'ok', home()); } catch { add('account', 'settings', 'fail', `${home()} is not writable`); }
77
+
78
+ const git = spawnSync('git', ['--version'], { encoding: 'utf8' });
79
+ add('git', 'git', git.status === 0 ? 'ok' : 'fail', git.status === 0 ? git.stdout.trim() : 'not found', git.status === 0 ? null : 'install git');
80
+ const st = hookStatus('.');
81
+ if (st.repo) add('git', 'push checks', st.installed ? 'ok' : 'warn', `${st.installed ? 'on' : 'off'} in ${path.basename(st.repo)}`, st.installed ? null : cmdText(ctx, 'hooks', 'install'));
82
+ else add('git', 'repository', 'ok', 'not inside a git repo (skipped)');
83
+
84
+ const local = localSessions({ limit: 200 });
85
+ add('agents', 'this computer', 'ok', `${local.filter((s) => s.agent === 'claude').length} Claude Code · ${local.filter((s) => s.agent === 'codex').length} Codex sessions in the last 14 days`);
86
+ add('terminal', 'colors', colorState.color ? 'ok' : 'warn', colorState.color ? (colorState.truecolor ? 'truecolor' : '256 colors') : 'off (piped or NO_COLOR)');
87
+ add('terminal', 'motion', 'ok', motion() ? 'on' : 'off');
88
+
89
+ if (ctx.json) { json(rows); return rows.some((r) => r.status === 'fail') ? 1 : 0; }
90
+ let group = '';
91
+ for (const r of rows) {
92
+ if (r.group !== group) { if (group) line(); line(c.m(r.group.toUpperCase())); group = r.group; }
93
+ const mark = r.status === 'ok' ? c.g('✓') : r.status === 'warn' ? c.w('◐') : c.r('✕');
94
+ line(` ${mark} ${pad(r.name, 14)}${r.status === 'fail' ? c.r(r.detail) : r.status === 'warn' ? c.w(r.detail) : c.s(r.detail)}${r.fix ? ` ${c.g(`→ ${r.fix}`)}` : ''}`);
95
+ }
96
+ const fails = rows.filter((r) => r.status === 'fail').length;
97
+ const warns = rows.filter((r) => r.status === 'warn').length;
98
+ line();
99
+ line(fails ? c.r(`✕ ${fails} problem${fails > 1 ? 's' : ''}`) : warns ? c.w(`◐ ${warns} thing${warns > 1 ? 's' : ''} to look at`) : c.g('✓ all good'));
100
+ return fails ? 1 : 0;
101
+ }