gitroots 0.5.0 → 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 +76 -76
- package/bin/gitroots.js +8 -0
- package/package.json +2 -1
- package/src/agents/local.js +219 -0
- package/src/api.js +61 -98
- package/src/cli.js +36 -110
- package/src/commands/completion.js +14 -37
- package/src/commands/push.js +65 -131
- package/src/commands/update.js +1 -1
- package/src/config.js +66 -0
- package/src/rules.js +88 -0
- package/src/runtime.js +1 -1
- package/src/session/account.js +133 -0
- package/src/session/agents.js +416 -0
- package/src/session/helpers.js +71 -0
- package/src/session/logo.js +40 -0
- package/src/session/registry.js +61 -0
- package/src/session/repl.js +332 -0
- package/src/session/setup.js +101 -0
- package/src/session/workspace.js +270 -0
- package/src/ui/term.js +2 -0
- package/src/commands/attach.js +0 -262
- package/src/commands/audit.js +0 -150
- package/src/commands/auth.js +0 -99
- package/src/commands/backup.js +0 -106
- package/src/commands/demo.js +0 -12
- package/src/commands/doctor.js +0 -81
- package/src/commands/pr.js +0 -322
- package/src/commands/repos.js +0 -122
- package/src/commands/restore.js +0 -125
- package/src/commands/splash.js +0 -95
- package/src/commands/status.js +0 -84
- package/src/commands/transfer.js +0 -79
- package/src/commands/tunnel.js +0 -116
- package/src/commands/watch.js +0 -347
- package/src/connected.js +0 -82
- package/src/data/live.js +0 -49
- package/src/data/sample.js +0 -163
- package/src/data/store.js +0 -145
package/README.md
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
# gitroots CLI
|
|
2
2
|
|
|
3
|
-
Code
|
|
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
|
+
```
|
|
4
20
|
|
|
5
21
|
## Install
|
|
6
22
|
|
|
@@ -36,85 +52,69 @@ From a checkout: `npm link`.
|
|
|
36
52
|
|
|
37
53
|
## Releasing
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
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`.
|
|
54
72
|
|
|
55
73
|
## Commands
|
|
56
74
|
|
|
57
|
-
|
|
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
|
+
| | |
|
|
58
78
|
|---|---|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
-
|
|
84
|
-
- Motion (spinners, progress bars, live rows) stops when output is piped, with `NO_COLOR`, in `CI`, or when `GITROOTS_REDUCED_MOTION=1`.
|
|
85
|
-
- The welcome screen shows the real logo image (`assets/logo.png`) in Kitty, Ghostty, WezTerm and iTerm2. Other terminals, tmux and screen get a plain `gitroots` line. Set `GITROOTS_NO_IMAGES=1` to turn the image off.
|
|
86
|
-
|
|
87
|
-
## What is real and what is sample data
|
|
88
|
-
|
|
89
|
-
- **Push checks (real):** `gitroots hooks install` writes a `pre-push` hook. An existing hook is kept and run first with `--force`. On every `git push` the hook checks your actual commits:
|
|
90
|
-
- the branch rule
|
|
91
|
-
- protected paths
|
|
92
|
-
- a secrets scan of added lines (AWS, GitHub, GitLab, Slack, Stripe, Google, Anthropic and OpenAI keys, private keys, hard-coded passwords)
|
|
93
|
-
- commit authors and signatures
|
|
94
|
-
- migration files
|
|
95
|
-
|
|
96
|
-
A blocked push exits non-zero, so nothing leaves the machine. If gitroots itself can't be found, the hook blocks the push rather than skipping the checks.
|
|
97
|
-
- **Rules:** rules come from the gitroots org. For any other repo, put a `.gitroots.json` at its root:
|
|
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`:
|
|
98
104
|
```json
|
|
99
105
|
{ "protected": ["main", "release/*"], "paths": [{ "pattern": "infra/**", "needs": "an ops review" }] }
|
|
100
106
|
```
|
|
101
|
-
|
|
102
|
-
- `whoami` and the `status` header
|
|
103
|
-
- `repo list` and `branch list` (GitHub repos synced in the dashboard)
|
|
104
|
-
- `audit`, `audit <ev-id>` and `audit follow` (which polls the API)
|
|
105
|
-
- the welcome screen's sign-in and repository checks
|
|
106
|
-
- **Sample data:** PRs, agent sessions, machines, backups and tunnels aren't in the gitroots API yet. They run on the design's sample org, and screens say so when you're signed in. Your changes to them are saved in `~/.gitroots/state.json`, so commands build on each other. `gitroots demo reset` starts over. When the API grows those endpoints, add them in `src/connected.js` the same way repos and audit work.
|
|
107
|
-
- **Shells:** `attach --shell` opens a real shell for sessions on this machine. A remote shell would need an agentd on that machine, which doesn't exist yet.
|
|
107
|
+
Every push also gets a secrets scan, an author check and migration flags. A blocked push never leaves the machine.
|
|
108
108
|
|
|
109
109
|
## Environment
|
|
110
110
|
|
|
111
111
|
| Variable | Effect |
|
|
112
112
|
|---|---|
|
|
113
113
|
| `GITROOTS_HOME` | where state, config and credentials live (default `~/.gitroots`) |
|
|
114
|
-
| `GITROOTS_SERVER` |
|
|
114
|
+
| `GITROOTS_SERVER` | gitroots server (default `https://gitroots.com`) |
|
|
115
|
+
| `GITROOTS_TOKEN` | use this API token instead of the stored sign-in (CI) |
|
|
115
116
|
| `GITROOTS_DOWNLOAD_BASE` | where `update` and the installers fetch releases |
|
|
116
117
|
| `GITROOTS_NO_KEYCHAIN` | store the password in credentials.json even on macOS |
|
|
117
|
-
| `GITROOTS_RELAY_DOMAIN` | domain used for tunnel and share links (default `<relay>.relay.gitroots.com`) |
|
|
118
118
|
| `NO_COLOR` / `FORCE_COLOR` | turn colors off or force them on |
|
|
119
119
|
| `GITROOTS_REDUCED_MOTION` | no spinners or animation |
|
|
120
120
|
| `GITROOTS_NO_IMAGES` | no inline logo image |
|
|
@@ -124,15 +124,15 @@ dist/Formula/gitroots.rb
|
|
|
124
124
|
|
|
125
125
|
```
|
|
126
126
|
bin/gitroots.js entry
|
|
127
|
-
src/cli.js
|
|
128
|
-
src/
|
|
129
|
-
|
|
130
|
-
src/
|
|
131
|
-
src/
|
|
132
|
-
|
|
133
|
-
src/
|
|
134
|
-
src/commands/
|
|
135
|
-
src/git/checks.js
|
|
136
|
-
|
|
137
|
-
test/ node --test (includes a
|
|
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
138
|
```
|
package/bin/gitroots.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { main } from '../src/cli.js';
|
|
3
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
|
+
|
|
4
12
|
main(process.argv.slice(2)).then(
|
|
5
13
|
(code) => { process.exitCode = code ?? 0; },
|
|
6
14
|
(err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitroots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "gitroots CLI: code governance and agent sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"test": "node --test test/",
|
|
17
17
|
"build": "node scripts/build.mjs",
|
|
18
18
|
"build:npm": "node scripts/build.mjs --no-binaries",
|
|
19
|
+
"deploy": "bash scripts/deploy.sh",
|
|
19
20
|
"prepublishOnly": "node --test test/"
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|
|
@@ -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
CHANGED
|
@@ -1,121 +1,84 @@
|
|
|
1
|
-
// gitroots API client
|
|
2
|
-
|
|
3
|
-
// or in $GITROOTS_HOME/credentials.json (mode 600) elsewhere.
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
7
|
-
import { home } from './data/store.js';
|
|
8
|
-
|
|
9
|
-
export const DEFAULT_SERVER = process.env.GITROOTS_SERVER || 'https://gitroots.com';
|
|
10
|
-
|
|
11
|
-
const configFile = () => path.join(home(), 'config.json');
|
|
12
|
-
const credFile = () => path.join(home(), 'credentials.json');
|
|
13
|
-
|
|
14
|
-
export function readConfig() {
|
|
15
|
-
try { return JSON.parse(fs.readFileSync(configFile(), 'utf8')); } catch { return {}; }
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function writeConfig(cfg) {
|
|
19
|
-
fs.mkdirSync(home(), { recursive: true });
|
|
20
|
-
fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const service = (server) => `gitroots:${server}`;
|
|
24
|
-
const useKeychain = () => process.platform === 'darwin' && !process.env.GITROOTS_NO_KEYCHAIN;
|
|
25
|
-
|
|
26
|
-
export function savePassword(server, username, password) {
|
|
27
|
-
if (useKeychain()) {
|
|
28
|
-
const r = spawnSync('security', ['add-generic-password', '-U', '-a', username, '-s', service(server), '-w', password], { stdio: 'ignore' });
|
|
29
|
-
if (r.status === 0) return 'keychain';
|
|
30
|
-
}
|
|
31
|
-
fs.mkdirSync(home(), { recursive: true });
|
|
32
|
-
let all = {};
|
|
33
|
-
try { all = JSON.parse(fs.readFileSync(credFile(), 'utf8')); } catch { /* none */ }
|
|
34
|
-
all[`${username}@${server}`] = password;
|
|
35
|
-
fs.writeFileSync(credFile(), JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
36
|
-
fs.chmodSync(credFile(), 0o600);
|
|
37
|
-
return 'file';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function loadPassword(server, username) {
|
|
41
|
-
if (process.env.GITROOTS_PASSWORD) return process.env.GITROOTS_PASSWORD;
|
|
42
|
-
if (useKeychain()) {
|
|
43
|
-
const r = spawnSync('security', ['find-generic-password', '-a', username, '-s', service(server), '-w'], { encoding: 'utf8' });
|
|
44
|
-
if (r.status === 0) return r.stdout.replace(/\n$/, '');
|
|
45
|
-
}
|
|
46
|
-
try { return JSON.parse(fs.readFileSync(credFile(), 'utf8'))[`${username}@${server}`] || null; } catch { return null; }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function forgetPassword(server, username) {
|
|
50
|
-
if (process.platform === 'darwin') spawnSync('security', ['delete-generic-password', '-a', username, '-s', service(server)], { stdio: 'ignore' });
|
|
51
|
-
try {
|
|
52
|
-
const all = JSON.parse(fs.readFileSync(credFile(), 'utf8'));
|
|
53
|
-
delete all[`${username}@${server}`];
|
|
54
|
-
fs.writeFileSync(credFile(), JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
55
|
-
} catch { /* none */ }
|
|
56
|
-
}
|
|
1
|
+
// gitroots API client. Uses the same token as the gitroots mobile app.
|
|
2
|
+
import { DEFAULT_SERVER, readConfig, loadToken } from './config.js';
|
|
57
3
|
|
|
58
4
|
export class ApiError extends Error {
|
|
59
|
-
constructor(message, status) { super(message); this.status = status; }
|
|
5
|
+
constructor(message, status, body) { super(message); this.status = status; this.body = body; }
|
|
60
6
|
}
|
|
61
7
|
|
|
62
8
|
export class Api {
|
|
63
|
-
constructor({ server,
|
|
9
|
+
constructor({ server = DEFAULT_SERVER, token = null, orgId = null } = {}) {
|
|
64
10
|
this.server = server.replace(/\/+$/, '');
|
|
65
|
-
this.
|
|
66
|
-
this.
|
|
11
|
+
this.token = token;
|
|
12
|
+
this.orgId = orgId;
|
|
67
13
|
}
|
|
68
14
|
|
|
69
15
|
static fromConfig() {
|
|
70
16
|
const cfg = readConfig();
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
return new Api({ server: cfg.server, username: cfg.username, password });
|
|
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 });
|
|
75
20
|
}
|
|
76
21
|
|
|
77
|
-
|
|
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 = [] } = {}) {
|
|
78
26
|
const url = new URL(`${this.server}/api/${p.replace(/^\/+/, '')}`);
|
|
79
|
-
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
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);
|
|
80
36
|
let res;
|
|
81
37
|
try {
|
|
82
|
-
res = await fetch(url, { headers:
|
|
38
|
+
res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.any ? AbortSignal.any(signals) : signals[0] });
|
|
83
39
|
} catch (e) {
|
|
84
|
-
throw new ApiError(
|
|
85
|
-
|
|
86
|
-
if (res.status === 401 || res.status === 403) {
|
|
87
|
-
let detail = '';
|
|
88
|
-
try { detail = (await res.json()).detail || ''; } catch { /* not json */ }
|
|
89
|
-
throw new ApiError(res.status === 401 ? 'not signed in, or the password changed · gitroots login' : `not allowed${detail ? `: ${detail}` : ''}`, res.status);
|
|
90
|
-
}
|
|
91
|
-
if (!res.ok) {
|
|
92
|
-
let detail = '';
|
|
93
|
-
try { detail = (await res.json()).detail || ''; } catch { /* not json */ }
|
|
94
|
-
throw new ApiError(`${res.status} from ${url.pathname}${detail ? `: ${detail}` : ''}`, res.status);
|
|
40
|
+
if (signal?.aborted) throw new ApiError('cancelled', 0);
|
|
41
|
+
throw new ApiError(`can't reach ${this.host} (${e.cause?.code || e.name})`, 0);
|
|
95
42
|
}
|
|
43
|
+
let data = null;
|
|
96
44
|
const ct = res.headers.get('content-type') || '';
|
|
97
|
-
if (
|
|
98
|
-
|
|
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);
|
|
99
53
|
}
|
|
100
54
|
|
|
101
|
-
|
|
102
|
-
async
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const next = new URL(data.next);
|
|
109
|
-
data = await this.get(next.pathname.replace(/^\/api\//, ''), Object.fromEntries(next.searchParams));
|
|
110
|
-
out.push(...(data.results || []));
|
|
111
|
-
}
|
|
112
|
-
return out;
|
|
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] });
|
|
113
62
|
}
|
|
114
63
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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); }
|
|
121
84
|
}
|