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.
Files changed (55) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +248 -0
  3. package/bin/jaitus.js +60 -0
  4. package/package.json +46 -0
  5. package/src/agent/loop.js +548 -0
  6. package/src/agent/permissions.js +218 -0
  7. package/src/agent/rules.js +190 -0
  8. package/src/agent/tools.js +923 -0
  9. package/src/agent/websearch.js +522 -0
  10. package/src/api.js +72 -0
  11. package/src/background.js +85 -0
  12. package/src/brand.js +232 -0
  13. package/src/commands/code-review.js +8 -0
  14. package/src/commands/commit.js +73 -0
  15. package/src/commands/completion.js +68 -0
  16. package/src/commands/doctor.js +185 -0
  17. package/src/commands/explain.js +33 -0
  18. package/src/commands/init.js +62 -0
  19. package/src/commands/login.js +201 -0
  20. package/src/commands/logout.js +25 -0
  21. package/src/commands/research.js +60 -0
  22. package/src/commands/review.js +80 -0
  23. package/src/commands/security-review.js +8 -0
  24. package/src/commands/simplify.js +8 -0
  25. package/src/commands/test.js +68 -0
  26. package/src/commands/update.js +97 -0
  27. package/src/config.js +95 -0
  28. package/src/diff.js +129 -0
  29. package/src/errors.js +101 -0
  30. package/src/hooks.js +55 -0
  31. package/src/index.js +2503 -0
  32. package/src/ledger.js +73 -0
  33. package/src/logo-data.js +33 -0
  34. package/src/mcp.js +160 -0
  35. package/src/open.js +50 -0
  36. package/src/project.js +0 -0
  37. package/src/rewind.js +130 -0
  38. package/src/session.js +207 -0
  39. package/src/sessions.js +179 -0
  40. package/src/sixel.js +131 -0
  41. package/src/skills.js +62 -0
  42. package/src/term-sim.js +103 -0
  43. package/src/terminal.js +90 -0
  44. package/src/tui/ansi.js +287 -0
  45. package/src/tui/entries.js +366 -0
  46. package/src/tui/highlight.js +211 -0
  47. package/src/tui/logo.js +93 -0
  48. package/src/tui/markdown.js +280 -0
  49. package/src/tui/mouse.js +125 -0
  50. package/src/tui/theme.js +201 -0
  51. package/src/tui/widgets.js +226 -0
  52. package/src/tui/wordmark.js +135 -0
  53. package/src/tui-ink.js +1927 -0
  54. package/src/tui.js +2555 -0
  55. package/src/ui.js +562 -0
