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.
package/README.md CHANGED
@@ -1,3 +1,138 @@
1
- # Temporary Holding Version
1
+ # gitroots CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ gitroots in your terminal, as a session like Claude Code. Run `gitroots` and you get a welcome box with the gitroots mark and what needs you right now. Type `/` to pick a command from the list, use ↑↓ and Enter, and everything works on your real workspace.
4
+
5
+ ```
6
+ ╭──────────────────────────────────────────────────────────────╮
7
+ │ ██ ██ ██ ██ gitroots v0.6.0 │
8
+ │ ██ ██ ██ ██ code governance and agent sessions │
9
+ │ ██ ██ ██ ██ Shawki Aladdin · Acme ERP (owner) · gitroots.com│
10
+ │ ██ ██ ██ ██ ~/work/erp · acme/erp @ main │
11
+ ╰──────────────────────────────────────────────────────────────╯
12
+ ◐ 1 approval waiting · ◐ 1 agent needs you → /approvals
13
+
14
+ ╭──────────────────────────────────────────────────────────────╮
15
+ │ > /ap │
16
+ ╰──────────────────────────────────────────────────────────────╯
17
+ ❯ /approvals Review merge and access requests · approve or decline
18
+ /attach Open one session live · approve, message, stop, resume
19
+ ```
20
+
21
+ ## Install
22
+
23
+ **macOS / Linux**: standalone binary, no Node needed:
24
+ ```bash
25
+ curl -fsSL https://gitroots.com/cli/install.sh | bash
26
+ ```
27
+ **Windows** (PowerShell):
28
+ ```powershell
29
+ irm https://gitroots.com/cli/install.ps1 | iex
30
+ ```
31
+ **npm** (any OS with Node 18+):
32
+ ```bash
33
+ npm install -g gitroots
34
+ ```
35
+ **Homebrew** (after publishing `dist/Formula/gitroots.rb` to a tap):
36
+ ```bash
37
+ brew install <you>/tap/gitroots
38
+ ```
39
+
40
+ Then run `gitroots`, `gitroots login` and `gitroots doctor`. `gitroots update` updates the same way it was installed (native binary or npm), and `gitroots uninstall` removes a native install.
41
+
42
+ The native installer works like Claude Code's:
43
+ - binaries go to `~/.local/share/gitroots/versions/<version>/`
44
+ - `~/.local/bin/gitroots` links to the current one
45
+ - every download is checked against `SHA256SUMS`
46
+ - the current and previous versions are kept
47
+ - `~/.local/bin` is added to your shell profile (skip with `GITROOTS_NO_MODIFY_PATH=1`)
48
+
49
+ Pin a version with `… | bash -s -- 0.5.0`. On a platform without a binary (for example Alpine/musl), the installer falls back to the npm package if Node 18+ is present.
50
+
51
+ From a checkout: `npm link`.
52
+
53
+ ## Releasing
54
+
55
+ 1. Bump `version` in `package.json` and `VERSION` in `src/runtime.js`. The build refuses to run if they differ.
56
+ 2. Build and publish:
57
+ ```bash
58
+ npm test
59
+ npm run build # binaries for all platforms, installers, checksums → dist/
60
+ npm run deploy # uploads to the gitroots-cli Railway service; https://gitroots.com/cli serves it
61
+ npm publish # npm asks you to approve with your passkey in the browser
62
+ ```
63
+
64
+ How it's hosted:
65
+ - https://gitroots.com/cli is the install page, in the Django app on `railway-main` (`apps/core/cli_downloads.py`, `templates/landing/cli.html`).
66
+ - Every file under `/cli/` redirects to the `gitroots-cli` service in the GITROOTS Railway project (production). Its server is in `deploy/downloads/`. The redirect target can be changed with `GITROOTS_CLI_DOWNLOADS_URL`.
67
+ - Railway caps uploads at a few hundred MB, so `npm run deploy` uploads only the current release's binaries. Older versions stay installable from npm with `npm install -g gitroots@<version>`.
68
+
69
+ Notes:
70
+ - The binaries are Node 22 LTS single-executable apps, about 35–42 MB compressed each. macOS builds are ad-hoc signed. Sign and notarize them with a Developer ID if you distribute outside the curl installer.
71
+ - The Homebrew formula is written to `dist/Formula/gitroots.rb`. Publish it in a tap repo to enable `brew install`.
72
+
73
+ ## Commands
74
+
75
+ Inside the session, type `/` to see and pick them. Outside it, each one also runs on its own (`gitroots status`, `gitroots repos --json`) for scripts and CI.
76
+
77
+ | | |
78
+ |---|---|
79
+ | `/status` | What needs you, workspace posture, agents |
80
+ | `/approvals` | Merge and access requests: pick one, then approve, request changes or decline |
81
+ | `/repos` · `/repo [name]` | Repositories, their branch rules, environments and people |
82
+ | `/pushes [--blocked]` | Recent pushes and the ones gitroots blocked |
83
+ | `/audit [blocked\|merges\|access] [search]` | Who did what, when |
84
+ | `/activity` · `/members` | Team activity, people and roles |
85
+ | `/agents` | Agent sessions on your machines (as the gitroots app reports them) and on this computer (Claude Code, Codex): approve, deny, message, stop, or resume a local one |
86
+ | `/watch` · `/attach` | Full-screen live view of agent sessions (`y` approve, `n` deny, `m` message, `s` stop, `r` resume) |
87
+ | `/machines` | Machines the gitroots app manages |
88
+ | `/hooks [install]` · `/check` | Push checks for the repository you're in |
89
+ | `/login` · `/logout` · `/whoami` · `/workspace` | Account and workspace |
90
+ | `/doctor` · `/update` · `/help` · `/clear` · `/exit` | |
91
+
92
+ Keys: `/` opens the list (type to filter, ↑↓ choose, Tab completes, Enter runs) · ↑↓ history · Esc clears · ctrl-c cancels a running command, twice to exit.
93
+
94
+ ## Where the data comes from
95
+
96
+ All of it is real: nothing is sample data.
97
+
98
+ - **Sign-in:** `/login` shows a code and opens gitroots.com/cli/auth. Approve it there while you're signed in, and the CLI receives your API token, which it keeps in the macOS Keychain (or `~/.gitroots/credentials.json`, mode 600). It works for GitHub-login accounts too.
99
+ - **Workspace:** status, approvals, repositories, branch rules, pushes, audit, activity and members come from the gitroots API (`/api/mobile/gov/…`), the same endpoints the mobile app uses.
100
+ - **Agents:**
101
+ - Your machines: from what the gitroots app reports (`/api/cli/agents`). Approve, deny, message and stop are queued through gitroots, and the app on that machine runs them.
102
+ - This computer: read from Claude Code (`~/.claude/projects`) and Codex (`~/.codex/sessions`) session files. "Resume here" runs `claude --resume <id>` or `codex resume <id>` in the session's folder, and you come back to gitroots when it exits.
103
+ - **Push checks:** `gitroots hooks install` adds a `pre-push` hook. It uses your workspace's branch rules for the GitHub repo you push to: no direct push, leads only, required approvals. The rules are cached so the hook works offline. Repos outside gitroots can use a `.gitroots.json`:
104
+ ```json
105
+ { "protected": ["main", "release/*"], "paths": [{ "pattern": "infra/**", "needs": "an ops review" }] }
106
+ ```
107
+ Every push also gets a secrets scan, an author check and migration flags. A blocked push never leaves the machine.
108
+
109
+ ## Environment
110
+
111
+ | Variable | Effect |
112
+ |---|---|
113
+ | `GITROOTS_HOME` | where state, config and credentials live (default `~/.gitroots`) |
114
+ | `GITROOTS_SERVER` | gitroots server (default `https://gitroots.com`) |
115
+ | `GITROOTS_TOKEN` | use this API token instead of the stored sign-in (CI) |
116
+ | `GITROOTS_DOWNLOAD_BASE` | where `update` and the installers fetch releases |
117
+ | `GITROOTS_NO_KEYCHAIN` | store the password in credentials.json even on macOS |
118
+ | `NO_COLOR` / `FORCE_COLOR` | turn colors off or force them on |
119
+ | `GITROOTS_REDUCED_MOTION` | no spinners or animation |
120
+ | `GITROOTS_NO_IMAGES` | no inline logo image |
121
+ | `GITROOTS_DEBUG` | print stack traces |
122
+
123
+ ## Layout
124
+
125
+ ```
126
+ bin/gitroots.js entry
127
+ src/cli.js one-shot commands and plumbing (hook, completion, uninstall)
128
+ src/session/ the session: repl.js (input, / menu, history), logo.js, registry.js (every command),
129
+ workspace.js, agents.js (incl. /watch and /attach), account.js, setup.js
130
+ src/api.js gitroots API client (token auth)
131
+ src/config.js settings, Keychain/credentials, caches
132
+ src/agents/local.js Claude Code and Codex sessions on this computer
133
+ src/rules.js branch rules for the push hook
134
+ src/commands/push.js the pre-push hook and checks · update.js · completion.js
135
+ src/git/checks.js secrets scan, authors, migrations
136
+ scripts/ build.mjs (release), deploy.sh, install.sh, install.ps1
137
+ test/ node --test (includes a mock gitroots API and real git pushes)
138
+ ```
Binary file
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ // `gitroots status | head` closes the pipe early: stop quietly instead of crashing.
5
+ for (const stream of [process.stdout, process.stderr]) {
6
+ stream.on('error', (err) => {
7
+ if (err && err.code === 'EPIPE') process.exit(0);
8
+ throw err;
9
+ });
10
+ }
11
+
12
+ main(process.argv.slice(2)).then(
13
+ (code) => { process.exitCode = code ?? 0; },
14
+ (err) => {
15
+ process.stderr.write(`gitroots: ${err && err.message ? err.message : err}\n`);
16
+ if (process.env.GITROOTS_DEBUG) process.stderr.write(`${err.stack}\n`);
17
+ process.exitCode = 1;
18
+ }
19
+ );
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "gitroots",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.0",
4
+ "description": "gitroots CLI: code governance and agent sessions.",
5
+ "type": "module",
6
+ "bin": {
7
+ "gitroots": "bin/gitroots.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "assets",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/",
17
+ "build": "node scripts/build.mjs",
18
+ "build:npm": "node scripts/build.mjs --no-binaries",
19
+ "deploy": "bash scripts/deploy.sh",
20
+ "prepublishOnly": "node --test test/"
21
+ },
22
+ "keywords": [
23
+ "gitroots",
24
+ "git",
25
+ "governance",
26
+ "code-review",
27
+ "agents",
28
+ "cli"
29
+ ],
30
+ "homepage": "https://gitroots.com",
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "license": "UNLICENSED"
38
+ }
@@ -0,0 +1,219 @@
1
+ // Agent sessions on this machine, read from the agents' own session files.
2
+ // Claude Code ~/.claude/projects/<folder>/<session-id>.jsonl resume: claude --resume <id>
3
+ // Codex ~/.codex/sessions/YYYY/MM/DD/rollout-…-<id>.jsonl resume: codex resume <id>
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ const TAIL = 192 * 1024;
9
+ const LIVE_MS = 2 * 60 * 1000;
10
+ const RECENT_MS = 14 * 24 * 3600 * 1000;
11
+
12
+ function readSlice(file, start, length) {
13
+ const fd = fs.openSync(file, 'r');
14
+ try {
15
+ const buf = Buffer.alloc(length);
16
+ const n = fs.readSync(fd, buf, 0, length, start);
17
+ return buf.subarray(0, n).toString('utf8');
18
+ } finally { fs.closeSync(fd); }
19
+ }
20
+
21
+ // Walk the start of a file line by line, 128 KB at a time, until visit() returns true (max 2 MB).
22
+ function scanHead(file, size, visit) {
23
+ const CHUNK = 128 * 1024;
24
+ let pos = 0;
25
+ let carry = '';
26
+ while (pos < Math.min(size, 2 * 1024 * 1024)) {
27
+ const text = carry + readSlice(file, pos, CHUNK);
28
+ pos += CHUNK;
29
+ const lines = text.split('\n');
30
+ carry = pos < size ? lines.pop() : '';
31
+ for (const l of lines) {
32
+ if (!l.trim()) continue;
33
+ let d;
34
+ try { d = JSON.parse(l); } catch { continue; }
35
+ if (visit(d)) return;
36
+ }
37
+ }
38
+ }
39
+
40
+ function jsonLines(text, dropFirst) {
41
+ const lines = text.split('\n');
42
+ if (dropFirst) lines.shift();
43
+ const out = [];
44
+ for (const l of lines) { if (!l.trim()) continue; try { out.push(JSON.parse(l)); } catch { /* partial line */ } }
45
+ return out;
46
+ }
47
+
48
+ function textOf(content) {
49
+ if (typeof content === 'string') return content;
50
+ if (!Array.isArray(content)) return '';
51
+ return content.map((c) => (c && (c.type === 'text' || c.type === 'input_text' || c.type === 'output_text') ? c.text : '')).filter(Boolean).join(' ');
52
+ }
53
+
54
+ const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
55
+ const isNoise = (t) => !t || t.startsWith('<') || t.startsWith('Caveat:') || /^\[Request interrupted/.test(t);
56
+
57
+ function state(mtime) {
58
+ const age = Date.now() - mtime;
59
+ return age < LIVE_MS ? 'live' : 'idle';
60
+ }
61
+
62
+ // ---------------------------------------------------------------- Claude Code
63
+
64
+ function claudeSession(file, st) {
65
+ const size = st.size;
66
+ const tail = jsonLines(readSlice(file, Math.max(0, size - TAIL), Math.min(size, TAIL)), size > TAIL);
67
+ let title = '';
68
+ let cwd = '';
69
+ let branch = '';
70
+ let id = path.basename(file, '.jsonl');
71
+ scanHead(file, size, (d) => {
72
+ if (d.cwd && !cwd) cwd = d.cwd;
73
+ if (d.sessionId) id = d.sessionId;
74
+ if (d.type === 'summary' && d.summary && !title) title = d.summary;
75
+ if (!title && d.type === 'user' && d.message && d.message.role === 'user') {
76
+ const t = clean(textOf(d.message.content));
77
+ if (!isNoise(t)) title = t;
78
+ }
79
+ return Boolean(title && cwd);
80
+ });
81
+ const log = [];
82
+ let lastUser = '';
83
+ for (const d of tail) {
84
+ if (d.cwd) cwd = d.cwd;
85
+ if (d.gitBranch) branch = d.gitBranch;
86
+ const m = d.message;
87
+ if (!m) continue;
88
+ if (d.type === 'user' && m.role === 'user') {
89
+ const t = clean(textOf(m.content));
90
+ if (!isNoise(t)) { lastUser = t; log.push({ who: 'you', text: t, at: d.timestamp }); }
91
+ } else if (d.type === 'assistant' && Array.isArray(m.content)) {
92
+ for (const c of m.content) {
93
+ if (c.type === 'text' && clean(c.text)) log.push({ who: 'agent', text: clean(c.text), at: d.timestamp });
94
+ if (c.type === 'tool_use') log.push({ who: 'tool', text: `${c.name}${c.input && (c.input.command || c.input.file_path || c.input.pattern) ? ` ${clean(c.input.command || c.input.file_path || c.input.pattern)}` : ''}`, at: d.timestamp });
95
+ }
96
+ }
97
+ }
98
+ if (!cwd) return null;
99
+ return {
100
+ source: 'local', agent: 'claude', agentName: 'Claude Code', id, file, cwd, branch: branch === 'HEAD' ? '' : branch,
101
+ title: title || lastUser || '(no prompt yet)', lastPrompt: lastUser, mtime: st.mtimeMs, status: state(st.mtimeMs),
102
+ log: log.slice(-60), resume: { cmd: 'claude', args: ['--resume', id] },
103
+ };
104
+ }
105
+
106
+ function findClaude(root) {
107
+ const out = [];
108
+ let dirs = [];
109
+ try { dirs = fs.readdirSync(root); } catch { return out; }
110
+ for (const d of dirs) {
111
+ let files = [];
112
+ try { files = fs.readdirSync(path.join(root, d)); } catch { continue; }
113
+ for (const f of files) {
114
+ if (!f.endsWith('.jsonl')) continue;
115
+ const p = path.join(root, d, f);
116
+ try { const st = fs.statSync(p); if (Date.now() - st.mtimeMs < RECENT_MS && st.size > 0) out.push([p, st]); } catch { /* gone */ }
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ // ---------------------------------------------------------------- Codex
123
+
124
+ function codexSession(file, st) {
125
+ const size = st.size;
126
+ const tail = jsonLines(readSlice(file, Math.max(0, size - TAIL), Math.min(size, TAIL)), size > TAIL);
127
+ let id = (/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(file) || [])[1] || path.basename(file);
128
+ let cwd = '';
129
+ let title = '';
130
+ let branch = '';
131
+ scanHead(file, size, (d) => {
132
+ const p = d.payload || {};
133
+ if (d.type === 'session_meta') { id = p.id || id; cwd = p.cwd || cwd; if (p.git && p.git.branch) branch = p.git.branch; }
134
+ if (!title && d.type === 'response_item' && p.type === 'message' && p.role === 'user') {
135
+ const t = clean(textOf(p.content));
136
+ if (!isNoise(t)) title = t;
137
+ }
138
+ return Boolean(title && cwd);
139
+ });
140
+ const log = [];
141
+ let lastUser = '';
142
+ for (const d of tail) {
143
+ const p = d.payload || {};
144
+ if (d.type === 'turn_context' && p.cwd) cwd = p.cwd;
145
+ if (d.type !== 'response_item') continue;
146
+ if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
147
+ const t = clean(textOf(p.content));
148
+ if (isNoise(t)) continue;
149
+ if (p.role === 'user') lastUser = t;
150
+ log.push({ who: p.role === 'user' ? 'you' : 'agent', text: t, at: d.timestamp });
151
+ } else if (p.type === 'function_call' || p.type === 'local_shell_call' || p.type === 'custom_tool_call') {
152
+ let args = '';
153
+ try { const a = JSON.parse(p.arguments || '{}'); args = Array.isArray(a.command) ? a.command.join(' ') : a.command || a.cmd || ''; } catch { args = ''; }
154
+ log.push({ who: 'tool', text: `${p.name || 'shell'}${args ? ` ${clean(args)}` : ''}`, at: d.timestamp });
155
+ }
156
+ }
157
+ if (!cwd) return null;
158
+ return {
159
+ source: 'local', agent: 'codex', agentName: 'Codex', id, file, cwd, branch,
160
+ title: title || lastUser || '(no prompt yet)', lastPrompt: lastUser, mtime: st.mtimeMs, status: state(st.mtimeMs),
161
+ log: log.slice(-60), resume: { cmd: 'codex', args: ['resume', id] },
162
+ };
163
+ }
164
+
165
+ function findCodex(root) {
166
+ const out = [];
167
+ const walk = (dir, depth) => {
168
+ let entries = [];
169
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
170
+ for (const e of entries) {
171
+ const p = path.join(dir, e.name);
172
+ if (e.isDirectory() && depth < 3) walk(p, depth + 1);
173
+ else if (e.isFile() && e.name.endsWith('.jsonl')) {
174
+ try { const st = fs.statSync(p); if (Date.now() - st.mtimeMs < RECENT_MS && st.size > 0) out.push([p, st]); } catch { /* gone */ }
175
+ }
176
+ }
177
+ };
178
+ walk(root, 0);
179
+ return out;
180
+ }
181
+
182
+ // ---------------------------------------------------------------- public
183
+
184
+ export function localSessions({ limit = 40, homeDir = os.homedir() } = {}) {
185
+ const claudeRoot = process.env.CLAUDE_CONFIG_DIR ? path.join(process.env.CLAUDE_CONFIG_DIR, 'projects') : path.join(homeDir, '.claude', 'projects');
186
+ const codexRoot = path.join(process.env.CODEX_HOME || path.join(homeDir, '.codex'), 'sessions');
187
+ const files = [
188
+ ...findClaude(claudeRoot).map(([p, st]) => ['claude', p, st]),
189
+ ...findCodex(codexRoot).map(([p, st]) => ['codex', p, st]),
190
+ ].sort((a, b) => b[2].mtimeMs - a[2].mtimeMs).slice(0, limit);
191
+ const out = [];
192
+ for (const [kind, p, st] of files) {
193
+ try {
194
+ const s = kind === 'claude' ? claudeSession(p, st) : codexSession(p, st);
195
+ if (s) out.push(s);
196
+ } catch { /* unreadable */ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ export function refreshLocal(s) {
202
+ try {
203
+ const st = fs.statSync(s.file);
204
+ return s.agent === 'claude' ? claudeSession(s.file, st) : codexSession(s.file, st);
205
+ } catch { return s; }
206
+ }
207
+
208
+ export function shortPath(p) {
209
+ const h = os.homedir();
210
+ return p && p.startsWith(h) ? `~${p.slice(h.length)}` : p || '';
211
+ }
212
+
213
+ export function ago(ms) {
214
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
215
+ if (s < 60) return `${s}s ago`;
216
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
217
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
218
+ return `${Math.floor(s / 86400)}d ago`;
219
+ }
package/src/api.js ADDED
@@ -0,0 +1,84 @@
1
+ // gitroots API client. Uses the same token as the gitroots mobile app.
2
+ import { DEFAULT_SERVER, readConfig, loadToken } from './config.js';
3
+
4
+ export class ApiError extends Error {
5
+ constructor(message, status, body) { super(message); this.status = status; this.body = body; }
6
+ }
7
+
8
+ export class Api {
9
+ constructor({ server = DEFAULT_SERVER, token = null, orgId = null } = {}) {
10
+ this.server = server.replace(/\/+$/, '');
11
+ this.token = token;
12
+ this.orgId = orgId;
13
+ }
14
+
15
+ static fromConfig() {
16
+ const cfg = readConfig();
17
+ const server = process.env.GITROOTS_SERVER || cfg.server || DEFAULT_SERVER;
18
+ const token = loadToken(server, cfg.username);
19
+ return new Api({ server, token, orgId: cfg.org?.id ?? null });
20
+ }
21
+
22
+ get signedIn() { return Boolean(this.token); }
23
+ get host() { return this.server.replace(/^https?:\/\//, ''); }
24
+
25
+ async request(method, p, { query = {}, body, signal, auth = true, timeout = 20000, okStatuses = [] } = {}) {
26
+ const url = new URL(`${this.server}/api/${p.replace(/^\/+/, '')}`);
27
+ for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
28
+ const headers = { Accept: 'application/json', 'User-Agent': 'gitroots-cli' };
29
+ if (auth) {
30
+ if (!this.token) throw new ApiError('not signed in · /login', 401);
31
+ headers.Authorization = `Token ${this.token}`;
32
+ }
33
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
34
+ const signals = [AbortSignal.timeout(timeout)];
35
+ if (signal) signals.push(signal);
36
+ let res;
37
+ try {
38
+ res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.any ? AbortSignal.any(signals) : signals[0] });
39
+ } catch (e) {
40
+ if (signal?.aborted) throw new ApiError('cancelled', 0);
41
+ throw new ApiError(`can't reach ${this.host} (${e.cause?.code || e.name})`, 0);
42
+ }
43
+ let data = null;
44
+ const ct = res.headers.get('content-type') || '';
45
+ if (ct.includes('json')) { try { data = await res.json(); } catch { data = null; } }
46
+ if (res.ok || okStatuses.includes(res.status)) return { status: res.status, data };
47
+ const detail = (data && (data.detail || data.error)) || '';
48
+ if (res.status === 401) throw new ApiError('your sign-in expired or was revoked · /login', 401, data);
49
+ if (res.status === 403) throw new ApiError(detail || 'not allowed for your role in this workspace', 403, data);
50
+ if (res.status === 404) throw new ApiError(detail || `not found (${url.pathname})`, 404, data);
51
+ if (!ct.includes('json')) throw new ApiError(`${this.host} answered ${res.status} · is it a gitroots server?`, res.status);
52
+ throw new ApiError(detail || `${res.status} from ${url.pathname}`, res.status, data);
53
+ }
54
+
55
+ async get(p, query, opts = {}) { return (await this.request('GET', p, { ...opts, query: { org_id: this.orgId, ...query } })).data; }
56
+ async post(p, body, opts = {}) { return (await this.request('POST', p, { ...opts, body, query: { org_id: this.orgId, ...(opts.query || {}) } })).data; }
57
+
58
+ // ---- sign-in (no token yet)
59
+ authStart(clientName) { return this.request('POST', 'cli/auth/start', { auth: false, body: { client_name: clientName } }).then((r) => r.data); }
60
+ authPoll(deviceCode, signal) {
61
+ return this.request('POST', 'cli/auth/poll', { auth: false, body: { device_code: deviceCode }, signal, okStatuses: [202, 403, 404, 410] });
62
+ }
63
+
64
+ // ---- account & workspace
65
+ me(opts) { return this.get('mobile/me', {}, opts); }
66
+
67
+ // ---- governance
68
+ home(opts) { return this.get('mobile/gov/home', {}, opts); }
69
+ repos(opts) { return this.get('mobile/gov/repos', {}, opts); }
70
+ repo(fullName, opts) { return this.get('mobile/gov/repos/detail', { repo: fullName }, opts); }
71
+ approvals(state = 'waiting', opts) { return this.get('mobile/gov/approvals', { state }, opts); }
72
+ approveSync(id, note, opts) { return this.request('POST', `provision/sync-requests/${id}/approve/`, { ...opts, body: { note: note || '' } }).then((r) => r.data); }
73
+ declineSync(id, mode, reason, opts) { return this.post(`mobile/gov/sync-requests/${id}/decline`, { mode, reason }, opts); }
74
+ decideAccess(id, decision, reason, opts) { return this.post(`mobile/gov/access-requests/${id}/decide`, { decision, reason }, opts); }
75
+ pushEvents(blocked, opts) { return this.get('mobile/gov/push-events', { blocked: blocked ? 1 : undefined }, opts); }
76
+ audit(category, q, opts) { return this.get('mobile/gov/audit', { category, q }, opts); }
77
+ teamActivity(opts) { return this.get('mobile/gov/team-activity', {}, opts); }
78
+ members(opts) { return this.get('mobile/gov/members', {}, opts); }
79
+
80
+ // ---- agents
81
+ agents(opts) { return this.get('cli/agents', {}, opts); }
82
+ agentSession(id, opts) { return this.get(`cli/agents/sessions/${id}`, {}, opts); }
83
+ agentAction(id, action, text, opts) { return this.post(`cli/agents/sessions/${id}/action`, { action, text }, opts); }
84
+ }
package/src/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ import { setColor, c } from './ui/ansi.js';
2
+ import { line, interactive } from './ui/term.js';
3
+ import { VERSION } from './runtime.js';
4
+
5
+ export { VERSION };
6
+
7
+ // Flags that never take a value.
8
+ const BOOL = new Set(['json', 'help', 'h', 'version', 'v', 'no-color', 'yes', 'y', 'force', 'blocked', 'purge', 'no-browser']);
9
+
10
+ export function parseArgs(argv) {
11
+ const args = { _: [], flags: {} };
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const a = argv[i];
14
+ if (a === '--') { args._.push(...argv.slice(i + 1)); break; }
15
+ if (a.startsWith('--')) {
16
+ const eq = a.indexOf('=');
17
+ if (eq > -1) { args.flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
18
+ const key = a.slice(2);
19
+ const nxt = argv[i + 1];
20
+ if (!BOOL.has(key) && nxt !== undefined && !nxt.startsWith('-')) { args.flags[key] = nxt; i++; } else args.flags[key] = true;
21
+ continue;
22
+ }
23
+ if (a.startsWith('-') && a.length > 1 && !/^-\d/.test(a)) {
24
+ const key = a.slice(1);
25
+ const nxt = argv[i + 1];
26
+ if (!BOOL.has(key) && key.length === 1 && nxt !== undefined && !nxt.startsWith('-')) { args.flags[key] = nxt; i++; } else args.flags[key] = true;
27
+ continue;
28
+ }
29
+ args._.push(a);
30
+ }
31
+ return args;
32
+ }
33
+
34
+ export async function main(argv) {
35
+ const args = parseArgs(argv);
36
+ if (args.flags['no-color'] || args.flags.json) setColor(false);
37
+ if (args.flags.version || args.flags.v) { line(`gitroots ${VERSION}`); return 0; }
38
+ const [name, ...rest] = args._;
39
+
40
+ // Hidden plumbing
41
+ if (name === 'hook') {
42
+ const fs = await import('node:fs');
43
+ const { prePush } = await import('./commands/push.js');
44
+ if (rest[0] !== 'pre-push') throw new Error(`unknown hook ${rest[0]}`);
45
+ let input = '';
46
+ try { input = fs.readFileSync(0, 'utf8'); } catch { /* no stdin */ }
47
+ return prePush({ remote: rest[1], url: rest[2], input });
48
+ }
49
+ if (name === 'completion') return (await import('./commands/completion.js')).run(rest, args.flags);
50
+ if (name === 'uninstall') return (await import('./commands/update.js')).run('uninstall', rest, args.flags);
51
+ if (name === 'version') { line(`gitroots ${VERSION}`); return 0; }
52
+
53
+ const { find, suggest } = await import('./session/registry.js');
54
+ const { makeCtx, printHelp, startSession } = await import('./session/repl.js');
55
+
56
+ if (!name) {
57
+ if (args.flags.help || args.flags.h || !interactive()) { printHelp(false); return 0; }
58
+ return startSession();
59
+ }
60
+ if (name === 'help' || args.flags.help || args.flags.h) { printHelp(false); return 0; }
61
+
62
+ const cmd = find(name);
63
+ if (!cmd || cmd.session || !cmd.run) {
64
+ const near = suggest(name);
65
+ line(`${c.r('✕')} unknown command ${c.b(name)}${near.length ? ` ${c.m('did you mean')} ${near.map((x) => c.g(x.name)).join(', ')}?` : ''}`);
66
+ line(` ${c.m('gitroots help lists them all · gitroots on its own opens a session')}`);
67
+ return 2;
68
+ }
69
+ const ctx = makeCtx({ session: false });
70
+ ctx.json = Boolean(args.flags.json);
71
+ const ac = new AbortController();
72
+ ctx.signal = ac.signal;
73
+ process.on('SIGINT', () => { ac.abort(); process.exit(130); });
74
+ const code = await cmd.run(ctx, rest, args.flags);
75
+ return code ?? 0;
76
+ }
@@ -0,0 +1,46 @@
1
+ // gitroots completion bash|zsh|fish
2
+ import { COMMANDS } from '../session/registry.js';
3
+
4
+ const SUBS = { hooks: ['install', 'uninstall'], approvals: ['waiting', 'approved', 'declined'], audit: ['blocked', 'merges', 'access', 'other'] };
5
+ const FLAGS = '--json --help --no-color --blocked --force --server';
6
+
7
+ export async function run(rest) {
8
+ const shell = rest[0] || (process.env.SHELL || '').split('/').pop();
9
+ const cmds = COMMANDS.filter((c) => !c.session).map((c) => c.name).join(' ');
10
+ const subs = Object.entries(SUBS);
11
+ if (shell === 'bash') {
12
+ process.stdout.write(`# gitroots bash completion · eval "$(gitroots completion bash)"
13
+ _gitroots() {
14
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
15
+ if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "${cmds}" -- "$cur") ); return; fi
16
+ case "$cur" in -*) COMPREPLY=( $(compgen -W "${FLAGS}" -- "$cur") ); return;; esac
17
+ case "\${COMP_WORDS[1]}" in
18
+ ${subs.map(([k, v]) => ` ${k}) COMPREPLY=( $(compgen -W "${v.join(' ')}" -- "$cur") );;`).join('\n')}
19
+ esac
20
+ }
21
+ complete -F _gitroots gitroots
22
+ `);
23
+ return 0;
24
+ }
25
+ if (shell === 'zsh') {
26
+ process.stdout.write(`#compdef gitroots
27
+ # gitroots zsh completion · gitroots completion zsh > "\${fpath[1]}/_gitroots"
28
+ _gitroots() {
29
+ if (( CURRENT == 2 )); then compadd -- ${cmds}; return; fi
30
+ if [[ $words[CURRENT] == -* ]]; then compadd -- ${FLAGS}; return; fi
31
+ case $words[2] in
32
+ ${subs.map(([k, v]) => ` ${k}) compadd -- ${v.join(' ')};;`).join('\n')}
33
+ esac
34
+ }
35
+ compdef _gitroots gitroots
36
+ `);
37
+ return 0;
38
+ }
39
+ if (shell === 'fish') {
40
+ const lines = ['# gitroots fish completion · gitroots completion fish > ~/.config/fish/completions/gitroots.fish', 'complete -c gitroots -f', `complete -c gitroots -n __fish_use_subcommand -a "${cmds}"`];
41
+ for (const [k, v] of subs) lines.push(`complete -c gitroots -n "__fish_seen_subcommand_from ${k}" -a "${v.join(' ')}"`);
42
+ process.stdout.write(`${lines.join('\n')}\n`);
43
+ return 0;
44
+ }
45
+ throw new Error('usage: gitroots completion bash|zsh|fish');
46
+ }