jaitus 0.3.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/LICENSE +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- package/src/ui.js +562 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// ─── jaitus login ───────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The loopback-redirect flow from RFC 8252 (OAuth 2.0 for Native Apps) with PKCE
|
|
4
|
+
// (RFC 7636), which is what `gh auth login`, Codex and the cloud CLIs use:
|
|
5
|
+
//
|
|
6
|
+
// 1. Start an HTTP server on a random port, bound to 127.0.0.1 only, and make a PKCE
|
|
7
|
+
// verifier that never leaves this process.
|
|
8
|
+
// 2. Open the browser at /cli-auth?port=…&state=…&challenge=…&device=… on the website.
|
|
9
|
+
// 3. The user signs in there — this CLI never sees a password.
|
|
10
|
+
// 4. The browser is redirected back to the loopback with a one-time CODE.
|
|
11
|
+
// 5. Exchange the code AND the verifier for this device's session, over HTTPS.
|
|
12
|
+
//
|
|
13
|
+
// No credential ever travels in a URL, so none can end up in browser history, a referrer
|
|
14
|
+
// header or a local proxy's log — and an intercepted code is useless without the
|
|
15
|
+
// verifier. `state` is compared on return, so a page the user happens to have open
|
|
16
|
+
// cannot drive this flow from the side.
|
|
17
|
+
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { endpoints, saveCredentials, loadCredentials } from '../config.js';
|
|
22
|
+
import { deviceId, deviceName } from '../session.js';
|
|
23
|
+
import { c, out, ok, fail, info, spinner, ask, glyph } from '../ui.js';
|
|
24
|
+
import { openUrl } from '../open.js';
|
|
25
|
+
|
|
26
|
+
// Five minutes is the real budget: long enough to find the right browser profile and
|
|
27
|
+
// sign in, short enough that a forgotten terminal does not hold a listening socket open
|
|
28
|
+
// all afternoon. Overridable because the test that proves a mismatched `state` never
|
|
29
|
+
// completes a sign-in has to wait out this exact timer, and a suite that takes five
|
|
30
|
+
// minutes to make one assertion is a suite nobody runs.
|
|
31
|
+
// The launcher lives in ../open.js: one copy of the Windows quoting, because two
|
|
32
|
+
// copies means one of them is wrong.
|
|
33
|
+
const openBrowser = openUrl;
|
|
34
|
+
|
|
35
|
+
const LOGIN_TIMEOUT_MS = Number(process.env.JAITUS_LOGIN_TIMEOUT_MS) || 5 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
const PAGE = (title, body, accent) => `<!doctype html><meta charset="utf-8">
|
|
39
|
+
<title>${title}</title>
|
|
40
|
+
<style>
|
|
41
|
+
body{margin:0;height:100vh;display:grid;place-items:center;background:#faf8f5;
|
|
42
|
+
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:#1a2332}
|
|
43
|
+
.card{max-width:420px;padding:40px;text-align:center}
|
|
44
|
+
h1{font-size:22px;margin:0 0 8px;color:#0f141c}
|
|
45
|
+
p{margin:0;color:#526077;font-size:14px}
|
|
46
|
+
.dot{width:44px;height:44px;border-radius:14px;margin:0 auto 20px;
|
|
47
|
+
display:grid;place-items:center;background:${accent}1a;color:${accent};font-size:22px}
|
|
48
|
+
@media (prefers-color-scheme:dark){
|
|
49
|
+
body{background:#0b0f16;color:#e6edf6} h1{color:#fff} p{color:#94a3b8}
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
52
|
+
<div class="card"><div class="dot">${accent === '#ff6b4a' ? '✓' : '✕'}</div>
|
|
53
|
+
<h1>${title}</h1><p>${body}</p></div>`;
|
|
54
|
+
|
|
55
|
+
/** Wait for the browser to come back with a code. Resolves { code } or rejects. */
|
|
56
|
+
function awaitCallback(server, expectedState) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
reject(new Error('Timed out waiting for the browser. Run `jaitus login` again.'));
|
|
60
|
+
}, LOGIN_TIMEOUT_MS);
|
|
61
|
+
|
|
62
|
+
server.on('request', (req, res) => {
|
|
63
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
64
|
+
if (url.pathname !== '/callback') {
|
|
65
|
+
res.writeHead(404).end();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const finish = (status, page) => {
|
|
70
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
71
|
+
res.end(page);
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Checked before anything else: a mismatched state means this callback did not
|
|
76
|
+
// come from the page this process opened.
|
|
77
|
+
//
|
|
78
|
+
// Deliberately does NOT give up. An ephemeral port gets reused quickly, so a tab
|
|
79
|
+
// left open from an earlier `jaitus login` can land here and would otherwise kill
|
|
80
|
+
// a sign-in the user is in the middle of completing correctly. Tell that tab it is
|
|
81
|
+
// stale and keep waiting for the right one.
|
|
82
|
+
if (url.searchParams.get('state') !== expectedState) {
|
|
83
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
84
|
+
res.end(PAGE(
|
|
85
|
+
'This tab is out of date',
|
|
86
|
+
'It came from an earlier sign-in attempt. Go back to your terminal and open the newest link.',
|
|
87
|
+
'#e84f2e'
|
|
88
|
+
));
|
|
89
|
+
return; // still listening
|
|
90
|
+
}
|
|
91
|
+
if (url.searchParams.get('error')) {
|
|
92
|
+
finish(200, PAGE('Sign-in cancelled', 'You can close this tab and go back to your terminal.', '#e84f2e'));
|
|
93
|
+
return reject(new Error('Cancelled in the browser.'));
|
|
94
|
+
}
|
|
95
|
+
const code = url.searchParams.get('code');
|
|
96
|
+
if (!code) {
|
|
97
|
+
finish(400, PAGE('Something went wrong', 'No authorisation code came back. Please try again.', '#e84f2e'));
|
|
98
|
+
return reject(new Error('No code returned.'));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
finish(200, PAGE('You‘re signed in', 'You can close this tab and go back to your terminal.', '#ff6b4a'));
|
|
102
|
+
resolve({ code });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default async function login(argv) {
|
|
108
|
+
const { baseUrl, siteUrl } = endpoints();
|
|
109
|
+
|
|
110
|
+
const existing = loadCredentials();
|
|
111
|
+
if (existing?.refreshToken && !argv.includes('--force')) {
|
|
112
|
+
out(`Already signed in as ${c.bold(existing.user?.email || 'your account')}.`);
|
|
113
|
+
const again = await ask(`${c.dim('Sign in again? [y/N] ')}`);
|
|
114
|
+
if (again.toLowerCase() !== 'y') return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const state = crypto.randomBytes(24).toString('base64url');
|
|
118
|
+
// PKCE (S256): the challenge goes out through the browser, the verifier stays here and
|
|
119
|
+
// is only ever sent in the final exchange.
|
|
120
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
121
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
122
|
+
const device = deviceId();
|
|
123
|
+
const server = http.createServer();
|
|
124
|
+
|
|
125
|
+
// Port 0 lets the OS pick a free one; 127.0.0.1 keeps it off the network entirely.
|
|
126
|
+
await new Promise((resolve, reject) => {
|
|
127
|
+
server.once('error', reject);
|
|
128
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
129
|
+
});
|
|
130
|
+
const port = server.address().port;
|
|
131
|
+
|
|
132
|
+
// Appended, not resolved from the root: `new URL('/cli-auth', 'https://x/app')` throws
|
|
133
|
+
// away the `/app` prefix, so a site hosted under a path would be sent to a page that
|
|
134
|
+
// does not exist.
|
|
135
|
+
const url = new URL(`${siteUrl}/cli-auth`);
|
|
136
|
+
url.searchParams.set('port', String(port));
|
|
137
|
+
url.searchParams.set('state', state);
|
|
138
|
+
url.searchParams.set('name', deviceName());
|
|
139
|
+
url.searchParams.set('challenge', challenge);
|
|
140
|
+
url.searchParams.set('device', device);
|
|
141
|
+
url.searchParams.set('platform', process.platform);
|
|
142
|
+
|
|
143
|
+
out('');
|
|
144
|
+
out(` ${c.brand('Jaitus')} — sign in`);
|
|
145
|
+
out('');
|
|
146
|
+
out(` Opening ${c.cyan(siteUrl + '/cli-auth')} in your browser.`);
|
|
147
|
+
out(` ${c.dim('If it does not open, paste this link:')}`);
|
|
148
|
+
out(` ${c.dim(url.toString())}`);
|
|
149
|
+
out('');
|
|
150
|
+
|
|
151
|
+
openBrowser(url.toString());
|
|
152
|
+
const spin = spinner(glyph('Waiting for you to authorise in the browser…', 'Waiting for you to authorise in the browser...'));
|
|
153
|
+
|
|
154
|
+
let code;
|
|
155
|
+
try {
|
|
156
|
+
({ code } = await awaitCallback(server, state));
|
|
157
|
+
} catch (e) {
|
|
158
|
+
spin.stop();
|
|
159
|
+
server.close();
|
|
160
|
+
fail(e.message);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
spin.update(glyph('Finishing sign-in…', 'Finishing sign-in...'));
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`${baseUrl}/api/auth/cli/exchange`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ code, verifier }),
|
|
170
|
+
});
|
|
171
|
+
const body = await res.json().catch(() => ({}));
|
|
172
|
+
spin.stop();
|
|
173
|
+
server.close();
|
|
174
|
+
|
|
175
|
+
if (!res.ok || !body?.data?.accessToken || !body?.data?.refreshToken) {
|
|
176
|
+
// Our own sign-in endpoint's wording, or a plain fallback — never a raw body.
|
|
177
|
+
fail(typeof body?.message === 'string' && body.message ? body.message : 'Sign-in did not complete. Run `jaitus login` again.');
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
saveCredentials({
|
|
182
|
+
accessToken: body.data.accessToken,
|
|
183
|
+
refreshToken: body.data.refreshToken,
|
|
184
|
+
accessExpiresAt: body.data.accessExpiresAt,
|
|
185
|
+
deviceId: device,
|
|
186
|
+
user: body.data.user || null,
|
|
187
|
+
baseUrl,
|
|
188
|
+
createdAt: new Date().toISOString(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const who = body.data.user;
|
|
192
|
+
ok(`Signed in as ${c.bold(who?.email || 'your account')}${who?.isPaid ? '' : c.dim(' (free plan)')}`);
|
|
193
|
+
info(`Run ${c.bold('jaitus')} in a project to start, or ${c.bold('jaitus --help')} to see everything.`);
|
|
194
|
+
return 0;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
spin.stop();
|
|
197
|
+
server.close();
|
|
198
|
+
fail('Could not reach Jaitus. Check your connection and run `jaitus login` again.');
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ─── jaitus logout ──────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Ends this device's session ON THE SERVER, then forgets it here. Signing out used to
|
|
4
|
+
// only delete the local file and leave a working key behind in the account; now the
|
|
5
|
+
// tokens stop working the moment this runs. Offline, the local copy still goes and the
|
|
6
|
+
// session lapses on its own — and that is said, not hidden.
|
|
7
|
+
|
|
8
|
+
import { endpoints } from '../config.js';
|
|
9
|
+
import { signOut } from '../session.js';
|
|
10
|
+
import { ok, info, warn } from '../ui.js';
|
|
11
|
+
|
|
12
|
+
export default async function logout() {
|
|
13
|
+
const { hadSession, server } = await signOut();
|
|
14
|
+
if (!hadSession) {
|
|
15
|
+
info('You were not signed in.');
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
if (server) {
|
|
19
|
+
ok('Signed out of this device.');
|
|
20
|
+
} else {
|
|
21
|
+
ok('Signed out here.');
|
|
22
|
+
warn(`Could not reach Jaitus to end the session on the server. It will expire on its own, or sign it out at ${endpoints().siteUrl}/gateway.`);
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ─── /research — agentic web research with citations ────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A /agent side-task with a research-grade brief: the model plans queries, runs
|
|
4
|
+
// web_search/web_fetch itself, reads what it needs, and returns a cited brief.
|
|
5
|
+
// Read-only by decree (plan-mode permissions), so it can never touch your files.
|
|
6
|
+
//
|
|
7
|
+
// Usage: /research <question>
|
|
8
|
+
// /research --deep <question> more rounds, for a question worth the tokens
|
|
9
|
+
// /research --rounds 5 <question> the same knob, named
|
|
10
|
+
import { runAgent } from '../agent/loop.js';
|
|
11
|
+
import { createPermissions } from '../agent/permissions.js';
|
|
12
|
+
import { c, ok, warn } from '../ui.js';
|
|
13
|
+
|
|
14
|
+
export default async function research(args, ctx) {
|
|
15
|
+
let rounds = 3;
|
|
16
|
+
const rest = [];
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
if (args[i] === '--rounds') rounds = Math.max(1, Math.min(8, Number(args[++i]) || 3));
|
|
19
|
+
// `--deep` was a separate /deep-research command, which is two names for one thing
|
|
20
|
+
// in a list that already had too many names in it.
|
|
21
|
+
else if (args[i] === '--deep') rounds = 5;
|
|
22
|
+
else rest.push(args[i]);
|
|
23
|
+
}
|
|
24
|
+
const question = rest.join(' ').trim();
|
|
25
|
+
if (!question) {
|
|
26
|
+
warn('Usage: /research [--deep] [--rounds 1-8] <question>');
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const brief = [
|
|
31
|
+
`Research this question and return a brief with citations.`,
|
|
32
|
+
``,
|
|
33
|
+
`Question: ${question}`,
|
|
34
|
+
``,
|
|
35
|
+
`Method — do this, not a single-shot answer:`,
|
|
36
|
+
`1. Decompose the question into 2-5 concrete sub-queries.`,
|
|
37
|
+
`2. web_search each one; open the 2-4 most promising results with web_fetch.`,
|
|
38
|
+
`3. Cross-check any number, price, date or claim across at least two independent sources before stating it. Say "single source" when you could not.`,
|
|
39
|
+
`4. Iterate up to ${rounds} research rounds; stop early when two rounds agree.`,
|
|
40
|
+
``,
|
|
41
|
+
`Output format — exactly this:`,
|
|
42
|
+
`## Answer`,
|
|
43
|
+
`The finding, stated plainly, in 3-8 sentences.`,
|
|
44
|
+
`## Key facts`,
|
|
45
|
+
`Bullet list: fact — [source name](url)`,
|
|
46
|
+
`## Confidence & gaps`,
|
|
47
|
+
`What is solid, what is thin, what you could not verify.`,
|
|
48
|
+
].join('\n');
|
|
49
|
+
|
|
50
|
+
const sideMessages = [{ role: 'user', content: brief }];
|
|
51
|
+
const sidePermissions = createPermissions({ mode: 'plan' }); // read-only: web tools only
|
|
52
|
+
const side = await runAgent({
|
|
53
|
+
client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
|
|
54
|
+
permissions: sidePermissions,
|
|
55
|
+
extraSystem: `You are the Jaitus research agent. You find things out on the web and report back with sources. You never edit files or run commands — web_search and web_fetch only. Be precise about what a source actually says; never invent a URL.`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
ok(`Research complete (${side.tools || 0} tool calls, ${((side.input || 0) + (side.output || 0)).toLocaleString()} tokens).`);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ─── Review engine — one diff reader, three lenses ──────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// /code-review, /security-review and /simplify share everything except the
|
|
4
|
+
// brief: read the change, report through one lens, apply with --fix (gated by
|
|
5
|
+
// the session's normal permissions, never silently).
|
|
6
|
+
//
|
|
7
|
+
// Usage: /code-review [--fix] [path|branch]
|
|
8
|
+
// /security-review [--fix] [path|branch]
|
|
9
|
+
// /simplify [--fix] [path]
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { runAgent } from '../agent/loop.js';
|
|
12
|
+
import { createPermissions } from '../agent/permissions.js';
|
|
13
|
+
import { c, out, ok, warn, fail, glyph } from '../ui.js';
|
|
14
|
+
|
|
15
|
+
const BRIEFS = {
|
|
16
|
+
correctness: {
|
|
17
|
+
title: 'Code review',
|
|
18
|
+
system: `You are the Jaitus code reviewer. Hunt correctness bugs first (wrong logic, off-by-ones, broken error paths, race conditions, API misuse), then dead code and cleanup. Every finding cites file:line and quotes the offending code. No praise paragraphs, no invented issues — if the diff is clean, say so in one line.`,
|
|
19
|
+
task: (target) => `Review this change for correctness bugs and cleanup opportunities. Structure: ## Bugs (each: severity high/med/low, file:line, the bad code, the fix) ## Cleanup (nit-level, optional) ## Verdict (one line: ship it / fix first).`,
|
|
20
|
+
},
|
|
21
|
+
security: {
|
|
22
|
+
title: 'Security review',
|
|
23
|
+
system: `You are the Jaitus security reviewer. Hunt: injection (command/SQL/template), auth/authz gaps, secret or credential exposure, path traversal, SSRF, unsafe deserialization, weak crypto, missing validation on untrusted input. Every finding cites file:line with the vulnerable code and a concrete fix. If nothing is exploitable, say so in one line — never pad with hypotheticals.`,
|
|
24
|
+
task: (target) => `Security-review this change. Structure: ## Vulnerabilities (each: severity, file:line, exploit sketch in one sentence, fix) ## Hardening notes (optional, low-risk improvements) ## Verdict (one line).`,
|
|
25
|
+
},
|
|
26
|
+
simplify: {
|
|
27
|
+
title: 'Simplify',
|
|
28
|
+
system: `You are the Jaitus simplifier. You do NOT hunt bugs — only readability: duplication to fold, nesting to flatten, names to clarify, dead branches to cut. Every suggestion shows before/after. If the code is already tight, say so in one line.`,
|
|
29
|
+
task: (target) => `Simplify this change for readability only. Structure: ## Suggestions (each: file:line, before → after) ## Verdict (one line).`,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function git(args, cwd) {
|
|
34
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
|
35
|
+
return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runReview(mode, args, ctx) {
|
|
39
|
+
const brief = BRIEFS[mode];
|
|
40
|
+
if (!brief) { fail(`Unknown review lens: ${mode}.`); return null; }
|
|
41
|
+
const fix = args.includes('--fix');
|
|
42
|
+
const target = args.filter((a) => a !== '--fix').join(' ').trim();
|
|
43
|
+
|
|
44
|
+
if (!git(['rev-parse', '--is-inside-work-tree'], ctx.flags.cwd).ok) {
|
|
45
|
+
fail('Not a git repository — nothing to review.');
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// A path scopes the review, a range (main..branch) compares branches,
|
|
49
|
+
// otherwise the whole working tree is the subject.
|
|
50
|
+
const diffArgs = !target
|
|
51
|
+
? ['diff', 'HEAD', '--']
|
|
52
|
+
: target.includes('..')
|
|
53
|
+
? ['diff', target, '--']
|
|
54
|
+
: ['diff', 'HEAD', '--', target];
|
|
55
|
+
const diff = git(diffArgs, ctx.flags.cwd);
|
|
56
|
+
if (!diff.ok) { fail(`Could not read the diff: ${diff.err || 'git refused'}.`); return null; }
|
|
57
|
+
if (!diff.out) { ok(target ? `No changes under ${target} — nothing to review.` : 'Working tree is clean — nothing to review.'); return null; }
|
|
58
|
+
|
|
59
|
+
const stat = git(['diff', 'HEAD', '--stat'], ctx.flags.cwd).out.split('\n').slice(0, 8);
|
|
60
|
+
out(c.dim(` ${brief.title}${target ? ` — ${target}` : ''} (${diff.out.split('\n').length.toLocaleString()} diff lines):`));
|
|
61
|
+
for (const line of stat) out(` ${c.dim(line)}`);
|
|
62
|
+
|
|
63
|
+
const messages = [{
|
|
64
|
+
role: 'user',
|
|
65
|
+
content: `${brief.task(target)}\n\nDiff${target ? ` (${target})` : ''}:\n${diff.out.slice(0, 30000)}${diff.out.length > 30000 ? `\n${glyph('…', '...')}(truncated)` : ''}`,
|
|
66
|
+
}];
|
|
67
|
+
await runAgent({
|
|
68
|
+
client: ctx.client, messages, model: ctx.model, cwd: ctx.flags.cwd,
|
|
69
|
+
// Reviewing reads; --fix writes — so the fix pass inherits the session's
|
|
70
|
+
// own permission mode instead of smuggling in a permissive one.
|
|
71
|
+
permissions: fix
|
|
72
|
+
? createPermissions({ mode: ctx.permissions?.mode || 'default' })
|
|
73
|
+
: createPermissions({ mode: 'plan' }),
|
|
74
|
+
extraSystem: fix
|
|
75
|
+
? `${brief.system}\n\nAfter reporting, APPLY the fixes you recommended (minimal diffs, same style as the file). Then summarize what changed.`
|
|
76
|
+
: brief.system,
|
|
77
|
+
});
|
|
78
|
+
if (!fix) warn(`Run with --fix to let the agent apply the findings (permissions still ask first).`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// ─── /security-review — vulnerabilities in the change, with --fix ────────────────
|
|
2
|
+
//
|
|
3
|
+
// Usage: /security-review [--fix] [path|branch..range]
|
|
4
|
+
import { runReview } from './review.js';
|
|
5
|
+
|
|
6
|
+
export default async function securityReview(args, ctx) {
|
|
7
|
+
return runReview('security', args, ctx);
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// ─── /simplify — readability-only pass over the change, with --fix ───────────────
|
|
2
|
+
//
|
|
3
|
+
// Usage: /simplify [--fix] [path]
|
|
4
|
+
import { runReview } from './review.js';
|
|
5
|
+
|
|
6
|
+
export default async function simplify(args, ctx) {
|
|
7
|
+
return runReview('simplify', args, ctx);
|
|
8
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ─── /test — run the project's tests, then FIX what fails (with your ok) ────────
|
|
2
|
+
//
|
|
3
|
+
// Detects the runner (package.json scripts, pytest, go test, cargo), runs it,
|
|
4
|
+
// streams the tail, and on failures offers a focused fix loop: the agent sees
|
|
5
|
+
// the failing output and the files it points at, proposes fixes, applies them
|
|
6
|
+
// only after permission, and re-runs until green or you stop it.
|
|
7
|
+
//
|
|
8
|
+
// Usage: /test [anything forwarded to the runner]
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { ok, warn, fail, out, c } from '../ui.js';
|
|
13
|
+
|
|
14
|
+
/** Pick a command from package.json scripts, or fall back to ecosystem defaults. */
|
|
15
|
+
function detectRunner(cwd) {
|
|
16
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
17
|
+
try {
|
|
18
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
19
|
+
const s = pkg.scripts || {};
|
|
20
|
+
if (s.test) return { cmd: 'npm', args: ['test', ...(process.platform === 'win32' ? [] : [])], label: `npm test` };
|
|
21
|
+
} catch { /* not a node project */ }
|
|
22
|
+
if (fs.existsSync(path.join(cwd, 'pytest.ini')) || fs.existsSync(path.join(cwd, 'pyproject.toml'))) {
|
|
23
|
+
return { cmd: 'python', args: ['-m', 'pytest', '-x', '-q'], label: 'pytest' };
|
|
24
|
+
}
|
|
25
|
+
if (fs.existsSync(path.join(cwd, 'go.mod'))) return { cmd: 'go', args: ['test', './...'], label: 'go test' };
|
|
26
|
+
if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) return { cmd: 'cargo', args: ['test'], label: 'cargo test' };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default async function test(args, ctx) {
|
|
31
|
+
const forward = args.filter((a) => a !== '--fix');
|
|
32
|
+
const wantsFix = args.includes('--fix');
|
|
33
|
+
const runner = detectRunner(ctx.flags.cwd);
|
|
34
|
+
if (!runner) { warn('No test runner found (package.json scripts, pytest, go, cargo).'); return null; }
|
|
35
|
+
|
|
36
|
+
out(c.dim(` Running: ${runner.label}${forward.length ? ` ${forward.join(' ')}` : ''}`));
|
|
37
|
+
const r = spawnSync(runner.cmd, [...runner.args, ...forward], {
|
|
38
|
+
cwd: ctx.flags.cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
const output = `${r.stdout || ''}\n${r.stderr || ''}`.trim();
|
|
41
|
+
|
|
42
|
+
// Show the tail — enough to see failures, not the whole log.
|
|
43
|
+
const lines = output.split('\n');
|
|
44
|
+
for (const line of lines.slice(-40)) out(` ${line}`);
|
|
45
|
+
|
|
46
|
+
if (r.status === 0) { ok('All tests passed.'); return null; }
|
|
47
|
+
fail(`Tests failed (exit ${r.status}).`);
|
|
48
|
+
|
|
49
|
+
if (!wantsFix) {
|
|
50
|
+
warn('Run /test --fix to let the agent fix the failures.');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The fix loop: hand the failure output to the agent, let it work through
|
|
55
|
+
// the code (permissions still gate every write), then re-run the suite.
|
|
56
|
+
const { runAgent } = await import('../agent/loop.js');
|
|
57
|
+
const { createPermissions } = await import('../agent/permissions.js');
|
|
58
|
+
const fixMessages = [{
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: `These tests are failing in ${ctx.flags.cwd}. Diagnose and FIX them: read the failing code, make the minimal correct changes, then run \`${runner.label}\` again. Do not weaken assertions to make tests pass unless the test itself is provably wrong.\n\nFailure output (tail):\n${output.slice(-12000)}`,
|
|
61
|
+
}];
|
|
62
|
+
await runAgent({
|
|
63
|
+
client: ctx.client, messages: fixMessages, model: ctx.model, cwd: ctx.flags.cwd,
|
|
64
|
+
permissions: createPermissions({ mode: ctx.permissions?.mode || 'default' }),
|
|
65
|
+
extraSystem: `You are fixing failing tests. Minimal, correct changes. Never delete a failing test to go green; fix the code (or fix the test only if the test is wrong).`,
|
|
66
|
+
});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ─── jaitus update ──────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Checks the registry and tells you. It deliberately does NOT run the install itself:
|
|
4
|
+
// the CLI may have been installed globally, with npx, through a version manager, or
|
|
5
|
+
// vendored into a repo, and a tool that guesses wrong there either fails confusingly or
|
|
6
|
+
// modifies something it was never asked to modify.
|
|
7
|
+
|
|
8
|
+
import { c, out, ok, info, warn, present } from '../ui.js';
|
|
9
|
+
import { version } from '../config.js';
|
|
10
|
+
|
|
11
|
+
const REGISTRY = process.env.JAITUS_REGISTRY || 'https://registry.npmjs.org';
|
|
12
|
+
const TIMEOUT_MS = 5000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Ask the registry what is published.
|
|
16
|
+
*
|
|
17
|
+
* "Not published" and "could not reach the registry" are different answers and must not
|
|
18
|
+
* collapse into one null: reporting an offline machine as "no version published" sends
|
|
19
|
+
* someone looking for a release that exists, and reporting an unpublished package as a
|
|
20
|
+
* network fault sends them looking for a firewall that does not exist. A 404 is a real
|
|
21
|
+
* answer from a registry that is working perfectly.
|
|
22
|
+
*/
|
|
23
|
+
export async function checkRegistry() {
|
|
24
|
+
const ctrl = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${REGISTRY}/jaitus/latest`, {
|
|
28
|
+
signal: ctrl.signal,
|
|
29
|
+
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
|
|
30
|
+
});
|
|
31
|
+
if (res.status === 404) return { version: null, status: 'unpublished' };
|
|
32
|
+
if (!res.ok) return { version: null, status: 'unreachable' };
|
|
33
|
+
const body = await res.json();
|
|
34
|
+
return typeof body?.version === 'string'
|
|
35
|
+
? { version: body.version, status: 'ok' }
|
|
36
|
+
: { version: null, status: 'unreachable' };
|
|
37
|
+
} catch {
|
|
38
|
+
return { version: null, status: 'unreachable' };
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The published version, or null. */
|
|
45
|
+
export async function latestVersion() {
|
|
46
|
+
return (await checkRegistry()).version;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Plain semver comparison — is `a` newer than `b`?
|
|
51
|
+
* Prerelease tags are treated as older than the release they lead to, which is what
|
|
52
|
+
* semver says and what stops a `-beta.1` being offered as an upgrade over the release.
|
|
53
|
+
*/
|
|
54
|
+
export function isNewer(a, b) {
|
|
55
|
+
const parse = (v) => {
|
|
56
|
+
const [core, pre] = String(v).split('-');
|
|
57
|
+
return { nums: core.split('.').map((n) => Number(n) || 0), pre: pre || null };
|
|
58
|
+
};
|
|
59
|
+
const x = parse(a);
|
|
60
|
+
const y = parse(b);
|
|
61
|
+
for (let i = 0; i < 3; i++) {
|
|
62
|
+
if ((x.nums[i] || 0) !== (y.nums[i] || 0)) return (x.nums[i] || 0) > (y.nums[i] || 0);
|
|
63
|
+
}
|
|
64
|
+
if (x.pre && !y.pre) return false;
|
|
65
|
+
if (!x.pre && y.pre) return true;
|
|
66
|
+
if (x.pre && y.pre) return x.pre > y.pre;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default async function update(argv = [], { json = false } = {}) {
|
|
71
|
+
const current = version();
|
|
72
|
+
const { version: latest, status } = await checkRegistry();
|
|
73
|
+
|
|
74
|
+
if (!latest) {
|
|
75
|
+
present(json, { current, latest: null, upToDate: null, status }, () => {
|
|
76
|
+
if (status === 'unpublished') warn('jaitus is not published on this registry, so there is nothing to update to.');
|
|
77
|
+
else warn('Could not reach the npm registry to check for updates.');
|
|
78
|
+
info(`You are running ${c.bold(`v${current}`)}.`);
|
|
79
|
+
});
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const available = isNewer(latest, current);
|
|
84
|
+
present(json, { current, latest, upToDate: !available, status }, () => {
|
|
85
|
+
if (!available) {
|
|
86
|
+
ok(`You are on the latest version (${c.bold(`v${current}`)}).`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
out('');
|
|
90
|
+
out(` ${c.bold(`v${latest}`)} is available ${c.dim(`— you have v${current}`)}`);
|
|
91
|
+
out('');
|
|
92
|
+
out(` ${c.dim('global install')} ${c.bold('npm install -g jaitus')}`);
|
|
93
|
+
out(` ${c.dim('no install')} ${c.bold('npx jaitus@latest')}`);
|
|
94
|
+
out('');
|
|
95
|
+
});
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ─── Where the CLI keeps things ─────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// One directory, `~/.jaitus`, holding credentials and settings. Deliberately NOT the
|
|
4
|
+
// shell profile and NOT the machine environment: a base URL or an auth token written into
|
|
5
|
+
// a user's profile silently reroutes every tool on the machine that reads those
|
|
6
|
+
// variables, including ones they never meant to point here. A tool that does that is a
|
|
7
|
+
// tool nobody can safely run twice.
|
|
8
|
+
//
|
|
9
|
+
// Credentials are written 0600. That is advisory on Windows, where the real protection
|
|
10
|
+
// is the per-user profile directory, but it costs nothing and is correct everywhere else.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
export const HOME = path.join(os.homedir(), '.jaitus');
|
|
18
|
+
const CREDENTIALS = path.join(HOME, 'credentials.json');
|
|
19
|
+
const SETTINGS = path.join(HOME, 'settings.json');
|
|
20
|
+
export const HISTORY = path.join(HOME, 'history');
|
|
21
|
+
export const SESSIONS = path.join(HOME, 'sessions');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The version, read from package.json rather than kept as a second copy in the source.
|
|
25
|
+
* Two places to edit is one place to forget, and a CLI reporting a version it is not is
|
|
26
|
+
* the kind of thing that wastes an afternoon of someone else's bug report.
|
|
27
|
+
*/
|
|
28
|
+
export function version() {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
31
|
+
return JSON.parse(fs.readFileSync(pkg, 'utf8')).version || '0.0.0';
|
|
32
|
+
} catch {
|
|
33
|
+
return '0.0.0';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_BASE_URL = 'https://api.jaitus.com';
|
|
38
|
+
export const DEFAULT_SITE_URL = 'https://www.jaitus.com';
|
|
39
|
+
|
|
40
|
+
function ensureHome() {
|
|
41
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readJson(file, fallback) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeJson(file, data, mode = 0o600) {
|
|
53
|
+
ensureHome();
|
|
54
|
+
// Written to a temp file and renamed, so an interrupted write cannot leave a
|
|
55
|
+
// half-parsed credentials file behind that makes every later command fail.
|
|
56
|
+
const tmp = `${file}.tmp`;
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode });
|
|
58
|
+
fs.renameSync(tmp, file);
|
|
59
|
+
try { fs.chmodSync(file, mode); } catch { /* best effort on Windows */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadCredentials() {
|
|
63
|
+
return readJson(CREDENTIALS, null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function saveCredentials(creds) {
|
|
67
|
+
writeJson(CREDENTIALS, creds, 0o600);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearCredentials() {
|
|
71
|
+
try { fs.rmSync(CREDENTIALS); return true; } catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function loadSettings() {
|
|
75
|
+
return readJson(SETTINGS, {});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function saveSettings(settings) {
|
|
79
|
+
writeJson(SETTINGS, settings, 0o644);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Effective endpoints. Environment wins so a developer can point the CLI at a local
|
|
84
|
+
* backend for one command without editing anything — the deliberate exception to the
|
|
85
|
+
* "never read env for config" rule, because here it IS per-invocation by design.
|
|
86
|
+
*/
|
|
87
|
+
export function endpoints() {
|
|
88
|
+
const s = loadSettings();
|
|
89
|
+
return {
|
|
90
|
+
baseUrl: (process.env.JAITUS_BASE_URL || s.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
91
|
+
siteUrl: (process.env.JAITUS_SITE_URL || s.siteUrl || DEFAULT_SITE_URL).replace(/\/+$/, ''),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Tokens are read and renewed by session.js, the only code that touches them.
|