@axplusb/kepler 1.0.10 → 2.0.2

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.
@@ -8,6 +8,35 @@ import { buildProjectSkeleton } from '../context/skeleton.mjs';
8
8
  import { indexDir as getIndexDir } from '../core/paths.mjs';
9
9
 
10
10
  const RESOURCE_FILE = 'project-resource.json';
11
+
12
+ /**
13
+ * Expand "~" and trim surrounding quotes/whitespace. Does NOT unescape shell
14
+ * meta characters — that is a separate, last-resort step done only if the
15
+ * literal path does not resolve.
16
+ */
17
+ function normalizePathInput(p) {
18
+ let s = String(p || '').trim();
19
+ // Trim balanced surrounding quotes.
20
+ if ((s.startsWith('"') && s.endsWith('"')) ||
21
+ (s.startsWith("'") && s.endsWith("'"))) {
22
+ s = s.slice(1, -1);
23
+ }
24
+ // Tilde expansion (~ or ~/...).
25
+ if (s === '~' || s.startsWith('~/')) {
26
+ s = path.join(os.homedir(), s.slice(1));
27
+ }
28
+ return s;
29
+ }
30
+
31
+ /**
32
+ * Replace common shell escape sequences with their literal characters. Used
33
+ * as a fallback when the literal path does not resolve — the agent may have
34
+ * pasted a copy of what they would type at a shell prompt.
35
+ */
36
+ function unescapeShellPath(p) {
37
+ return String(p || '').replace(/\\([ \t()&$;'"])/g, '$1');
38
+ }
39
+
11
40
  const LANGUAGE_EXTENSIONS = new Map([
12
41
  ['.py', 'Python'],
13
42
  ['.js', 'JavaScript'],
@@ -280,6 +309,12 @@ export class ProjectRegistry {
280
309
  if (!rawPath) {
281
310
  throw new Error('get_project_overview requires a project path');
282
311
  }
312
+
313
+ // LLM sometimes passes shell-escaped paths ("Tarang\ Orca") or paths
314
+ // beginning with "~". Normalize defensively so the tool does not bounce
315
+ // back a "not found" error on a path that's correct apart from quoting.
316
+ rawPath = normalizePathInput(rawPath);
317
+
283
318
  if (!path.isAbsolute(rawPath)) {
284
319
  rawPath = path.resolve(process.cwd(), rawPath);
285
320
  }
@@ -288,7 +323,15 @@ export class ProjectRegistry {
288
323
  try {
289
324
  root = fs.realpathSync(rawPath);
290
325
  } catch {
291
- throw new Error(`Project path not found: ${rawPath}`);
326
+ // Try the unescaped variant explicitly so the error message can
327
+ // tell the agent what it actually attempted.
328
+ const unescaped = unescapeShellPath(rawPath);
329
+ if (unescaped !== rawPath) {
330
+ try { root = fs.realpathSync(unescaped); }
331
+ catch { throw new Error(`Project path not found: ${rawPath} (also tried ${unescaped})`); }
332
+ } else {
333
+ throw new Error(`Project path not found: ${rawPath}`);
334
+ }
292
335
  }
293
336
  if (!fs.statSync(root).isDirectory()) {
294
337
  throw new Error(`Project path is not a directory: ${root}`);
@@ -377,25 +420,64 @@ export class ProjectRegistry {
377
420
  if (!rawPath) {
378
421
  if (root) return root;
379
422
  if (this.projects.size === 1) return this.resources()[0].root;
380
- throw new Error('Path requires project_id when multiple or no projects are registered');
423
+ // Fall back to the first registered project when the model omits
424
+ // both path and project_id. Beats throwing on an inferable case.
425
+ const first = this.resources()[0];
426
+ if (first) return first.root;
427
+ throw new Error('No projects registered. Call get_project_overview first.');
381
428
  }
382
429
 
383
- let candidate;
384
- if (path.isAbsolute(rawPath)) {
385
- candidate = canonicalizeCandidate(path.resolve(rawPath));
386
- } else {
430
+ // LLM frequently passes shell-quoted paths copied from a terminal,
431
+ // e.g. "Tarang\ Orca/src/app/\(kepler\)/page.tsx". Normalize here so
432
+ // every tool benefits, not just get_project_overview.
433
+ rawPath = normalizePathInput(rawPath);
434
+
435
+ const buildCandidate = (input) => {
436
+ if (path.isAbsolute(input)) {
437
+ return canonicalizeCandidate(path.resolve(input));
438
+ }
387
439
  if (!root) {
388
- if (this.projects.size !== 1) {
389
- throw new Error('Relative path requires project_id when multiple or no projects are registered');
440
+ if (this.projects.size === 1) {
441
+ return canonicalizeCandidate(path.resolve(this.resources()[0].root, input));
390
442
  }
391
- root = this.resources()[0].root;
443
+ if (this.projects.size > 1) {
444
+ throw new Error('Relative path requires project_id when multiple projects are registered. Pass project_id or use an absolute path.');
445
+ }
446
+ throw new Error('No projects registered. Call get_project_overview first.');
392
447
  }
393
- candidate = canonicalizeCandidate(path.resolve(root, rawPath));
394
- }
448
+ return canonicalizeCandidate(path.resolve(root, input));
449
+ };
395
450
 
396
- const containingProject = [...this.projects.values()].find(({ resource }) =>
397
- isWithin(resource.root, candidate)
451
+ let candidate = buildCandidate(rawPath);
452
+
453
+ const findContaining = (cand) => [...this.projects.values()].find(({ resource }) =>
454
+ isWithin(resource.root, cand)
398
455
  );
456
+
457
+ let containingProject = findContaining(candidate);
458
+
459
+ // Two reasons to try the unescaped variant:
460
+ // (1) candidate is outside every project root (literal "Tarang\ Orca"
461
+ // does not contain a real project), or
462
+ // (2) candidate is inside a root but does not exist on disk because
463
+ // a path segment like "\(kepler\)" only resolves once unescaped.
464
+ // We retry once on the unescaped form before raising.
465
+ const needsRetry = !containingProject ||
466
+ (!allowMissing && !fs.existsSync(candidate));
467
+ if (needsRetry) {
468
+ const unescaped = unescapeShellPath(rawPath);
469
+ if (unescaped !== rawPath) {
470
+ try {
471
+ const altCandidate = buildCandidate(unescaped);
472
+ const altProject = findContaining(altCandidate);
473
+ if (altProject && (allowMissing || fs.existsSync(altCandidate))) {
474
+ candidate = altCandidate;
475
+ containingProject = altProject;
476
+ }
477
+ } catch { /* fall through to the original error */ }
478
+ }
479
+ }
480
+
399
481
  if (!containingProject) {
400
482
  throw new Error(`Path is outside registered project roots: ${rawPath}`);
401
483
  }
@@ -406,10 +488,21 @@ export class ProjectRegistry {
406
488
  }
407
489
 
408
490
  projectForPath(filePath) {
409
- const candidate = canonicalizeCandidate(path.resolve(filePath));
410
- return [...this.projects.values()].find(({ resource }) =>
491
+ const normalized = normalizePathInput(filePath);
492
+ const candidate = canonicalizeCandidate(path.resolve(normalized));
493
+ const direct = [...this.projects.values()].find(({ resource }) =>
411
494
  isWithin(resource.root, candidate)
412
- ) || null;
495
+ );
496
+ if (direct) return direct;
497
+ // Same unescape fallback used in resolvePath.
498
+ const unescaped = unescapeShellPath(normalized);
499
+ if (unescaped !== normalized) {
500
+ const altCandidate = canonicalizeCandidate(path.resolve(unescaped));
501
+ return [...this.projects.values()].find(({ resource }) =>
502
+ isWithin(resource.root, altCandidate)
503
+ ) || null;
504
+ }
505
+ return null;
413
506
  }
414
507
 
415
508
  reset() {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Approval prompt UI — Mission Control (PRD-055 §8.2).
3
+ *
4
+ * ┃ AWAITING APPROVAL ┃
5
+ * ┃ ┃
6
+ * ┃ ⚙️ shell "rm -rf node_modules" ┃
7
+ * ┃ ┃
8
+ * ┃ Tier: SHELL-DANGEROUS ┃
9
+ * ┃ Why: Recursive delete in project directory ┃
10
+ * ┃ ┃
11
+ * ┃ [Enter] approve [e] edit [r] re-plan [n] reject [?] why
12
+ *
13
+ * The border colour is `brand.accent` (magenta) for explicit-approval
14
+ * tiers; safe-default prompts use `brand.data` so they read as advisory.
15
+ *
16
+ * Pure — caller writes the returned string to stderr.
17
+ */
18
+
19
+ import { paint, width as visibleWidth } from './palette.mjs';
20
+ import { icon } from './icons.mjs';
21
+ import { toolDisplayLabel, toolDisplaySummary } from '../terminal/tool-display.mjs';
22
+ import { label as tierLabel, requiresExplicitApproval, TIERS } from '../core/risk-tier.mjs';
23
+
24
+ const VBAR = '┃';
25
+ const PAD_X = 3;
26
+
27
+ /**
28
+ * Default option set per tier. Caller can override via `opts.options`.
29
+ *
30
+ * Each option is `{ key, label, value, hint? }`:
31
+ * key — single-letter shortcut (case-insensitive)
32
+ * label — text shown in the menu
33
+ * value — return value from the menu loop
34
+ * hint — secondary description shown to the right of the label
35
+ */
36
+ export function defaultOptions(tier) {
37
+ if (requiresExplicitApproval(tier)) {
38
+ return [
39
+ { key: 'y', label: 'approve', value: 'approve', hint: 'run this once' },
40
+ { key: 'e', label: 'edit', value: 'edit', hint: 'tweak the args before running' },
41
+ { key: 'r', label: 're-plan', value: 'replan', hint: 'send back to the agent' },
42
+ { key: 'n', label: 'reject', value: 'reject', hint: 'do not run' },
43
+ { key: '?', label: 'why', value: 'why', hint: 'show reasoning' },
44
+ ];
45
+ }
46
+ return [
47
+ { key: 'y', label: 'approve', value: 'approve', hint: 'run this once' },
48
+ { key: 't', label: 'always allow', value: 'allow-type', hint: 'auto-approve future calls to this tool' },
49
+ { key: 'n', label: 'reject', value: 'reject', hint: 'do not run' },
50
+ { key: '?', label: 'why', value: 'why', hint: 'show reasoning' },
51
+ ];
52
+ }
53
+
54
+ /**
55
+ * Render the bordered prompt with vertical, arrow-navigable options.
56
+ *
57
+ * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
58
+ * ┃ AWAITING APPROVAL ┃
59
+ * ┃ ┃
60
+ * ┃ ⚙️ shell "rm -rf …" ┃
61
+ * ┃ ┃
62
+ * ┃ Tier: SHELL-DANGEROUS ┃
63
+ * ┃ Why: Recursive delete… ┃
64
+ * ┃ ┃
65
+ * ┃ ▸ [y] approve ┃ ← selected (accent)
66
+ * ┃ [e] edit ┃
67
+ * ┃ [r] re-plan ┃
68
+ * ┃ [n] reject ┃
69
+ * ┃ [?] why ┃
70
+ * ┃ ┃
71
+ * ┃ ↑↓ move · Enter pick ┃
72
+ * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
73
+ *
74
+ * @param {object} opts
75
+ * @param {string} opts.tool
76
+ * @param {object} opts.args
77
+ * @param {string} opts.tier
78
+ * @param {string} [opts.why]
79
+ * @param {number} [opts.width]
80
+ * @param {Array} [opts.options] — override default option set
81
+ * @param {number} [opts.selected] — index of the highlighted option
82
+ */
83
+ export function renderApprovalPrompt({
84
+ tool, args = {}, tier, why = '', width,
85
+ options, selected = 0,
86
+ } = {}) {
87
+ const cols = Math.max(60, Math.min(width || process.stderr.columns || 96, 120));
88
+ const explicit = requiresExplicitApproval(tier);
89
+ const accent = explicit ? paint.brand.accent : paint.brand.data;
90
+ const opts = options || defaultOptions(tier);
91
+
92
+ const summary = toolDisplaySummary(tool, args, {});
93
+ const toolLine = `${icon(tool)} ${paint.text.primary(toolDisplayLabel(tool))} ${paint.text.muted(summary ? `"${truncate(summary, cols - 20)}"` : '')}`;
94
+
95
+ const lines = [
96
+ bar(cols, accent),
97
+ fill(' AWAITING APPROVAL', cols, accent, paint.bold(accent('AWAITING APPROVAL'))),
98
+ fill('', cols, accent),
99
+ fill(' ' + ' '.repeat(PAD_X - 1) + toolLine, cols, accent),
100
+ fill('', cols, accent),
101
+ fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Tier: ') + accent(tierLabel(tier)), cols, accent),
102
+ ];
103
+ if (why) {
104
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Why: ') + paint.text.primary(truncate(why, cols - PAD_X - 12)), cols, accent));
105
+ }
106
+ lines.push(fill('', cols, accent));
107
+
108
+ for (let i = 0; i < opts.length; i++) {
109
+ const o = opts[i];
110
+ const isSel = i === selected;
111
+ const cursor = isSel ? accent('▸ ') : paint.text.dim(' ');
112
+ const keyTag = paint.text.dim('[') + (isSel ? accent(o.key) : paint.brand.data(o.key)) + paint.text.dim('] ');
113
+ const label = isSel ? paint.bold(accent(o.label)) : paint.text.primary(o.label);
114
+ const hint = o.hint ? ' ' + paint.text.muted(o.hint) : '';
115
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + cursor + keyTag + label + hint, cols, accent));
116
+ }
117
+
118
+ lines.push(fill('', cols, accent));
119
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) +
120
+ paint.text.dim('↑↓ ') + paint.brand.data('move') +
121
+ paint.text.dim(' · Enter ') + paint.brand.data('pick') +
122
+ paint.text.dim(' · or press a letter'), cols, accent));
123
+ lines.push(bar(cols, accent));
124
+
125
+ return '\n' + lines.join('\n');
126
+ }
127
+
128
+ function bar(width, painter) {
129
+ // Top / bottom rule: a magenta vertical-stack line spanning the full width.
130
+ return painter('▔'.repeat(width));
131
+ }
132
+
133
+ function fill(text, width, painter, override) {
134
+ const visible = override ?? text;
135
+ const pad = Math.max(0, width - 2 - visibleWidth(visible));
136
+ return painter(VBAR) + visible + ' '.repeat(pad) + painter(VBAR);
137
+ }
138
+
139
+ function truncate(text, n) {
140
+ const s = String(text || '');
141
+ if (s.length <= n) return s;
142
+ return s.slice(0, Math.max(0, n - 1)) + '…';
143
+ }
144
+
145
+ // ── Inline (safe-default) prompt for shell-medium / network ────────────
146
+
147
+ /**
148
+ * Render the compact one-line prompt used for safe-default tiers.
149
+ * The bordered block is reserved for explicit-approval tiers.
150
+ *
151
+ * ? Run `npm install lodash`? Tier: SHELL-MEDIUM [Enter=yes n=no ?=why]
152
+ */
153
+ export function renderInlinePrompt({ tool, args = {}, tier, why = '' } = {}) {
154
+ const summary = toolDisplaySummary(tool, args, {});
155
+ const label = toolDisplayLabel(tool);
156
+ const lines = [];
157
+ const head = `${paint.brand.data('?')} ${paint.text.primary(label)} ${paint.text.muted(summary ? `"${truncate(summary, 80)}"` : '')}`;
158
+ lines.push(' ' + head);
159
+ lines.push(' ' + paint.text.dim('Tier ') + paint.brand.data(tierLabel(tier)) +
160
+ (why ? ' ' + paint.text.dim('Why ') + paint.text.muted(truncate(why, 60)) : ''));
161
+ lines.push(' ' + paint.text.dim('[') + paint.brand.data('Enter') + paint.text.dim('=yes ') +
162
+ paint.brand.data('n') + paint.text.dim('=no ') +
163
+ paint.brand.data('?') + paint.text.dim('=why]'));
164
+ return '\n' + lines.join('\n');
165
+ }
166
+
167
+ export { TIERS };
package/src/ui/banner.mjs CHANGED
@@ -1,162 +1,173 @@
1
1
  /**
2
2
  * Banner & Branding — Kepler CLI startup display.
3
+ *
4
+ * Refreshed for Mission Control (PRD-055 §4.3). Uses the semantic palette
5
+ * (`paint.brand.*`) so the same banner renders correctly across truecolor,
6
+ * 256-color, ansi16, and monochrome terminals.
3
7
  */
4
8
 
5
9
  import { execSync } from 'node:child_process';
6
10
  import * as path from 'node:path';
7
11
 
8
- // ANSI color helpers
9
- const BOLD = '\x1b[1m';
10
- const DIM = '\x1b[2m';
11
- const RESET = '\x1b[0m';
12
- const GREEN = '\x1b[32m';
13
- const CYAN = '\x1b[36m';
14
- const YELLOW = '\x1b[33m';
15
- const RED = '\x1b[31m';
16
- const BLUE = '\x1b[34m';
17
- const BOLD_GREEN = '\x1b[1;32m';
18
- const BOLD_CYAN = '\x1b[1;36m';
12
+ import { icons } from './icons.mjs';
13
+ import { paint, strip } from './palette.mjs';
14
+ import { term } from './term.mjs';
15
+
16
+ const out = process.stderr;
17
+ const write = (s) => { try { out.write(s); } catch {} };
18
+
19
+ // ── Brand banner ─────────────────────────────────────────────────────────
20
+
21
+ const KEPLER_LETTERS = ['K', 'E', 'P', 'L', 'E', 'R'];
22
+
23
+ /**
24
+ * Render `KEPLER` letter-by-letter as a purple→magenta→cyan gradient.
25
+ * Each letter picks the appropriate brand token; the palette handles tier
26
+ * fallbacks transparently.
27
+ *
28
+ * Falls back to a single solid color in monochrome / ascii mode so we
29
+ * still see something distinctive on hostile terminals.
30
+ */
31
+ function gradientKepler() {
32
+ if (!term().color) return KEPLER_LETTERS.join(' · ');
33
+
34
+ // Three-stop gradient mapped onto six letters. Stop selection:
35
+ // 0,1 → primary 2,3 → accent 4,5 → data
36
+ const painters = [
37
+ paint.brand.primary, paint.brand.primary,
38
+ paint.brand.accent, paint.brand.accent,
39
+ paint.brand.data, paint.brand.data,
40
+ ];
41
+ return KEPLER_LETTERS.map((ch, i) => painters[i](ch)).join(paint.text.dim(' · '));
42
+ }
19
43
 
20
44
  /**
21
45
  * Print the branded orbital banner.
22
46
  */
23
47
  export function printBanner() {
24
- process.stderr.write('\n');
25
- process.stderr.write(`${DIM} ✦${RESET}\n`);
26
- process.stderr.write(`${DIM} ╭──────────────────────────╮${RESET}\n`);
27
- process.stderr.write(`${DIM} │${RESET} ${BOLD}${CYAN}K · E · P · L · E · R${RESET} ${DIM}│${RESET}\n`);
28
- process.stderr.write(`${DIM} ╰──────── ${YELLOW}◯${RESET}${DIM} ───────────────╯${RESET}\n`);
29
- process.stderr.write(`${DIM} ╱ ╲${RESET}\n`);
30
- process.stderr.write(`${DIM} the agentic os${RESET}\n`);
31
- process.stderr.write('\n');
48
+ const dim = paint.text.dim;
49
+ const brandMark = paint.brand.primary(icons.brand);
50
+ const orbit = paint.brand.accent(icons.orbit);
51
+
52
+ write('\n');
53
+ write(` ${brandMark}\n`);
54
+ write(` ${dim('╭───────────────────────────╮')}\n`);
55
+ write(` ${dim('│')} ${gradientKepler()} ${dim('│')}\n`);
56
+ write(` ${dim('╰────── ')}${orbit}${dim(' ─────────────────╯')}\n`);
57
+ write(` ${dim('╱ ╲')}\n`);
58
+ write(` ${dim('the agentic os')}\n`);
59
+ write('\n');
32
60
  }
33
61
 
62
+ // ── Project info bar ─────────────────────────────────────────────────────
63
+
64
+ const BAR_WIDTH = 60;
65
+
34
66
  /**
35
67
  * Print project info bar with version, project name, and git info.
36
- * @param {string} version
37
68
  */
38
69
  export function printProjectInfo(version) {
39
- const cwd = process.cwd();
40
- const projectName = path.basename(cwd);
41
- const gitInfo = getGitInfo(cwd);
42
-
43
- const sep = `${DIM}${RESET}`;
44
- let info = ` ${DIM}v${version}${RESET}${sep}${BOLD}${projectName}${RESET}`;
45
- if (gitInfo) {
46
- info += `${sep}${YELLOW}${gitInfo}${RESET}`;
47
- }
48
-
49
- // Draw a boxed info bar
50
- const barWidth = 60;
51
- const topBorder = `${BLUE}┌${'─'.repeat(barWidth)}┐${RESET}`;
52
- const bottomBorder = `${BLUE}└${'─'.repeat(barWidth)}┘${RESET}`;
53
-
54
- process.stderr.write(`${topBorder}\n`);
55
- process.stderr.write(`${BLUE}│${RESET} ${info}${' '.repeat(Math.max(0, barWidth - stripAnsi(info).length - 1))}${BLUE}│${RESET}\n`);
56
- process.stderr.write(`${bottomBorder}\n`);
70
+ const cwd = process.cwd();
71
+ const projectName = path.basename(cwd);
72
+ const gitInfo = getGitInfo(cwd);
73
+
74
+ const sep = paint.text.dim('');
75
+ let info = ` ${paint.text.dim('v' + version)}${sep}${paint.bold(projectName)}`;
76
+ if (gitInfo) {
77
+ info += `${sep}${paint.state.warn(gitInfo)}`;
78
+ }
79
+
80
+ const padCount = Math.max(0, BAR_WIDTH - strip(info).length - 1);
81
+ const border = paint.brand.primary;
82
+
83
+ write(`${border('┌' + '─'.repeat(BAR_WIDTH) + '┐')}\n`);
84
+ write(`${border('│')} ${info}${' '.repeat(padCount)}${border('│')}\n`);
85
+ write(`${border('└' + '─'.repeat(BAR_WIDTH) + '┘')}\n`);
57
86
  }
58
87
 
59
- /**
60
- * Print keyboard hints and usage tips.
61
- */
62
- export function printHints() {
63
- const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
88
+ // ── Hints ────────────────────────────────────────────────────────────────
64
89
 
65
- process.stderr.write(`${GREEN}Type your instructions${RESET}, or ${CYAN}/help${RESET} for commands\n`);
66
- process.stderr.write(`${DIM}Ctrl+C${RESET}${DIM}=exit ${RESET}${DIM}/clear${RESET}${DIM}=reset ${RESET}${DIM}/config${RESET}${DIM}=settings ${RESET}${DIM}/login${RESET}${DIM}=auth${RESET}\n`);
67
- process.stderr.write(`${DIM}env:${env} models:configured via browser (/config)${RESET}\n`);
68
- process.stderr.write('\n');
90
+ export function printHints() {
91
+ const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
92
+ const dim = paint.text.dim;
93
+ const accent = paint.brand.data;
94
+
95
+ write(`${paint.state.success('Type your instructions')}, or ${accent('/help')} for commands\n`);
96
+ write(`${dim('Ctrl+C')}${dim('=exit ')}${dim('/clear')}${dim('=reset ')}${dim('/config')}${dim('=settings ')}${dim('/login')}${dim('=auth')}\n`);
97
+ write(`${dim('env:' + env + ' models:configured via browser (/config)')}\n`);
98
+ write('\n');
69
99
  }
70
100
 
71
- /**
72
- * Print auth status indicators.
73
- * @param {object} creds - { token, openRouterKey, anthropicKey, backendUrl, mode }
74
- */
75
- export function printAuthStatus(creds) {
76
- const check = `${GREEN}✓${RESET}`;
77
- const cross = `${RED}✗${RESET}`;
101
+ // ── Auth + config ────────────────────────────────────────────────────────
78
102
 
79
- const tokenOk = !!creds.token;
80
- const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
81
-
82
- process.stderr.write(` Auth: ${tokenOk ? `${check} logged in ${DIM}(/whoami for details)${RESET}` : `${cross} not logged in ${DIM}(/login)${RESET}`}\n`);
83
- process.stderr.write(` Env: ${DIM}${env}${RESET}\n`);
84
- process.stderr.write(` Mode: ${DIM}${creds.mode || 'auto'}${RESET}\n`);
85
- process.stderr.write('\n');
103
+ export function printAuthStatus(creds) {
104
+ const check = paint.state.success(icons.pass);
105
+ const cross = paint.state.danger(icons.fail);
106
+ const dim = paint.text.dim;
107
+
108
+ const tokenOk = !!creds.token;
109
+ const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
110
+
111
+ write(` Auth: ${tokenOk
112
+ ? `${check} logged in ${dim('(/whoami for details)')}`
113
+ : `${cross} not logged in ${dim('(/login)')}`}\n`);
114
+ write(` Env: ${dim(env)}\n`);
115
+ write(` Mode: ${dim(creds.mode || 'auto')}\n`);
116
+ write('\n');
86
117
  }
87
118
 
88
- /**
89
- * Print config in a styled format.
90
- * @param {object} creds
91
- */
92
119
  export function printStyledConfig(creds) {
93
- const check = `${GREEN}✓${RESET}`;
94
- const cross = `${RED}✗${RESET}`;
95
- const mask = (val) => {
96
- if (!val) return `${cross} not set`;
97
- if (val.length <= 8) return `${check} ****`;
98
- return `${check} ${val.slice(0, 6)}...${val.slice(-4)}`;
99
- };
100
-
101
- process.stderr.write(`\n${BOLD}Kepler Configuration${RESET} ${DIM}(~/.kepler/config.json)${RESET}\n`);
102
- process.stderr.write(`${'─'.repeat(50)}\n`);
103
- process.stderr.write(` Token: ${mask(creds.token)}\n`);
104
- process.stderr.write(` OpenRouter: ${mask(creds.openRouterKey)}\n`);
105
- process.stderr.write(` Anthropic: ${mask(creds.anthropicKey)}\n`);
106
- const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
107
- process.stderr.write(` Environment: ${DIM}${env}${RESET}\n`);
108
- process.stderr.write(` Backend URL: ${DIM}${creds.backendUrl}${RESET}\n`);
109
- process.stderr.write(` Mode: ${DIM}${creds.mode || 'auto'}${RESET}\n`);
110
- process.stderr.write('\n');
120
+ const check = paint.state.success(icons.pass);
121
+ const cross = paint.state.danger(icons.fail);
122
+ const dim = paint.text.dim;
123
+
124
+ const mask = (val) => {
125
+ if (!val) return `${cross} not set`;
126
+ if (val.length <= 8) return `${check} ****`;
127
+ return `${check} ${val.slice(0, 6)}...${val.slice(-4)}`;
128
+ };
129
+
130
+ const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
131
+
132
+ write(`\n${paint.bold('Kepler Configuration')} ${dim('(~/.kepler/config.json)')}\n`);
133
+ write(`${dim(''.repeat(50))}\n`);
134
+ write(` Token: ${mask(creds.token)}\n`);
135
+ write(` OpenRouter: ${mask(creds.openRouterKey)}\n`);
136
+ write(` Anthropic: ${mask(creds.anthropicKey)}\n`);
137
+ write(` Environment: ${dim(env)}\n`);
138
+ write(` Backend URL: ${dim(creds.backendUrl)}\n`);
139
+ write(` Mode: ${dim(creds.mode || 'auto')}\n`);
140
+ write('\n');
111
141
  }
112
142
 
113
- /**
114
- * Print a goodbye message.
115
- */
116
143
  export function printGoodbye() {
117
- process.stderr.write(`\n${BOLD_CYAN}Goodbye!${RESET}\n\n`);
144
+ write(`\n${paint.bold(paint.brand.primary('Goodbye!'))}\n\n`);
118
145
  }
119
146
 
120
- /**
121
- * Get git branch and change count for current directory.
122
- * @param {string} cwd
123
- * @returns {string|null}
124
- */
125
- function getGitInfo(cwd) {
126
- try {
127
- const branch = execSync('git branch --show-current', {
128
- cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
129
- }).trim();
130
-
131
- if (!branch) return null;
132
-
133
- const status = execSync('git status --porcelain', {
134
- cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
135
- }).trim();
147
+ // ── Git probe ────────────────────────────────────────────────────────────
136
148
 
137
- const changes = status ? status.split('\n').filter(Boolean).length : 0;
138
-
139
- if (changes > 0) {
140
- return `\u2387 ${branch} (${changes} changed)`;
141
- }
142
- return `\u2387 ${branch}`;
143
- } catch {
144
- return null;
145
- }
149
+ function getGitInfo(cwd) {
150
+ try {
151
+ const branch = execSync('git branch --show-current', {
152
+ cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
153
+ }).trim();
154
+ if (!branch) return null;
155
+
156
+ const status = execSync('git status --porcelain', {
157
+ cwd, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
158
+ }).trim();
159
+ const changes = status ? status.split('\n').filter(Boolean).length : 0;
160
+
161
+ return changes > 0 ? `⎇ ${branch} (${changes} changed)` : `⎇ ${branch}`;
162
+ } catch {
163
+ return null;
164
+ }
146
165
  }
147
166
 
148
- /**
149
- * Strip ANSI escape codes for length calculation.
150
- */
151
- function stripAnsi(str) {
152
- return str.replace(/\x1b\[[0-9;]*m/g, '');
153
- }
167
+ // ── OAuth success page (unchanged from prior version) ────────────────────
154
168
 
155
- /**
156
- * Branded HTML for OAuth success page.
157
- */
158
169
  export function getLoginSuccessHTML() {
159
- return `<!DOCTYPE html>
170
+ return `<!DOCTYPE html>
160
171
  <html>
161
172
  <head>
162
173
  <meta charset="utf-8">