package/src/brand.js ADDED
@@ -0,0 +1,232 @@
1
+ // ─── Jaitus terminal identity ─────────────────────────────────────────────────
2
+ // A coding session should look like a tool, not a launch screen. The product mark is
3
+ // intentionally one compact coral token beside the Jaitus name; the web logo remains
4
+ // the right place for the detailed mark, while a terminal needs recognition without
5
+ // stealing the first screen from the work.
6
+
7
+ import { c, columns, colorLevel, unicode, visibleWidth } from './ui.js';
8
+
9
+ export function badge() {
10
+ // JAITUS_ASCII forces plain text so the mark never renders as tofu.
11
+ if (process.env.JAITUS_ASCII) return '[J]';
12
+ const level = colorLevel();
13
+ if (level >= 3) return '\x1b[48;2;255;107;74m\x1b[97m J \x1b[0m';
14
+ if (level >= 2) return '\x1b[48;5;208m\x1b[97m J \x1b[0m';
15
+ if (level === 1) return '\x1b[41m\x1b[97m J \x1b[0m';
16
+ return '[J]';
17
+ }
18
+
19
+ export function wordmark(version = '') {
20
+ return `${badge()} ${c.brand(c.bold('JAITUS'))}${version ? ` ${c.dim(`v${version}`)}` : ''}`;
21
+ }
22
+
23
+ // The Jaitus J alone as mascot art — no disc, no block. Sampled once from
24
+ // the checked-in logo pixels (white-J bbox) into quadrant cells: hard-edged
25
+ // vertices and corners, single brand ink, small and condensed.
26
+ const MASCOT_ART = [
27
+ "..████▌.",
28
+ "......▟█",
29
+ "██..▟█▛▀",
30
+ "▀█████▌.",
31
+ ];
32
+ // Full J for the banner showcase: same geometry, full height.
33
+ const MARK_LARGE = [
34
+ "...▐███████...",
35
+ "...▐███████...",
36
+ "...▝██████▛...",
37
+ "..........▐███",
38
+ "..........▐███",
39
+ "▄▄▄▖...▄▄▄▟▀▀▀",
40
+ "███▌...████...",
41
+ "███▌...████...",
42
+ "▜██▙▄▄▄████...",
43
+ ".▀█████████...",
44
+ "..▝████████...",
45
+ ];
46
+ /**
47
+ * The mascot J as raw rows, for the app view to ink itself.
48
+ *
49
+ * Deliberately used in preference to downsampling the real badge at small sizes. A
50
+ * 125×125 photograph reduced to eight cells is a coral blob — the white J inside it
51
+ * lands on about two pixels and disappears. These quadrant cells were drawn for this
52
+ * size, so the letterform survives. The photographic mark is for somewhere it has room.
53
+ */
54
+ export function MASCOT_ROWS() {
55
+ return MASCOT_ART.map((row) => row.replace(/\./g, ' '));
56
+ }
57
+
58
+ /** Mascot rows in the brand ink (pad to equal width for text lockups).
59
+ * Dots in the grid are transparent cells, never printed. */
60
+ export function renderMascot() {
61
+ const clean = MASCOT_ART.map((r) => r.replace(/\./g, ' '));
62
+ const width = Math.max(...clean.map((r) => [...r].length));
63
+ return clean.map((r) => ` ${c.brand(r + ' '.repeat(Math.max(0, width - [...r].length)))}`);
64
+ }
65
+ /** Full-height mark for `banner`: the complete J geometry, same ink. */
66
+ export function renderMarkLarge() {
67
+ const clean = MARK_LARGE.map((r) => r.replace(/\./g, ' '));
68
+ const width = Math.max(...clean.map((r) => [...r].length));
69
+ return clean.map((r) => ` ${c.brand(r + ' '.repeat(Math.max(0, width - [...r].length)))}`);
70
+ }
71
+ // The JAITUS name as pure-ASCII block letters for terminals that render
72
+ // nothing else (classic conhost raster fonts). 7 rows, 45 columns, only `#`
73
+ // and spaces — the grand opening where block pixels would be tofu.
74
+ const ASCII_GRAND = [
75
+ ' ### ### ##### ####### ## ## ######',
76
+ ' ### ## ## ##### ####### ## ## ##',
77
+ ' ### ## ## ##### ### ## ## ##',
78
+ ' ### ####### ##### ### ## ## ######',
79
+ ' ## ### ## ## ##### ### ## ## ##',
80
+ ' ## ### ## ## ##### ### ## ## ##',
81
+ ' ###### ## ## ##### ### ##### ######',
82
+ ];
83
+ /** ASCII grand logo rows (unstyled plain text — color never reaches these terminals). */
84
+ export function renderAsciiGrand() {
85
+ return ASCII_GRAND.map((r) => ` ${r.replace(/\s+$/, '')}`);
86
+ }
87
+ // Fresh-session home mark in the opencode idiom: the name as 4-row block
88
+ // letters, left half muted, right half bright, centered — the whole welcome.
89
+ // ASCII terminals get the plain wordmark line instead of tofu.
90
+ const HOME_LOGO_LEFT = [
91
+ ' █ ▀▀ █ ',
92
+ ' █ █ █ ',
93
+ ' █ █▀▀█ █ ',
94
+ '▀▀▀ ▀ █ █ ',
95
+ ];
96
+ const HOME_LOGO_RIGHT = [
97
+ ' █ █ █ ▀▀ ',
98
+ '███ █ █ █ ',
99
+ ' █ █ █ ▀▀█',
100
+ ' █ ▀▀▀▀ ▀▀█',
101
+ ];
102
+ /**
103
+ * The same block letters as data, for the app view.
104
+ *
105
+ * The app renders them through a real per-character gradient, which cannot be done
106
+ * with a pre-coloured string: the escape codes would have to be shredded mid-frame.
107
+ * Kept here beside the art so the two surfaces can never drift apart.
108
+ */
109
+ export function HOME_LOGO_ROWS() {
110
+ return HOME_LOGO_LEFT.map((left, i) => ({ left, right: HOME_LOGO_RIGHT[i] }));
111
+ }
112
+
113
+ /** Centered two-tone JAITUS block rows for a fresh fullscreen session.
114
+ * Both halves wear the brand coral (plain left, bold right) — the opencode
115
+ * idiom in Jaitus ink. A full reset closes each row so no attribute leaks. */
116
+ export function renderHomeLogo({ version = '', width = 80 } = {}) {
117
+ if (!unicode() || width < 40) {
118
+ const line = `JAITUS${version ? ` v${version}` : ''}`;
119
+ const pad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
120
+ return [`${' '.repeat(pad)}${c.bold(line)}`];
121
+ }
122
+ const pad = Math.max(0, Math.floor((width - 29) / 2));
123
+ const end = colorLevel() > 0 ? '\x1b[0m' : '';
124
+ return HOME_LOGO_LEFT.map((l, i) => `${' '.repeat(pad)}${c.brand(l)} ${c.bold(c.brand(HOME_LOGO_RIGHT[i]))}${end}`);
125
+ }
126
+ /** Rotating startup tips — every one names a real command. */
127
+ const STARTUP_TIPS = [
128
+ '/research digs up sources with citations',
129
+ '/code-review --fix ships cleaner code',
130
+ '/rewind un-breaks the timeline',
131
+ '/btw asks off the record',
132
+ '/branch tries the other way',
133
+ '/test --fix repairs failures',
134
+ '/explain reads the code for you',
135
+ '/commit writes the message',
136
+ ];
137
+ export function startupTip() {
138
+ const day = Math.floor(Date.now() / 86400000);
139
+ return STARTUP_TIPS[day % STARTUP_TIPS.length];
140
+ }
141
+ /** Compact classic open: codex-style session header. Lowercase wordmark,
142
+ * model, approval posture, directory, account — one block, pure ASCII,
143
+ * instant. Returns plain-text lines. */
144
+ export function renderClassicOpen({ version, model, approval, cwd, account, session, narrow }) {
145
+ const meta = [
146
+ `jaitus v${version}`,
147
+ `model ${model}`,
148
+ `approval ${approval}`,
149
+ `directory ${cwd}`,
150
+ ...(account ? [`account ${account} - session ${session}`] : [`session ${session}`]),
151
+ ];
152
+ if (narrow) return [...meta.map((m) => ` ${m}`), ` Tip: ${startupTip()}`];
153
+ return [...meta.map((m) => ` ${m}`), ` Tip: ${startupTip()}`];
154
+ }
155
+ export function logoCard(version = '') {
156
+ if (process.env.JAITUS_ASCII || !unicode()) {
157
+ const inner = 40;
158
+ const bar = ` +${'-'.repeat(inner)}+`;
159
+ const row = (text) => ` |${text}${' '.repeat(Math.max(0, inner - visibleWidth(text)))}|`;
160
+ return [
161
+ bar,
162
+ row(` ${c.brand(c.bold('J A I T U S'))}`),
163
+ row(` a coding agent in your terminal ${c.dim(`v${version}`)}`),
164
+ bar,
165
+ ];
166
+ }
167
+ return [
168
+ ...renderMarkLarge(),
169
+ ` ${c.brand(c.bold('JAITUS'))} ${c.dim(`a coding agent in your terminal v${version}`)}`,
170
+ ];
171
+ }
172
+
173
+ export function bannerLines({ version = '', width = columns() } = {}) {
174
+ const suffix = width >= 44 ? ` ${c.dim('coding agent')}` : '';
175
+ return [` ${wordmark(version)}${suffix}`];
176
+ }
177
+
178
+ export async function banner(opts = {}) {
179
+ if (!process.stdout.isTTY && !opts.force) return;
180
+ if (process.env.JAITUS_NO_BANNER && !opts.force) return;
181
+ // `jaitus banner --image`: the README block-letter mark is ALWAYS printed,
182
+ // because a sixel-only path goes invisible on terminals that misreport the
183
+ // DA capability (seen on conhost: probe says yes, pixels never land, and the
184
+ // user sees the same quiet line as before). Real pixels go first when the
185
+ // probe positively confirms sixel, then the block letters guarantee ink.
186
+ if (opts.image) {
187
+ let sixelSeq = '';
188
+ try {
189
+ const { sixelSupported } = await import('./terminal.js');
190
+ if (sixelSupported()) {
191
+ const { markSixel } = await import('./sixel.js');
192
+ sixelSeq = markSixel({}) || '';
193
+ }
194
+ } catch { sixelSeq = ''; }
195
+ if (sixelSeq) process.stdout.write(`\n${sixelSeq}\n`);
196
+ for (const line of logoCard(opts.version || '')) process.stdout.write(`${line}\n`);
197
+ process.stdout.write('\n');
198
+ return;
199
+ }
200
+ // One-shot wordmark shimmer on interactive start: a light sweep across the
201
+ // brand row (~600 ms), once, only when the terminal animates. Skipped for
202
+ // --image, non-TTY, reduced motion, and the /banner re-draw (opts.force).
203
+ // The sweep paints per-character from the ANSI-stripped text — spreading the
204
+ // styled string would shred escape sequences mid-frame.
205
+ if (!opts.image && !opts.force && process.stdout.isTTY && !process.env.JAITUS_NO_ANIMATION && colorLevel() > 0 && unicode()) {
206
+ const styled = bannerLines(opts);
207
+ const plainText = (styled[0] || '').replace(/\x1b\[[0-9;]*m/g, '');
208
+ const chars = [...plainText];
209
+ const sweep = async () => {
210
+ for (let pos = -3; pos <= chars.length + 3; pos++) {
211
+ const frame = chars.map((ch, idx) => {
212
+ const d = Math.abs(idx - pos);
213
+ if (d === 0) return c.bold(`\x1b[97m${ch}\x1b[0m`);
214
+ if (d <= 2) return c.shimmer(ch);
215
+ return c.brand(ch);
216
+ }).join('');
217
+ process.stdout.write(`\r${frame}`);
218
+ await new Promise((r) => setTimeout(r, 24));
219
+ }
220
+ process.stdout.write(`\r${styled.join('\n')}\n\n`);
221
+ };
222
+ try { await sweep(); } catch { process.stdout.write(`${styled.join('\n')}\n\n`); }
223
+ } else {
224
+ process.stdout.write(`${bannerLines(opts).join('\n')}\n\n`);
225
+ }
226
+ }
227
+
228
+ // Kept as a small pure helper for the classic renderer and external integrations.
229
+ // It is deliberately not a welcome card.
230
+ export function welcomeLines({ version = '' } = {}) {
231
+ return [` ${wordmark(version)}`];
232
+ }
@@ -0,0 +1,8 @@
1
+ // ─── /code-review — correctness bugs and cleanup, with --fix ────────────────────
2
+ //
3
+ // Usage: /code-review [--fix] [path|branch..range] (alias: /review)
4
+ import { runReview } from './review.js';
5
+
6
+ export default async function codeReview(args, ctx) {
7
+ return runReview('correctness', args, ctx);
8
+ }
@@ -0,0 +1,73 @@
1
+ // ─── /commit — review the diff, write a real message, land it ───────────────────
2
+ //
3
+ // Shows what changed, asks the model to summarise the WHY into a subject line,
4
+ // then commits. Pushing is always a separate confirmation — never implicit.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { runAgent } from '../agent/loop.js';
7
+ import { createPermissions } from '../agent/permissions.js';
8
+ import { c, out, ok, warn, fail, askKey, glyph } from '../ui.js';
9
+
10
+ function git(args, opts = {}) {
11
+ const r = spawnSync('git', args, { encoding: 'utf8', ...opts });
12
+ return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
13
+ }
14
+
15
+ export default async function commit(args, ctx) {
16
+ if (!git(['rev-parse', '--is-inside-work-tree']).ok) {
17
+ fail('Not a git repository.');
18
+ return null;
19
+ }
20
+ const status = git(['status', '--porcelain']);
21
+ if (!status.out) { ok('Nothing to commit — the working tree is clean.'); return null; }
22
+
23
+ // What the commit would contain, shown before anything happens.
24
+ const stat = git(['diff', '--stat', 'HEAD']).out || '';
25
+ if (stat) {
26
+ out(c.dim(' Changes:'));
27
+ for (const line of stat.split('\n').slice(0, 20)) out(` ${c.dim(line)}`);
28
+ const n = stat.split('\n').length - 20;
29
+ if (n > 0) out(c.dim(` ${glyph('…', '...')} and ${n} more files`));
30
+ }
31
+
32
+ // The model writes the message from the actual diff — with a firm brevity
33
+ // leash, because left alone every model writes essays.
34
+ let subject = '';
35
+ try {
36
+ const diff = git(['diff', 'HEAD']).out.slice(0, 12000);
37
+ const side = await runAgent({
38
+ client: ctx.client, cwd: ctx.flags.cwd,
39
+ permissions: createPermissions({ mode: 'plan' }),
40
+ messages: [{ role: 'user', content: `Write a git commit subject for this diff. Rules: imperative mood, no prefix, max 72 chars, say WHAT changed and WHY. Answer with the subject line ONLY — no quotes, no body, no markdown.\n\n${diff}` }],
41
+ });
42
+ const text = (side?.messages || []).filter((m) => m.role === 'assistant').pop();
43
+ const content = Array.isArray(text?.content) ? text.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ') : (text?.content || '');
44
+ subject = content.trim().split('\n')[0].replace(/^["'`]|["'`]$/g, '').slice(0, 72);
45
+ } catch { /* fall through to the manual path */ }
46
+
47
+ const typed = subject || '';
48
+ out(` ${c.dim('Subject:')} ${c.bold(typed || '(write your own below)')}`);
49
+ const answer = askKey(' Commit with this message? (y)es / (e)dit / (n)o', ['y', 'e', 'n']);
50
+ if (answer === 'n') { warn('Commit cancelled.'); return null; }
51
+
52
+ let message = typed;
53
+ if (answer === 'e' || !typed) {
54
+ const editorMsg = git(['commit', '--dry-run', '--allow-empty'], {});
55
+ message = process.env.JAITUS_COMMIT_MSG || typed;
56
+ if (!message) { warn('No message — commit cancelled.'); return null; }
57
+ }
58
+
59
+ const add = git(['add', '-A']);
60
+ if (!add.ok) { fail(`Could not stage: ${add.err}`); return null; }
61
+ const done = git(['commit', '-m', message]);
62
+ if (!done.ok) { fail(`Commit failed: ${done.err || done.out}`); return null; }
63
+ ok(`Committed: ${message}`);
64
+
65
+ const push = askKey(' Push now? (y/N)', ['y', 'n']);
66
+ if (push === 'y') {
67
+ const branch = git(['branch', '--show-current']).out || 'main';
68
+ const pushed = git(['push', 'origin', branch]);
69
+ if (pushed.ok) ok(`Pushed to origin/${branch}.`);
70
+ else fail(`Push failed: ${pushed.err || pushed.out}`);
71
+ }
72
+ return null;
73
+ }
@@ -0,0 +1,68 @@
1
+ // ─── jaitus completion ──────────────────────────────────────────────────────────
2
+ //
3
+ // Prints a completion script for the shell you name. It prints rather than installs:
4
+ // writing to someone's .bashrc or their PowerShell profile is exactly the kind of
5
+ // reach-into-your-machine behaviour this CLI refuses to do anywhere else, and the
6
+ // one-line instruction below is no harder than a flag would have been.
7
+
8
+ import { c, out, err, glyph } from '../ui.js';
9
+
10
+ const SHELLS = ['bash', 'zsh', 'fish', 'powershell'];
11
+
12
+ const script = {
13
+ bash: (words, flags) => `# jaitus completion for bash
14
+ # Add to ~/.bashrc: eval "$(jaitus completion bash)"
15
+ _jaitus_completions() {
16
+ local cur prev
17
+ cur="\${COMP_WORDS[COMP_CWORD]}"
18
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
19
+ if [ "$COMP_CWORD" -eq 1 ]; then
20
+ COMPREPLY=( $(compgen -W "${words}" -- "$cur") )
21
+ else
22
+ COMPREPLY=( $(compgen -W "${flags}" -- "$cur") )
23
+ fi
24
+ }
25
+ complete -F _jaitus_completions jaitus`,
26
+
27
+ zsh: (words, flags) => `# jaitus completion for zsh
28
+ # Add to ~/.zshrc: eval "$(jaitus completion zsh)"
29
+ _jaitus() {
30
+ if (( CURRENT == 2 )); then
31
+ compadd ${words}
32
+ else
33
+ compadd ${flags}
34
+ fi
35
+ }
36
+ compdef _jaitus jaitus`,
37
+
38
+ fish: (words, flags) => `# jaitus completion for fish
39
+ # Save as: jaitus completion fish > ~/.config/fish/completions/jaitus.fish
40
+ complete -c jaitus -f
41
+ ${words.split(' ').map((w) => `complete -c jaitus -n "__fish_use_subcommand" -a "${w}"`).join('\n')}
42
+ ${flags.split(' ').map((f) => `complete -c jaitus -l "${f.replace(/^--/, '')}"`).join('\n')}`,
43
+
44
+ powershell: (words, flags) => `# jaitus completion for PowerShell
45
+ # Add to your profile: jaitus completion powershell | Out-String | Invoke-Expression
46
+ Register-ArgumentCompleter -Native -CommandName jaitus -ScriptBlock {
47
+ param($wordToComplete, $commandAst, $cursorPosition)
48
+ $commands = '${words}'.Split(' ')
49
+ $flags = '${flags}'.Split(' ')
50
+ $candidates = if ($commandAst.CommandElements.Count -le 2) { $commands + $flags } else { $flags }
51
+ $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
52
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
53
+ }
54
+ }`,
55
+ };
56
+
57
+ export default function completion(argv = [], { commands = [], flags = [] } = {}) {
58
+ const shell = (argv[0] || '').toLowerCase();
59
+ if (!script[shell]) {
60
+ err(`Usage: jaitus completion <${SHELLS.join('|')}>`);
61
+ if (shell) err(`${c.red(glyph('✗', 'x'))} Unknown shell: ${shell}`);
62
+ return 1;
63
+ }
64
+ out(script[shell](commands.join(' '), flags.join(' ')));
65
+ return 0;
66
+ }
67
+
68
+ export { SHELLS };
@@ -0,0 +1,185 @@
1
+ // ─── jaitus doctor ──────────────────────────────────────────────────────────────
2
+ //
3
+ // "It does not work" is the single most common bug report, and it covers at least six
4
+ // different faults: old Node, no sign-in, a revoked key, no network, a gateway that is
5
+ // down, a proxy in the way. This walks them in the order they fail and names the one
6
+ // that is actually broken, so the answer is a sentence instead of a conversation.
7
+ //
8
+ // It never prints a credential. A key that appears on screen is a key in the terminal
9
+ // scrollback, in any screenshot of it, and in whatever the user pastes into a support
10
+ // thread — so checks report presence and shape, never the value.
11
+
12
+ import fs from 'node:fs';
13
+ import { c, out, table, heading, present, colorLevel, glyph } from '../ui.js';
14
+ import { endpoints, loadCredentials, loadSettings, HOME, version } from '../config.js';
15
+ import { signedIn, accessToken } from '../session.js';
16
+ import { checkRegistry, isNewer } from './update.js';
17
+
18
+ const TIMEOUT_MS = 8000;
19
+
20
+ const PASS = 'pass';
21
+ const WARN = 'warn';
22
+ const FAIL = 'fail';
23
+
24
+ function mark(status) {
25
+ if (status === PASS) return c.green(glyph('✓', '+'));
26
+ if (status === WARN) return c.yellow('!');
27
+ return c.red(glyph('✗', 'x'));
28
+ }
29
+
30
+ /** fetch with a deadline, because a hung socket is indistinguishable from a hung CLI. */
31
+ async function reach(url, init = {}) {
32
+ const ctrl = new AbortController();
33
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
34
+ try {
35
+ return await fetch(url, { ...init, signal: ctrl.signal });
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ export default async function doctor(argv = [], { json = false } = {}) {
42
+ const checks = [];
43
+ const add = (name, status, detail, fix) => checks.push({ name, status, detail, fix });
44
+
45
+ // ── The machine ───────────────────────────────────────────────────────────────
46
+ const major = Number(process.versions.node.split('.')[0]);
47
+ add('Node', major >= 20 ? PASS : FAIL, `v${process.versions.node}`,
48
+ major >= 20 ? null : 'Install Node 20 or newer from nodejs.org.');
49
+
50
+ add('Jaitus CLI', PASS, `v${version()} on ${process.platform}-${process.arch}`);
51
+
52
+ // ── Where it keeps things ─────────────────────────────────────────────────────
53
+ let homeOk = false;
54
+ try {
55
+ fs.accessSync(HOME, fs.constants.W_OK);
56
+ homeOk = true;
57
+ } catch { /* reported below */ }
58
+ add('Config directory', homeOk ? PASS : WARN, HOME,
59
+ homeOk ? null : 'It will be created on first sign-in. If this keeps failing, check the directory is writable.');
60
+
61
+ // ── Credentials ───────────────────────────────────────────────────────────────
62
+ const creds = loadCredentials();
63
+ const fromEnv = Boolean(process.env.JAITUS_TOKEN);
64
+ let key = null;
65
+ if (!signedIn()) {
66
+ add('Sign-in', FAIL, 'not signed in', 'Run `jaitus login`.');
67
+ } else {
68
+ try {
69
+ // Renews the session if its hour is up — which is itself the check that the
70
+ // session is still alive on the server.
71
+ key = await accessToken();
72
+ // Who and where from only. Never a token, not even its first characters.
73
+ add('Sign-in', PASS, fromEnv ? 'JAITUS_TOKEN (not renewed)' : (creds?.user?.email || 'signed in'));
74
+ } catch (e) {
75
+ add('Sign-in', FAIL, e?.code === 'session_expired' ? 'this device was signed out' : 'could not renew the session',
76
+ 'Run `jaitus login` again.');
77
+ }
78
+ }
79
+
80
+ // ── The gateway ───────────────────────────────────────────────────────────────
81
+ const { baseUrl, siteUrl } = endpoints();
82
+ add('Gateway', PASS, baseUrl);
83
+
84
+ let reachable = false;
85
+ try {
86
+ const res = await reach(`${baseUrl}/v1/models`, {
87
+ headers: key ? { authorization: `Bearer ${key}` } : {},
88
+ });
89
+ reachable = true;
90
+ if (res.ok) {
91
+ const body = await res.json().catch(() => ({}));
92
+ const ids = (body?.data || []).map((m) => m.id);
93
+ add('Reachable', PASS, `HTTP ${res.status}`);
94
+ add('Models', ids.length ? PASS : WARN, ids.length ? ids.join(', ') : 'the gateway advertised none',
95
+ ids.length ? null : 'Nothing to run against. This is a gateway configuration problem, not a local one.');
96
+ } else if (res.status === 401 || res.status === 403) {
97
+ add('Reachable', PASS, `HTTP ${res.status}`);
98
+ add('Session accepted', FAIL, 'Jaitus did not accept this device\'s session',
99
+ 'Run `jaitus login` again — this device may have been signed out from the website.');
100
+ } else {
101
+ add('Reachable', WARN, `HTTP ${res.status}`, 'The gateway answered but not with a model list.');
102
+ }
103
+ } catch (e) {
104
+ add('Reachable', FAIL, describeNetworkError(e, baseUrl),
105
+ 'Check your connection, and whether a proxy or firewall is between you and the gateway.');
106
+ }
107
+
108
+ // ── The account ───────────────────────────────────────────────────────────────
109
+ if (key && reachable) {
110
+ try {
111
+ const res = await reach(`${baseUrl}/api/auth/me`, { headers: { 'x-api-key': key } });
112
+ const body = await res.json().catch(() => ({}));
113
+ const u = body.user || body.data || {};
114
+ if (res.ok && (u.email || u.id)) {
115
+ const plan = u.isPaid ? (u.planStatus?.name || 'paid') : `free · ${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} prompts used`;
116
+ add('Account', PASS, `${u.email || 'signed in'} (${plan})`);
117
+ } else {
118
+ add('Account', WARN, body?.message || `HTTP ${res.status}`,
119
+ 'The key works for the gateway but the account endpoint did not answer.');
120
+ }
121
+ } catch (e) {
122
+ add('Account', WARN, e.message);
123
+ }
124
+ }
125
+
126
+ // ── The terminal ──────────────────────────────────────────────────────────────
127
+ const settings = loadSettings();
128
+ add('Model', settings.model ? PASS : PASS,
129
+ settings.model || 'not pinned — whatever the gateway lists first');
130
+ add('Website', PASS, siteUrl);
131
+
132
+ // ── Is there a newer one ──────────────────────────────────────────────────────
133
+ try {
134
+ const { version: latest, status } = await checkRegistry();
135
+ if (status === 'unpublished') {
136
+ add('Updates', PASS, 'not published on this registry — nothing to check against');
137
+ } else if (!latest) {
138
+ add('Updates', WARN, 'could not reach the npm registry');
139
+ } else if (isNewer(latest, version())) {
140
+ add('Updates', WARN, `v${latest} is available`, 'Run `npm install -g jaitus` to update.');
141
+ } else {
142
+ add('Updates', PASS, 'up to date');
143
+ }
144
+ } catch {
145
+ add('Updates', WARN, 'could not reach the npm registry');
146
+ }
147
+
148
+ const failed = checks.filter((k) => k.status === FAIL);
149
+ const warned = checks.filter((k) => k.status === WARN);
150
+
151
+ present(json, { ok: failed.length === 0, checks }, () => {
152
+ heading('Diagnostics');
153
+ out('');
154
+ table(checks.map((k) => [` ${mark(k.status)}`, k.name, c.dim(k.detail || '')]));
155
+ // Labeled swatches: if brand and error reds are indistinguishable on this
156
+ // console, this row shows exactly which token renders wrong.
157
+ out('');
158
+ out(` ${c.dim('colors:')} level ${colorLevel()} · TERM=${process.env.TERM || '?'}${process.env.WT_SESSION ? ' · WT' : ''}${process.env.TERM_PROGRAM ? ` · ${process.env.TERM_PROGRAM}` : ''} · theme ${loadSettings().theme || 'auto'}`);
159
+ out(` ${c.dim('brand')} ${c.brand('[Ja]')} ${c.dim('accent')} ${c.accent('[Te]')} ${c.dim('ok')} ${c.green('[✓]')} ${c.dim('warn')} ${c.yellow('[!]')} ${c.dim('error')} ${c.red('[✗]')}`);
160
+ if (failed.length || warned.length) {
161
+ out('');
162
+ for (const k of [...failed, ...warned]) {
163
+ if (k.fix) out(` ${c.dim('→')} ${k.name}: ${k.fix}`);
164
+ }
165
+ }
166
+ out('');
167
+ out(failed.length
168
+ ? ` ${c.red(`${failed.length} problem${failed.length > 1 ? 's' : ''} to fix.`)}`
169
+ : ` ${c.green('Everything checks out.')}${warned.length ? c.dim(` ${warned.length} note${warned.length > 1 ? 's' : ''} above.`) : ''}`);
170
+ out('');
171
+ });
172
+
173
+ return failed.length ? 1 : 0;
174
+ }
175
+
176
+ function describeNetworkError(e, baseUrl) {
177
+ if (e.name === 'AbortError') return `no answer from ${baseUrl} within ${TIMEOUT_MS / 1000}s`;
178
+ const cause = e.cause?.code || e.code;
179
+ if (cause === 'ENOTFOUND') return `cannot resolve ${new URL(baseUrl).hostname}`;
180
+ if (cause === 'ECONNREFUSED') return `connection refused by ${baseUrl}`;
181
+ if (cause === 'CERT_HAS_EXPIRED' || cause === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
182
+ return `TLS certificate rejected (${cause}) — usually a proxy intercepting HTTPS`;
183
+ }
184
+ return e.message;
185
+ }
@@ -0,0 +1,33 @@
1
+ // ─── /explain — what does this code do, and why ─────────────────────────────────
2
+ //
3
+ // Read-only walkthrough of a file/selection, an error, or a concept grounded in
4
+ // the current project. Distinct from /agent: the brief is tuned for teaching,
5
+ // the output format is structured, and it never touches anything.
6
+ //
7
+ // Usage: /explain <file | error text | concept>
8
+ import { runAgent } from '../agent/loop.js';
9
+ import { createPermissions } from '../agent/permissions.js';
10
+ import { warn } from '../ui.js';
11
+
12
+ export default async function explain(args, ctx) {
13
+ const topic = args.join(' ').trim();
14
+ if (!topic) {
15
+ warn('Usage: /explain <file or symbol> | /explain <error message> | /explain <concept>');
16
+ return null;
17
+ }
18
+
19
+ const looksLikePath = /\.[a-z]{1,4}$/i.test(topic.split(' ')[0]) || topic.startsWith('./') || topic.startsWith('/');
20
+ const brief = looksLikePath
21
+ ? `Walk a developer through ${topic} in this project. Read the file(s) first. Structure: what it does (2 sentences), how it works (the flow, with file:line references), anything surprising or fragile worth knowing. Plain text, no headings beyond short bold labels.`
22
+ : /error|exception|fail|cannot|unable|undefined|null|crash/i.test(topic)
23
+ ? `Explain this error in the context of THIS project — read whatever files you need to find the real cause, not a generic guess. Structure: what the error means, the actual cause here (with file:line), the fix. Error: ${topic}`
24
+ : `Explain the concept "${topic}" grounded in this project where it applies — read files to find real usages rather than answering abstractly. Keep it under 250 words.`;
25
+
26
+ const sideMessages = [{ role: 'user', content: brief }];
27
+ const side = await runAgent({
28
+ client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
29
+ permissions: createPermissions({ mode: 'plan' }),
30
+ extraSystem: `You are the Jaitus explainer. Read-only: never edit files or run commands. Show file:line references for every claim about the code. Prefer short paragraphs over walls of text.`,
31
+ });
32
+ return null;
33
+ }
@@ -0,0 +1,62 @@
1
+ // ─── jaitus init ────────────────────────────────────────────────────────────────
2
+ //
3
+ // Writes the two project files the agent reads: JAITUS.md (notes it is given with every
4
+ // request) and .jaitusignore (paths it must not open). Both are checked into the repo on
5
+ // purpose — the point is that everyone working on the project gets the same agent
6
+ // behaviour, not that each person configures their own copy.
7
+ //
8
+ // It refuses to overwrite. A JAITUS.md someone spent an afternoon writing is not
9
+ // something a command called `init` gets to replace because it was run twice.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { c, out, ok, info, warn, fail } from '../ui.js';
14
+ import { CONTEXT_FILE, IGNORE_FILE, CONTEXT_TEMPLATE } from '../project.js';
15
+
16
+ const IGNORE_TEMPLATE = `# Paths the agent must not list, search, read or write.
17
+ # One pattern per line. A leading / anchors to the project root, a trailing / means a
18
+ # directory, * matches within a path segment and ** across them.
19
+
20
+ .env
21
+ .env.*
22
+ *.pem
23
+ *.key
24
+ secrets/
25
+ credentials.json
26
+ `;
27
+
28
+ export default function init(argv = [], { cwd = process.cwd() } = {}) {
29
+ const force = argv.includes('--force');
30
+ const written = [];
31
+ const kept = [];
32
+
33
+ const put = (name, contents) => {
34
+ const file = path.join(cwd, name);
35
+ if (fs.existsSync(file) && !force) {
36
+ kept.push(name);
37
+ return;
38
+ }
39
+ fs.writeFileSync(file, contents, 'utf8');
40
+ written.push(name);
41
+ };
42
+
43
+ try {
44
+ put(CONTEXT_FILE, CONTEXT_TEMPLATE);
45
+ put(IGNORE_FILE, IGNORE_TEMPLATE);
46
+ } catch (e) {
47
+ fail(`Could not write to ${cwd}: ${e.message}`);
48
+ return 1;
49
+ }
50
+
51
+ out('');
52
+ for (const name of written) ok(`Created ${c.bold(name)}`);
53
+ for (const name of kept) warn(`${c.bold(name)} already exists — left alone. Use --force to replace it.`);
54
+ out('');
55
+
56
+ if (written.includes(CONTEXT_FILE)) {
57
+ info(`Fill in ${c.bold(CONTEXT_FILE)} — it is sent with every request, so keep it short and true.`);
58
+ }
59
+ info(`Both files are meant to be committed, so the whole team gets the same behaviour.`);
60
+ out('');
61
+ return 0;
62
+ }