klyro 0.1.43 → 0.1.45
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/dist/cli/repl.js +1393 -36
- package/dist/cli/slash/parser.d.ts +241 -0
- package/dist/cli/slash/parser.js +243 -2
- package/dist/tui/app.js +25 -13
- package/package.json +1 -1
package/dist/cli/repl.js
CHANGED
|
@@ -17,6 +17,7 @@ import { buildLevel6Context } from '../context/level6.js';
|
|
|
17
17
|
import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
|
|
18
18
|
import { TuiApprovalBridge } from '../tui/approval.js';
|
|
19
19
|
import { parseUnifiedDiff } from '../tui/diff-parser.js';
|
|
20
|
+
import { parse } from './slash/parser.js';
|
|
20
21
|
import { resolveProvider, providerHelp } from '../providers.js';
|
|
21
22
|
import { inferProviderFromBaseURL } from '../agent/registry.js';
|
|
22
23
|
import { getDefaultSessionStore } from '../persistence/session.js';
|
|
@@ -40,7 +41,7 @@ export async function startRepl(opts = {}) {
|
|
|
40
41
|
const baseUrl = resolved.baseURL;
|
|
41
42
|
const apiKey = resolved.apiKey;
|
|
42
43
|
let model = opts.model ?? resolved.model;
|
|
43
|
-
|
|
44
|
+
let cwd = opts.cwd ?? process.cwd();
|
|
44
45
|
const registry = builtinRegistry();
|
|
45
46
|
const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
|
|
46
47
|
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
@@ -60,17 +61,17 @@ export async function startRepl(opts = {}) {
|
|
|
60
61
|
: httpChatAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 });
|
|
61
62
|
let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
|
|
62
63
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
63
|
-
|
|
64
|
-
// 4.4 KLYRO.md hierarchy
|
|
64
|
+
let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
65
|
+
// 4.4 KLYRO.md hierarchy (mutable — /reload refreshes)
|
|
65
66
|
const klyroMd = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
|
|
66
|
-
|
|
67
|
+
let klyroBlock = klyroMd ? `\n\n<KLYRO.md>\n${klyroMd.slice(0, 4000)}\n</KLYRO.md>` : '';
|
|
67
68
|
// 2.3 layered system prompt
|
|
68
69
|
const systemPromptFn = (_ctx) => {
|
|
69
70
|
const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix + klyroBlock });
|
|
70
71
|
const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
|
|
71
72
|
return base + t;
|
|
72
73
|
};
|
|
73
|
-
|
|
74
|
+
let ac = new AbortController();
|
|
74
75
|
// When the TUI is mounted, use the inline Ink prompt. Otherwise
|
|
75
76
|
// fall back to stdin readline. The bridge is shared between the
|
|
76
77
|
// App and the runtime so the modal can resolve the runtime's ask().
|
|
@@ -140,6 +141,91 @@ export async function startRepl(opts = {}) {
|
|
|
140
141
|
if (isAltScreen)
|
|
141
142
|
enterAlt();
|
|
142
143
|
const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
|
|
144
|
+
// P1 session/permission state (commands.md Priority 1)
|
|
145
|
+
let sessionLabel = '';
|
|
146
|
+
let currentBranch = '';
|
|
147
|
+
let fastMode = false;
|
|
148
|
+
let displayMode = 'default';
|
|
149
|
+
let lastAssistantText = '';
|
|
150
|
+
// P2 state (commands.md Priority 2)
|
|
151
|
+
let activeAgent = 'default';
|
|
152
|
+
let verboseMode = false;
|
|
153
|
+
let detailsMode = false;
|
|
154
|
+
let rawMode = false;
|
|
155
|
+
let lastPromptText = '';
|
|
156
|
+
const attachedFiles = new Map();
|
|
157
|
+
const bgAgentTasks = [];
|
|
158
|
+
const aliases = new Map();
|
|
159
|
+
const savedPrompts = new Map();
|
|
160
|
+
const AGENT_ROLES = ['default', 'explorer', 'implementer', 'tester', 'reviewer'];
|
|
161
|
+
const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
|
|
162
|
+
const aliasFile = `${homeDir}/.klyro/aliases.json`.replace(/\\/g, '/');
|
|
163
|
+
const promptFile = `${homeDir}/.klyro/prompts.json`.replace(/\\/g, '/');
|
|
164
|
+
try {
|
|
165
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
166
|
+
if (existsSync(aliasFile)) {
|
|
167
|
+
const raw = JSON.parse(readFileSync(aliasFile, 'utf-8'));
|
|
168
|
+
for (const [k, v] of Object.entries(raw))
|
|
169
|
+
aliases.set(k, v);
|
|
170
|
+
}
|
|
171
|
+
if (existsSync(promptFile)) {
|
|
172
|
+
const raw = JSON.parse(readFileSync(promptFile, 'utf-8'));
|
|
173
|
+
for (const [k, v] of Object.entries(raw))
|
|
174
|
+
savedPrompts.set(k, v);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch { /* best-effort */ }
|
|
178
|
+
function persistMap(file, m) {
|
|
179
|
+
try {
|
|
180
|
+
const fs = require('node:fs');
|
|
181
|
+
const path = require('node:path');
|
|
182
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
183
|
+
fs.writeFileSync(file, JSON.stringify(Object.fromEntries(m), null, 2), 'utf-8');
|
|
184
|
+
}
|
|
185
|
+
catch { /* best-effort */ }
|
|
186
|
+
}
|
|
187
|
+
/** Truncation budget honoring /verbose and /raw. */
|
|
188
|
+
function outCap(s) {
|
|
189
|
+
const cap = rawMode ? 12000 : verboseMode ? 8000 : 4000;
|
|
190
|
+
return s.length > cap ? s.slice(0, cap) + `\n... [truncated ${s.length - cap} chars]` : s;
|
|
191
|
+
}
|
|
192
|
+
async function execShell(command, timeoutMs = 120_000) {
|
|
193
|
+
const r = await registry.execute('shell_exec', { command, timeoutMs }, { cwd, env: process.env, nonInteractive: true });
|
|
194
|
+
if (!r.ok)
|
|
195
|
+
throw new Error(r.error.message ?? 'shell failed');
|
|
196
|
+
return r.value;
|
|
197
|
+
}
|
|
198
|
+
/** Read-only LLM answer (no tools) — powers /ask and /explain. */
|
|
199
|
+
async function answerReadOnly(question, context) {
|
|
200
|
+
const sys = systemPromptFn({ cwd, telemetry: '' }) + '\n\nAnswer read-only: do not call tools, do not edit files.';
|
|
201
|
+
const userText = context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question;
|
|
202
|
+
const req = {
|
|
203
|
+
model,
|
|
204
|
+
system: sys,
|
|
205
|
+
messages: [{ role: 'user', content: [{ kind: 'text', text: userText }] }],
|
|
206
|
+
tools: [],
|
|
207
|
+
signal: ac.signal,
|
|
208
|
+
};
|
|
209
|
+
queuedStatus({ status: 'running', step: 0, model });
|
|
210
|
+
let text = '';
|
|
211
|
+
try {
|
|
212
|
+
for await (const ev of adapter.stream(req)) {
|
|
213
|
+
if (ev.kind === 'text_delta') {
|
|
214
|
+
text += ev.text;
|
|
215
|
+
queuedDelta(ev.text);
|
|
216
|
+
}
|
|
217
|
+
else if (ev.kind === 'error')
|
|
218
|
+
throw new Error(ev.message);
|
|
219
|
+
}
|
|
220
|
+
lastAssistantText = text;
|
|
221
|
+
queuedStatus({ status: 'done' });
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
225
|
+
queuedAppend({ id: `ro-err-${Date.now()}`, kind: 'error', message: msg });
|
|
226
|
+
queuedStatus({ status: 'error', errorMessage: msg });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
143
229
|
function queuedClear() {
|
|
144
230
|
if (isMounted && directHooks)
|
|
145
231
|
directHooks.clearTranscript();
|
|
@@ -156,6 +242,7 @@ export async function startRepl(opts = {}) {
|
|
|
156
242
|
approvalBridge: tuiBridge,
|
|
157
243
|
isFullscreen: isAltScreen,
|
|
158
244
|
onPrompt: async (text) => {
|
|
245
|
+
lastPromptText = text;
|
|
159
246
|
inflight = runWithBridge(text);
|
|
160
247
|
await inflight;
|
|
161
248
|
inflight = null;
|
|
@@ -235,6 +322,7 @@ export async function startRepl(opts = {}) {
|
|
|
235
322
|
else if (ev.kind === 'error')
|
|
236
323
|
throw new Error(ev.message);
|
|
237
324
|
}
|
|
325
|
+
lastAssistantText = simpleText;
|
|
238
326
|
queuedStatus({ status: 'done' });
|
|
239
327
|
return;
|
|
240
328
|
}
|
|
@@ -352,6 +440,8 @@ export async function startRepl(opts = {}) {
|
|
|
352
440
|
}
|
|
353
441
|
},
|
|
354
442
|
}, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
|
|
443
|
+
if (result.finalText)
|
|
444
|
+
lastAssistantText = result.finalText;
|
|
355
445
|
if (result.verification) {
|
|
356
446
|
const v = result.verification;
|
|
357
447
|
queuedAppend({
|
|
@@ -407,29 +497,18 @@ export async function startRepl(opts = {}) {
|
|
|
407
497
|
return;
|
|
408
498
|
case 'help': {
|
|
409
499
|
const helpText = [
|
|
410
|
-
'commands:',
|
|
411
|
-
' /
|
|
412
|
-
' /
|
|
413
|
-
' /
|
|
414
|
-
' /
|
|
415
|
-
' /
|
|
416
|
-
' /
|
|
417
|
-
'
|
|
418
|
-
'
|
|
419
|
-
'
|
|
420
|
-
' /
|
|
421
|
-
'
|
|
422
|
-
' /cost — token cost',
|
|
423
|
-
' /jobs — background jobs',
|
|
424
|
-
' /memory — session notes',
|
|
425
|
-
' /undo /rewind — checkpoints',
|
|
426
|
-
' /login /logout — credentials',
|
|
427
|
-
' /init — create KLYRO.md',
|
|
428
|
-
' /config — show config path',
|
|
429
|
-
' /doctor — run diagnostics',
|
|
430
|
-
' /version — show version',
|
|
431
|
-
' /quit (/exit) — exit',
|
|
432
|
-
`provider: ${currentProvider} model: ${model} effort: ${effortLevel} (${currentMaxSteps} steps) cwd: ${cwd}`,
|
|
500
|
+
'commands (Priority 1):',
|
|
501
|
+
' session: /new /clear /compact [focus] /resume [id] /sessions /rename [n] /fork [p] /branch [n] /export [f] /copy [n] /quit',
|
|
502
|
+
' model: /model [id] /models /provider [name] /effort [low|medium|high|max] /fast [on|off]',
|
|
503
|
+
' project: /init /status /context /diff /plan [task] /todos /memory',
|
|
504
|
+
' perms: /permissions /mode [m] /sandbox [dir] /approve /deny',
|
|
505
|
+
' app: /login /logout /auth /version /update /cancel /shell (!cmd) /mention (@path) /tools /config /doctor',
|
|
506
|
+
' P2: /review /code-review /security-review /simplify /test /lint /build /run /fix /explain /format /ask',
|
|
507
|
+
' /undo /redo /rewind /checkpoint /accept /reject /details /verbose /raw /activity /tasks /queue /retry',
|
|
508
|
+
' /mcp /agents /subtask /background /attach /files /ls /tree /search /web /read /map /tokens',
|
|
509
|
+
' /commit /push /pull /pr /issue /theme /debug /whoami /reload /reset /prompt /alias /commands /env /deps /install',
|
|
510
|
+
' (!cmd runs shell, @path attaches a file; /commands lists everything)',
|
|
511
|
+
`provider: ${currentProvider} model: ${model} effort: ${effortLevel}${fastMode ? ' fast' : ''} (${currentMaxSteps} steps) mode: ${displayMode} cwd: ${cwd}${sessionLabel ? ` session: ${sessionLabel}` : ''}${currentBranch ? ` branch: ${currentBranch}` : ''}`,
|
|
433
512
|
].join('\n');
|
|
434
513
|
queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
|
|
435
514
|
return;
|
|
@@ -504,7 +583,7 @@ export async function startRepl(opts = {}) {
|
|
|
504
583
|
});
|
|
505
584
|
}
|
|
506
585
|
else {
|
|
507
|
-
queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${currentProvider} effort: ${effortLevel} (${currentMaxSteps} steps) cwd: ${cwd}`, role: 'assistant' });
|
|
586
|
+
queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${currentProvider} effort: ${effortLevel}${fastMode ? '+fast' : ''} (${currentMaxSteps} steps) mode: ${displayMode} agent: ${activeAgent} cwd: ${cwd}${sessionLabel ? ` session: ${sessionLabel}` : ''}${currentBranch ? ` branch: ${currentBranch}` : ''}`, role: 'assistant' });
|
|
508
587
|
}
|
|
509
588
|
return;
|
|
510
589
|
}
|
|
@@ -701,6 +780,9 @@ export async function startRepl(opts = {}) {
|
|
|
701
780
|
return;
|
|
702
781
|
}
|
|
703
782
|
case 'plan': {
|
|
783
|
+
if (cmd.task) {
|
|
784
|
+
queuedAppend({ id: `plan-mode-${Date.now()}`, kind: 'text', text: `Plan mode: "${cmd.task}" — the agent will plan first (todo_write) before editing. Current plan below:`, role: 'assistant' });
|
|
785
|
+
}
|
|
704
786
|
try {
|
|
705
787
|
const { readFileSync, existsSync } = await import('node:fs');
|
|
706
788
|
const { join } = await import('node:path');
|
|
@@ -718,17 +800,1292 @@ export async function startRepl(opts = {}) {
|
|
|
718
800
|
}
|
|
719
801
|
return;
|
|
720
802
|
}
|
|
721
|
-
case '
|
|
722
|
-
|
|
803
|
+
case 'todos': {
|
|
804
|
+
try {
|
|
805
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
806
|
+
const { join } = await import('node:path');
|
|
807
|
+
const todosPath = join(cwd, '.klyro', 'plans', 'todos.json');
|
|
808
|
+
if (!existsSync(todosPath)) {
|
|
809
|
+
queuedAppend({ id: `todos-${Date.now()}`, kind: 'text', text: 'No todos (no .klyro/plans/todos.json yet).', role: 'assistant' });
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
const arr = JSON.parse(readFileSync(todosPath, 'utf-8'));
|
|
813
|
+
const lines = arr.map((t) => `${t.status === 'done' ? '[x]' : t.status === 'in_progress' ? '[>]' : '[ ]'} ${t.title} (${t.id})`);
|
|
814
|
+
queuedAppend({ id: `todos2-${Date.now()}`, kind: 'text', text: `Todos (${arr.length}):\n${lines.join('\n').slice(0, 3000)}`, role: 'assistant' });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
queuedAppend({ id: `todos-err-${Date.now()}`, kind: 'error', message: `todos failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
819
|
+
}
|
|
723
820
|
return;
|
|
724
821
|
}
|
|
725
|
-
case '
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
822
|
+
case 'new': {
|
|
823
|
+
queuedClear();
|
|
824
|
+
sessionLabel = '';
|
|
825
|
+
currentBranch = '';
|
|
826
|
+
try {
|
|
827
|
+
const rec = await tuiStore.create({ cwd, task: 'new session', config: { model, maxSteps: currentMaxSteps } });
|
|
828
|
+
tuiSessionId = rec.id;
|
|
829
|
+
queuedAppend({ id: `new-${Date.now()}`, kind: 'text', text: `new session ${rec.id.slice(0, 8)} started`, role: 'assistant' });
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
queuedAppend({ id: `new2-${Date.now()}`, kind: 'text', text: 'new session started', role: 'assistant' });
|
|
833
|
+
}
|
|
834
|
+
queuedStatus({ status: 'idle', step: 0 });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
case 'models': {
|
|
838
|
+
const { MODEL_REGISTRY } = await import('../providers/model-info.js');
|
|
839
|
+
const lines = Object.values(MODEL_REGISTRY).map((m) => ` ${m.id} ctx ${(m.contextWindow / 1000).toFixed(0)}k $${m.inputPricePer1k}/$${m.outputPricePer1k} per 1k`);
|
|
840
|
+
queuedAppend({ id: `models-${Date.now()}`, kind: 'text', text: `Available models (current: ${model}):\n${lines.join('\n')}\nusage: /model <id>`, role: 'assistant' });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
case 'fast': {
|
|
844
|
+
const s = cmd.state?.trim().toLowerCase();
|
|
845
|
+
if (!s) {
|
|
846
|
+
queuedAppend({ id: `fast-${Date.now()}`, kind: 'text', text: `fast mode: ${fastMode ? 'on' : 'off'} (${currentMaxSteps} steps)\nusage: /fast on|off`, role: 'assistant' });
|
|
847
|
+
}
|
|
848
|
+
else if (s === 'on') {
|
|
849
|
+
fastMode = true;
|
|
850
|
+
currentMaxSteps = 10;
|
|
851
|
+
queuedStatus({ maxSteps: currentMaxSteps });
|
|
852
|
+
queuedAppend({ id: `fast2-${Date.now()}`, kind: 'text', text: 'fast mode on (10 max steps)', role: 'assistant' });
|
|
853
|
+
}
|
|
854
|
+
else if (s === 'off') {
|
|
855
|
+
fastMode = false;
|
|
856
|
+
currentMaxSteps = EFFORT_STEPS[effortLevel];
|
|
857
|
+
queuedStatus({ maxSteps: currentMaxSteps });
|
|
858
|
+
queuedAppend({ id: `fast3-${Date.now()}`, kind: 'text', text: `fast mode off (restored ${effortLevel}: ${currentMaxSteps} steps)`, role: 'assistant' });
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
queuedAppend({ id: `fast-err-${Date.now()}`, kind: 'error', message: `unknown /fast value: ${s} (expected on|off)` });
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
case 'permissions': {
|
|
866
|
+
const cfg = policy.config;
|
|
867
|
+
const lines = [
|
|
868
|
+
`mode: ${displayMode} (engine: ${String(cfg.mode ?? 'default')})`,
|
|
869
|
+
`shellAllow: ${(cfg.shellAllow ?? []).length} prefixes`,
|
|
870
|
+
`shellDeny: ${(cfg.shellDeny ?? []).length} patterns`,
|
|
871
|
+
`allow: ${JSON.stringify(cfg.allow ?? [])}`,
|
|
872
|
+
`deny: ${JSON.stringify(cfg.deny ?? [])}`,
|
|
873
|
+
`ask: ${JSON.stringify(cfg.ask ?? [])}`,
|
|
874
|
+
`sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}`,
|
|
875
|
+
];
|
|
876
|
+
queuedAppend({ id: `perm-${Date.now()}`, kind: 'text', text: `Permissions:\n${lines.join('\n')}\nchange via /mode <manual|accept-edits|plan|auto|yolo>`, role: 'assistant' });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
case 'mode': {
|
|
880
|
+
const m = cmd.mode?.trim().toLowerCase();
|
|
881
|
+
if (!m) {
|
|
882
|
+
queuedAppend({ id: `mode-${Date.now()}`, kind: 'text', text: `current mode: ${displayMode}\nmodes: manual | accept-edits | plan | auto | yolo\nusage: /mode <mode>`, role: 'assistant' });
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
const map = { manual: 'default', 'accept-edits': 'accept-edits', plan: 'plan', auto: 'auto', yolo: 'auto' };
|
|
886
|
+
const engineMode = map[m];
|
|
887
|
+
if (!engineMode) {
|
|
888
|
+
queuedAppend({ id: `mode-err-${Date.now()}`, kind: 'error', message: `unknown mode: ${m} (expected manual|accept-edits|plan|auto|yolo)` });
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
policy.config.mode = engineMode;
|
|
892
|
+
displayMode = m;
|
|
893
|
+
queuedAppend({ id: `mode2-${Date.now()}`, kind: 'text', text: `mode set to ${m}${m === 'yolo' ? ' (auto-approve everything — careful)' : ''}${m === 'plan' ? ' (writes blocked)' : ''}`, role: 'assistant' });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
case 'sandbox': {
|
|
899
|
+
const cfg = policy.config;
|
|
900
|
+
const p = cmd.policy?.trim();
|
|
901
|
+
if (!p) {
|
|
902
|
+
queuedAppend({ id: `sb-${Date.now()}`, kind: 'text', text: `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}\nusage: /sandbox <dir> (adds an allowed directory)`, role: 'assistant' });
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
cfg.additionalDirs = [...(cfg.additionalDirs ?? []), p];
|
|
906
|
+
queuedAppend({ id: `sb2-${Date.now()}`, kind: 'text', text: `sandbox: added allowed dir ${p}`, role: 'assistant' });
|
|
907
|
+
}
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
case 'approve': {
|
|
911
|
+
const ok = tuiBridge.resolve('allow');
|
|
912
|
+
queuedAppend({ id: `appr-${Date.now()}`, kind: 'text', text: ok ? 'approved pending action' : 'no pending approval', role: 'assistant' });
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
case 'deny': {
|
|
916
|
+
const ok = tuiBridge.resolve('deny');
|
|
917
|
+
queuedAppend({ id: `deny-${Date.now()}`, kind: 'text', text: ok ? 'denied pending action' : 'no pending approval', role: 'assistant' });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
case 'resume': {
|
|
921
|
+
try {
|
|
922
|
+
const { resolveSessionId } = await import('../persistence/session.js');
|
|
923
|
+
const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
924
|
+
const full = cmd.id ? await resolveSessionId(tuiStore, cmd.id) : all[0]?.id;
|
|
925
|
+
if (!full) {
|
|
926
|
+
queuedAppend({ id: `resume-err-${Date.now()}`, kind: 'error', message: cmd.id ? `session not found: ${cmd.id}` : 'no previous session in this cwd' });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const rec = await tuiStore.get(full);
|
|
930
|
+
const msgs = await tuiStore.loadMessages(full);
|
|
931
|
+
tuiSessionId = full;
|
|
932
|
+
sessionLabel = rec?.task.slice(0, 40) ?? '';
|
|
933
|
+
queuedAppend({ id: `resume-${Date.now()}`, kind: 'text', text: `resumed session ${full.slice(0, 8)} — "${rec?.task}" (${msgs.length} messages, status ${rec?.status})`, role: 'assistant' });
|
|
934
|
+
queuedStatus({ status: 'idle' });
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
queuedAppend({ id: `resume-err2-${Date.now()}`, kind: 'error', message: `resume failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
938
|
+
}
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
case 'sessions': {
|
|
942
|
+
try {
|
|
943
|
+
const { formatSession } = await import('../persistence/session.js');
|
|
944
|
+
const all = (await tuiStore.list()).sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 20);
|
|
945
|
+
if (all.length === 0) {
|
|
946
|
+
queuedAppend({ id: `sess-${Date.now()}`, kind: 'text', text: 'No sessions yet.', role: 'assistant' });
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
const lines = all.map((r) => `${r.id.slice(0, 8) === tuiSessionId?.slice(0, 8) ? '*' : ' '} ${formatSession(r)}`);
|
|
950
|
+
queuedAppend({ id: `sess2-${Date.now()}`, kind: 'text', text: `Sessions (* = current):\n${lines.join('\n')}\nusage: /resume <id>`, role: 'assistant' });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
queuedAppend({ id: `sess-err-${Date.now()}`, kind: 'error', message: `sessions failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
955
|
+
}
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
case 'rename': {
|
|
959
|
+
if (!cmd.name) {
|
|
960
|
+
queuedAppend({ id: `ren-${Date.now()}`, kind: 'text', text: `current session label: ${sessionLabel || '(none)'}\nusage: /rename <name>`, role: 'assistant' });
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
sessionLabel = cmd.name;
|
|
964
|
+
queuedAppend({ id: `ren2-${Date.now()}`, kind: 'text', text: `session renamed to "${cmd.name}"`, role: 'assistant' });
|
|
965
|
+
}
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
case 'fork': {
|
|
969
|
+
try {
|
|
970
|
+
const base = sessionLabel || 'session';
|
|
971
|
+
const rec = await tuiStore.create({ cwd, task: `${base} (fork)${cmd.prompt ? `: ${cmd.prompt}` : ''}`, config: { model, maxSteps: currentMaxSteps } });
|
|
972
|
+
tuiSessionId = rec.id;
|
|
973
|
+
queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${rec.id.slice(0, 8)}`, role: 'assistant' });
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
queuedAppend({ id: `fork-err-${Date.now()}`, kind: 'error', message: `fork failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
977
|
+
}
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
case 'branch': {
|
|
981
|
+
if (!cmd.name) {
|
|
982
|
+
queuedAppend({ id: `br-${Date.now()}`, kind: 'text', text: `current branch: ${currentBranch || '(none)'}\nusage: /branch <name>`, role: 'assistant' });
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
currentBranch = cmd.name;
|
|
986
|
+
queuedAppend({ id: `br2-${Date.now()}`, kind: 'text', text: `branch "${cmd.name}" created — conversation continues here`, role: 'assistant' });
|
|
987
|
+
}
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
case 'export': {
|
|
991
|
+
try {
|
|
992
|
+
const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
993
|
+
const target = tuiSessionId ?? all[0]?.id;
|
|
994
|
+
if (!target) {
|
|
995
|
+
queuedAppend({ id: `exp-err-${Date.now()}`, kind: 'error', message: 'nothing to export (no session)' });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const rec = await tuiStore.get(target);
|
|
999
|
+
const msgs = await tuiStore.loadMessages(target);
|
|
1000
|
+
const out = cmd.file ?? `${target.slice(0, 8)}.export.json`;
|
|
1001
|
+
await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs }, null, 2), 'utf-8');
|
|
1002
|
+
queuedAppend({ id: `exp-${Date.now()}`, kind: 'text', text: `exported ${target.slice(0, 8)} → ${out} (${msgs.length} messages)`, role: 'assistant' });
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
queuedAppend({ id: `exp-err2-${Date.now()}`, kind: 'error', message: `export failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
case 'copy': {
|
|
1010
|
+
if (!lastAssistantText) {
|
|
1011
|
+
queuedAppend({ id: `copy-err-${Date.now()}`, kind: 'error', message: 'nothing to copy yet (no assistant response this session)' });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const n = cmd.n ? parseInt(cmd.n, 10) : NaN;
|
|
1015
|
+
const text = Number.isFinite(n) && n > 0 ? lastAssistantText.slice(0, n) : lastAssistantText;
|
|
1016
|
+
try {
|
|
1017
|
+
const { execSync } = await import('node:child_process');
|
|
1018
|
+
const clip = process.platform === 'win32' ? 'clip' : process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
|
|
1019
|
+
execSync(clip, { input: text });
|
|
1020
|
+
queuedAppend({ id: `copy-${Date.now()}`, kind: 'text', text: `copied ${text.length} chars to clipboard`, role: 'assistant' });
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
queuedAppend({ id: `copy2-${Date.now()}`, kind: 'text', text: `clipboard unavailable — response preview (${text.length} chars):\n${text.slice(0, 500)}`, role: 'assistant' });
|
|
1024
|
+
}
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
case 'auth': {
|
|
1028
|
+
const { getStoredKey } = await import('./auth.js');
|
|
1029
|
+
const rows = ['openai', 'anthropic'].map((p) => {
|
|
1030
|
+
const hasFile = !!getStoredKey(p);
|
|
1031
|
+
const hasEnv = !!(p === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY) || !!process.env.KLYRO_API_KEY;
|
|
1032
|
+
return ` ${p}: ${hasFile ? 'stored key (0600)' : hasEnv ? 'env key' : '—'}`;
|
|
730
1033
|
});
|
|
1034
|
+
queuedAppend({ id: `auth-${Date.now()}`, kind: 'text', text: `Auth:\n${rows.join('\n')}\ncurrent provider: ${currentProvider}\nmanage via /login /logout`, role: 'assistant' });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
case 'update': {
|
|
1038
|
+
const { runUpdate } = await import('./update.js');
|
|
1039
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
1040
|
+
let out = '';
|
|
1041
|
+
process.stdout.write = ((chunk) => { out += String(chunk); return true; });
|
|
1042
|
+
await runUpdate();
|
|
1043
|
+
process.stdout.write = origWrite;
|
|
1044
|
+
queuedAppend({ id: `upd-${Date.now()}`, kind: 'text', text: out || 'update check done', role: 'assistant' });
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
case 'cancel': {
|
|
1048
|
+
ac.abort();
|
|
1049
|
+
ac = new AbortController();
|
|
1050
|
+
queuedStatus({ status: 'aborted' });
|
|
1051
|
+
queuedAppend({ id: `cancel-${Date.now()}`, kind: 'text', text: 'cancelled current operation', role: 'assistant' });
|
|
731
1052
|
return;
|
|
1053
|
+
}
|
|
1054
|
+
case 'shell': {
|
|
1055
|
+
if (!cmd.command) {
|
|
1056
|
+
queuedAppend({ id: `sh-${Date.now()}`, kind: 'text', text: 'usage: /shell <command> (or !<command>)', role: 'assistant' });
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
const r = await registry.execute('shell_exec', { command: cmd.command }, { cwd, env: process.env, nonInteractive: true });
|
|
1061
|
+
if (!r.ok) {
|
|
1062
|
+
queuedAppend({ id: `sh-err-${Date.now()}`, kind: 'error', message: `shell failed: ${r.error.message ?? r.error.code}` });
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
const v = r.value;
|
|
1066
|
+
const body = (v.stdout + (v.stderr ? `\n[stderr]\n${v.stderr}` : '')).slice(0, 4000) || '(no output)';
|
|
1067
|
+
queuedAppend({ id: `sh2-${Date.now()}`, kind: 'text', text: `$ ${cmd.command}\nexit ${v.exitCode}\n${body}`, role: 'assistant' });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
queuedAppend({ id: `sh-err2-${Date.now()}`, kind: 'error', message: `shell failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
case 'mention': {
|
|
1076
|
+
if (!cmd.path) {
|
|
1077
|
+
queuedAppend({ id: `men-${Date.now()}`, kind: 'text', text: 'usage: /mention <path> (or @<path>)', role: 'assistant' });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
const r = await registry.execute('read_file', { path: cmd.path }, { cwd, env: process.env, nonInteractive: true });
|
|
1082
|
+
if (!r.ok) {
|
|
1083
|
+
queuedAppend({ id: `men-err-${Date.now()}`, kind: 'error', message: `mention failed: ${r.error.message ?? r.error.code}` });
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
const v = r.value;
|
|
1087
|
+
const body = String(v.content ?? v.text ?? JSON.stringify(v)).slice(0, 6000);
|
|
1088
|
+
queuedAppend({ id: `men2-${Date.now()}`, kind: 'text', text: `attached ${cmd.path} (${body.length} chars):\n${body}`, role: 'assistant' });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
queuedAppend({ id: `men-err2-${Date.now()}`, kind: 'error', message: `mention failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1093
|
+
}
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
case 'tools': {
|
|
1097
|
+
const lines = registry.list().map((t) => ` ${t.name} — ${t.description.slice(0, 80)}`);
|
|
1098
|
+
queuedAppend({ id: `tools-${Date.now()}`, kind: 'text', text: `Tools (${lines.length}):\n${lines.join('\n')}`, role: 'assistant' });
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
case 'settings': {
|
|
1102
|
+
const { getConfigPath } = await import('./config.js');
|
|
1103
|
+
queuedAppend({ id: `set-${Date.now()}`, kind: 'text', text: `config: ${getConfigPath()} (alias of /config)`, role: 'assistant' });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
case 'review': {
|
|
1107
|
+
try {
|
|
1108
|
+
const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
|
|
1109
|
+
if (!r.ok) {
|
|
1110
|
+
queuedAppend({ id: `rev-err-${Date.now()}`, kind: 'error', message: `review failed: ${r.error.message ?? 'git_diff error'}` });
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
const v = r.value;
|
|
1114
|
+
const head = v.patchedFiles.length === 0 ? 'Working tree clean — nothing to review.' : `Reviewing ${v.patchedFiles.length} file(s): ${v.patchedFiles.join(', ')}`;
|
|
1115
|
+
let extra = '';
|
|
1116
|
+
if (cmd.target) {
|
|
1117
|
+
try {
|
|
1118
|
+
const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
|
|
1119
|
+
if (fr.ok)
|
|
1120
|
+
extra = `\n\n--- ${cmd.target} ---\n${String(fr.value.content ?? '').slice(0, 2000)}`;
|
|
1121
|
+
}
|
|
1122
|
+
catch { /* ignore */ }
|
|
1123
|
+
}
|
|
1124
|
+
queuedAppend({ id: `rev-${Date.now()}`, kind: 'text', text: `${head}\n${v.stat.slice(0, 1500)}${extra}\n\n${outCap(v.diff).slice(0, 3000)}`, role: 'assistant' });
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
catch (err) {
|
|
1128
|
+
queuedAppend({ id: `rev-err2-${Date.now()}`, kind: 'error', message: `review failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1129
|
+
}
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
case 'code-review': {
|
|
1133
|
+
try {
|
|
1134
|
+
const { checkImports } = await import('../verification/scoped.js');
|
|
1135
|
+
const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
|
|
1136
|
+
if (!r.ok) {
|
|
1137
|
+
queuedAppend({ id: `cr-err-${Date.now()}`, kind: 'error', message: 'code-review failed: no diff available' });
|
|
1138
|
+
}
|
|
1139
|
+
else {
|
|
1140
|
+
const v = r.value;
|
|
1141
|
+
const findings = [];
|
|
1142
|
+
for (const f of v.patchedFiles.slice(0, 10)) {
|
|
1143
|
+
const ic = checkImports(cwd, f);
|
|
1144
|
+
for (const m of ic.missing)
|
|
1145
|
+
findings.push(`missing import '${m}' in ${f}`);
|
|
1146
|
+
if (f.length > 200)
|
|
1147
|
+
findings.push(`suspicious path length: ${f}`);
|
|
1148
|
+
}
|
|
1149
|
+
if (/(^|\/)\.env(\.|$)/.test(v.diff))
|
|
1150
|
+
findings.push('.env content in diff — never commit secrets');
|
|
1151
|
+
if (/console\.log|debugger/.test(v.diff))
|
|
1152
|
+
findings.push('debug leftovers (console.log/debugger) in diff');
|
|
1153
|
+
if (/\.skip\(|\.todo\(|xit\(|xtest\(/.test(v.diff))
|
|
1154
|
+
findings.push('skipped tests in diff');
|
|
1155
|
+
const verdict = findings.length === 0 ? 'No issues found.' : `Findings (${findings.length}):\n- ${findings.join('\n- ')}`;
|
|
1156
|
+
queuedAppend({ id: `cr-${Date.now()}`, kind: 'text', text: `Code review — ${v.patchedFiles.length} file(s)${cmd.options ? ` (${cmd.options})` : ''}:\n${v.stat.slice(0, 1000)}\n\n${verdict}`, role: 'assistant' });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
catch (err) {
|
|
1160
|
+
queuedAppend({ id: `cr-err2-${Date.now()}`, kind: 'error', message: `code-review failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1161
|
+
}
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
case 'security-review': {
|
|
1165
|
+
try {
|
|
1166
|
+
const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
|
|
1167
|
+
const diff = r.ok ? r.value.diff : '';
|
|
1168
|
+
const checks = [
|
|
1169
|
+
[/sk-[A-Za-z0-9]{8,}|sk-ant-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{10,}/, 'possible hardcoded secret in diff'],
|
|
1170
|
+
[/(^|\/)\.env(\.|$)/, '.env content in diff'],
|
|
1171
|
+
[/\beval\s*\(/, 'eval() usage in diff'],
|
|
1172
|
+
[/rm\s+-rf\s+(\/|~|\*)/, 'dangerous rm -rf in diff'],
|
|
1173
|
+
[/chmod\s+-R\s+777/, 'chmod 777 in diff'],
|
|
1174
|
+
[/curl.*\|\s*(sh|bash)/i, 'curl|sh pipe in diff'],
|
|
1175
|
+
[/password\s*=\s*["'][^"']+["']/i, 'hardcoded password in diff'],
|
|
1176
|
+
];
|
|
1177
|
+
const hits = checks.filter(([re]) => re.test(diff)).map(([, msg]) => msg);
|
|
1178
|
+
queuedAppend({ id: `sr-${Date.now()}`, kind: 'text', text: hits.length === 0 ? 'Security review: no issues found in working-tree diff.' : `Security review findings:\n- ${hits.join('\n- ')}`, role: 'assistant' });
|
|
1179
|
+
}
|
|
1180
|
+
catch (err) {
|
|
1181
|
+
queuedAppend({ id: `sr-err-${Date.now()}`, kind: 'error', message: `security-review failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1182
|
+
}
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
case 'simplify': {
|
|
1186
|
+
await runWithBridge(`Simplify ${cmd.target ?? 'recent changes'}: refactor for clarity without changing behavior. Keep the diff minimal.`);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
case 'test': {
|
|
1190
|
+
try {
|
|
1191
|
+
const { primaryVerifyCommand } = await import('../verification/registry.js');
|
|
1192
|
+
const { buildScopedCommand } = await import('../verification/scoped.js');
|
|
1193
|
+
const { verify } = await import('../verification/engine.js');
|
|
1194
|
+
const base = primaryVerifyCommand(cwd);
|
|
1195
|
+
if (!base) {
|
|
1196
|
+
queuedAppend({ id: `tst-${Date.now()}`, kind: 'text', text: 'No test command detected. Try /verify or run tests manually.', role: 'assistant' });
|
|
1197
|
+
}
|
|
1198
|
+
else {
|
|
1199
|
+
const scoped = cmd.target ? buildScopedCommand(cwd, base, [cmd.target]) ?? base : base;
|
|
1200
|
+
queuedAppend({ id: `tst-run-${Date.now()}`, kind: 'text', text: `[test] running \`${scoped}\`...`, role: 'assistant' });
|
|
1201
|
+
const res = await verify({ cwd, command: scoped, timeoutMs: 120_000 });
|
|
1202
|
+
queuedAppend({ id: `tst-res-${Date.now()}`, kind: 'text', text: res.ok ? `[test] passed (${scoped})` : `[test] failed:\n${outCap(res.stderr || res.stdout)}`, role: 'assistant' });
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
catch (err) {
|
|
1206
|
+
queuedAppend({ id: `tst-err-${Date.now()}`, kind: 'error', message: `test failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1207
|
+
}
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
case 'lint': {
|
|
1211
|
+
try {
|
|
1212
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
1213
|
+
const { join } = await import('node:path');
|
|
1214
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
1215
|
+
let lintCmd = null;
|
|
1216
|
+
if (pkg.scripts?.lint)
|
|
1217
|
+
lintCmd = 'npm run lint --silent';
|
|
1218
|
+
else if (existsSync(join(cwd, 'eslint.config.js')) || existsSync(join(cwd, '.eslintrc.json')))
|
|
1219
|
+
lintCmd = 'npx eslint .';
|
|
1220
|
+
else
|
|
1221
|
+
lintCmd = 'npx tsc --noEmit';
|
|
1222
|
+
queuedAppend({ id: `lint-run-${Date.now()}`, kind: 'text', text: `[lint] running \`${lintCmd}\`...`, role: 'assistant' });
|
|
1223
|
+
const v = await execShell(lintCmd);
|
|
1224
|
+
queuedAppend({ id: `lint-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `[lint] clean (${lintCmd})` : `[lint] issues (exit ${v.exitCode}):\n${outCap(v.stdout + v.stderr)}`, role: 'assistant' });
|
|
1225
|
+
}
|
|
1226
|
+
catch (err) {
|
|
1227
|
+
queuedAppend({ id: `lint-err-${Date.now()}`, kind: 'error', message: `lint failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1228
|
+
}
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
case 'build': {
|
|
1232
|
+
try {
|
|
1233
|
+
const { readFileSync } = await import('node:fs');
|
|
1234
|
+
const { join } = await import('node:path');
|
|
1235
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
1236
|
+
if (!pkg.scripts?.build) {
|
|
1237
|
+
queuedAppend({ id: `bld-${Date.now()}`, kind: 'text', text: 'No build script in package.json.', role: 'assistant' });
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
queuedAppend({ id: `bld-run-${Date.now()}`, kind: 'text', text: '[build] running `npm run build`...', role: 'assistant' });
|
|
1241
|
+
const v = await execShell('npm run build', 300_000);
|
|
1242
|
+
queuedAppend({ id: `bld-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? '[build] succeeded' : `[build] failed (exit ${v.exitCode}):\n${outCap(v.stdout + v.stderr)}`, role: 'assistant' });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch (err) {
|
|
1246
|
+
queuedAppend({ id: `bld-err-${Date.now()}`, kind: 'error', message: `build failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1247
|
+
}
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
case 'run': {
|
|
1251
|
+
if (!cmd.command) {
|
|
1252
|
+
queuedAppend({ id: `run-${Date.now()}`, kind: 'text', text: 'usage: /run <command>', role: 'assistant' });
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
try {
|
|
1256
|
+
const v = await execShell(cmd.command, 300_000);
|
|
1257
|
+
queuedAppend({ id: `run2-${Date.now()}`, kind: 'text', text: `$ ${cmd.command}\nexit ${v.exitCode}\n${outCap(v.stdout + (v.stderr ? `\n[stderr]\n${v.stderr}` : '') || '(no output)')}`, role: 'assistant' });
|
|
1258
|
+
}
|
|
1259
|
+
catch (err) {
|
|
1260
|
+
queuedAppend({ id: `run-err-${Date.now()}`, kind: 'error', message: `run failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
case 'fix': {
|
|
1266
|
+
await runWithBridge(`Fix ${cmd.target ?? 'failing tests and lint errors'}: reproduce the failure, fix the source (do not edit test assertions unless the test itself is wrong), then verify.`);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
case 'explain': {
|
|
1270
|
+
if (!cmd.target) {
|
|
1271
|
+
queuedAppend({ id: `exp-${Date.now()}`, kind: 'text', text: 'usage: /explain <file|symbol>', role: 'assistant' });
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
let ctx = '';
|
|
1275
|
+
try {
|
|
1276
|
+
const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
|
|
1277
|
+
if (fr.ok)
|
|
1278
|
+
ctx = `File ${cmd.target}:\n${String(fr.value.content ?? '').slice(0, 6000)}`;
|
|
1279
|
+
}
|
|
1280
|
+
catch { /* symbol — answer without file context */ }
|
|
1281
|
+
await answerReadOnly(`Explain ${cmd.target}: what it does, key logic, and gotchas.`, ctx || undefined);
|
|
1282
|
+
}
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
case 'format': {
|
|
1286
|
+
try {
|
|
1287
|
+
const v = await execShell('git status --porcelain');
|
|
1288
|
+
const files = v.stdout.split('\n').map((l) => l.slice(3).trim()).filter((f) => /\.(ts|tsx|js|jsx|json|md)$/.test(f)).slice(0, 20);
|
|
1289
|
+
if (files.length === 0) {
|
|
1290
|
+
queuedAppend({ id: `fmt-${Date.now()}`, kind: 'text', text: 'Nothing to format (no changed source files).', role: 'assistant' });
|
|
1291
|
+
}
|
|
1292
|
+
else {
|
|
1293
|
+
const check = await execShell('npx --no-install prettier --version').catch(() => null);
|
|
1294
|
+
if (!check || check.exitCode !== 0) {
|
|
1295
|
+
queuedAppend({ id: `fmt2-${Date.now()}`, kind: 'text', text: 'prettier not installed — run `npm i -D prettier` first.', role: 'assistant' });
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
const fv = await execShell(`npx prettier --write ${files.map((f) => `"${f}"`).join(' ')}`);
|
|
1299
|
+
queuedAppend({ id: `fmt3-${Date.now()}`, kind: 'text', text: fv.exitCode === 0 ? `[format] formatted ${files.length} file(s)` : `[format] failed:\n${outCap(fv.stderr || fv.stdout)}`, role: 'assistant' });
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
catch (err) {
|
|
1304
|
+
queuedAppend({ id: `fmt-err-${Date.now()}`, kind: 'error', message: `format failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1305
|
+
}
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
case 'ask': {
|
|
1309
|
+
if (!cmd.question) {
|
|
1310
|
+
queuedAppend({ id: `ask-${Date.now()}`, kind: 'text', text: 'usage: /ask <question> (read-only, no edits)', role: 'assistant' });
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
await answerReadOnly(cmd.question);
|
|
1314
|
+
}
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
case 'redo': {
|
|
1318
|
+
queuedAppend({ id: `redo-${Date.now()}`, kind: 'text', text: 'No redo stack — checkpoints support /undo and /rewind only.', role: 'assistant' });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
case 'checkpoint': {
|
|
1322
|
+
try {
|
|
1323
|
+
const { snapshot } = await import('../checkpoints/store.js');
|
|
1324
|
+
const v = await execShell('git status --porcelain');
|
|
1325
|
+
const files = v.stdout.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
|
|
1326
|
+
if (files.length === 0) {
|
|
1327
|
+
queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'Working tree clean — nothing to checkpoint.', role: 'assistant' });
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
const id = await snapshot(cwd, files.slice(0, 50));
|
|
1331
|
+
queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)`, role: 'assistant' });
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
catch (err) {
|
|
1335
|
+
queuedAppend({ id: `ckpt-err-${Date.now()}`, kind: 'error', message: `checkpoint failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1336
|
+
}
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
case 'accept': {
|
|
1340
|
+
const ok = tuiBridge.resolve('allow');
|
|
1341
|
+
queuedAppend({ id: `acc-${Date.now()}`, kind: 'text', text: ok ? 'accepted pending edits' : 'no pending edits to accept', role: 'assistant' });
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
case 'reject': {
|
|
1345
|
+
const ok = tuiBridge.resolve('deny');
|
|
1346
|
+
queuedAppend({ id: `rej-${Date.now()}`, kind: 'text', text: ok ? 'rejected pending edits' : 'no pending edits to reject', role: 'assistant' });
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
case 'details': {
|
|
1350
|
+
detailsMode = !detailsMode;
|
|
1351
|
+
queuedAppend({ id: `det-${Date.now()}`, kind: 'text', text: `detailed activity: ${detailsMode ? 'on (tool groups expanded by default — use ctrl+o)' : 'off'}`, role: 'assistant' });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
case 'verbose': {
|
|
1355
|
+
verboseMode = !verboseMode;
|
|
1356
|
+
queuedAppend({ id: `verb-${Date.now()}`, kind: 'text', text: `verbose output: ${verboseMode ? 'on (8k output cap)' : 'off (4k output cap)'}`, role: 'assistant' });
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
case 'raw': {
|
|
1360
|
+
rawMode = !rawMode;
|
|
1361
|
+
queuedAppend({ id: `raw-${Date.now()}`, kind: 'text', text: `raw output: ${rawMode ? 'on (12k cap, no truncation notes)' : 'off'}`, role: 'assistant' });
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
case 'activity': {
|
|
1365
|
+
const s = lastStatus;
|
|
1366
|
+
const line = s ? `status=${s.status} step=${s.step}/${s.maxSteps} model=${s.model} tokens=${s.usageInput + s.usageOutput}` : `status=idle model=${model}`;
|
|
1367
|
+
queuedAppend({ id: `act-${Date.now()}`, kind: 'text', text: `Activity: ${line}${inflight ? ' (task running)' : ''}${tuiSessionId ? ` session=${tuiSessionId.slice(0, 8)}` : ''}`, role: 'assistant' });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
case 'tasks':
|
|
1371
|
+
case 'ps': {
|
|
1372
|
+
const { listJobs } = await import('../tools/shell/background.js');
|
|
1373
|
+
const jobs = listJobs();
|
|
1374
|
+
if (jobs.length === 0)
|
|
1375
|
+
queuedAppend({ id: `tasks-${Date.now()}`, kind: 'text', text: 'No background jobs', role: 'assistant' });
|
|
1376
|
+
else
|
|
1377
|
+
queuedAppend({ id: `tasks2-${Date.now()}`, kind: 'text', text: `Background jobs:\n${jobs.map((j) => ` ${j.id.slice(0, 12)} [${j.running ? 'running' : 'done'}] ${j.command}`).join('\n')}\nstop via /stop <id>`, role: 'assistant' });
|
|
1378
|
+
if (bgAgentTasks.length > 0) {
|
|
1379
|
+
queuedAppend({ id: `tasks3-${Date.now()}`, kind: 'text', text: `Agent tasks:\n${bgAgentTasks.map((t) => ` ${t.id} [${t.status}] ${t.task.slice(0, 80)}`).join('\n')}`, role: 'assistant' });
|
|
1380
|
+
}
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
case 'stop':
|
|
1384
|
+
case 'kill': {
|
|
1385
|
+
const id = cmd.id?.trim();
|
|
1386
|
+
const { listJobs, killJob } = await import('../tools/shell/background.js');
|
|
1387
|
+
if (!id) {
|
|
1388
|
+
const jobs = listJobs();
|
|
1389
|
+
queuedAppend({ id: `stop-${Date.now()}`, kind: 'text', text: jobs.length === 0 ? 'No background jobs.\nusage: /stop <id>' : `usage: /stop <id>\njobs:\n${jobs.map((j) => ` ${j.id.slice(0, 12)} ${j.command}`).join('\n')}`, role: 'assistant' });
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
const match = listJobs().find((j) => j.id.startsWith(id)) ?? listJobs().find((j) => j.id.includes(id));
|
|
1393
|
+
const ag = bgAgentTasks.find((t) => t.id.startsWith(id));
|
|
1394
|
+
if (match) {
|
|
1395
|
+
try {
|
|
1396
|
+
killJob(match.id);
|
|
1397
|
+
queuedAppend({ id: `stop2-${Date.now()}`, kind: 'text', text: `stopped ${match.id.slice(0, 12)}`, role: 'assistant' });
|
|
1398
|
+
}
|
|
1399
|
+
catch (err) {
|
|
1400
|
+
queuedAppend({ id: `stop-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
else if (ag) {
|
|
1404
|
+
ag.status = 'stopped';
|
|
1405
|
+
queuedAppend({ id: `stop3-${Date.now()}`, kind: 'text', text: `marked agent task ${ag.id} stopped (in-flight loop finishes current step)`, role: 'assistant' });
|
|
1406
|
+
}
|
|
1407
|
+
else {
|
|
1408
|
+
queuedAppend({ id: `stop-err2-${Date.now()}`, kind: 'error', message: `no job: ${id}` });
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
case 'queue': {
|
|
1414
|
+
const pending = bgAgentTasks.filter((t) => t.status === 'running');
|
|
1415
|
+
queuedAppend({ id: `queue-${Date.now()}`, kind: 'text', text: pending.length === 0 ? 'Queue empty (input queue lives in the TUI — enter to queue while running, esc to drop).' : `Queued/running agent tasks:\n${pending.map((t) => ` ${t.id}: ${t.task.slice(0, 80)}`).join('\n')}`, role: 'assistant' });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
case 'retry': {
|
|
1419
|
+
if (!lastPromptText) {
|
|
1420
|
+
queuedAppend({ id: `retry-err-${Date.now()}`, kind: 'error', message: 'nothing to retry yet' });
|
|
1421
|
+
}
|
|
1422
|
+
else {
|
|
1423
|
+
queuedAppend({ id: `retry-${Date.now()}`, kind: 'text', text: `retrying: ${lastPromptText.slice(0, 120)}`, role: 'assistant' });
|
|
1424
|
+
await runWithBridge(lastPromptText);
|
|
1425
|
+
}
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
case 'mcp': {
|
|
1429
|
+
try {
|
|
1430
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
1431
|
+
const { join } = await import('node:path');
|
|
1432
|
+
const sub = cmd.sub?.trim().split(/\s+/)[0] ?? 'list';
|
|
1433
|
+
const arg = cmd.sub?.trim().split(/\s+/).slice(1).join(' ');
|
|
1434
|
+
const cfgPath = join(cwd, '.mcp.json');
|
|
1435
|
+
if (!existsSync(cfgPath)) {
|
|
1436
|
+
queuedAppend({ id: `mcp-${Date.now()}`, kind: 'text', text: 'No MCP servers configured (no .mcp.json). Add servers to .mcp.json.', role: 'assistant' });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
1440
|
+
const servers = cfg.servers ?? {};
|
|
1441
|
+
const names = Object.keys(servers);
|
|
1442
|
+
if (sub === 'list' || sub === 'status' || !sub) {
|
|
1443
|
+
queuedAppend({ id: `mcp2-${Date.now()}`, kind: 'text', text: names.length === 0 ? 'MCP: .mcp.json has no servers.' : `MCP servers (${names.length}, lazy-connect):\n${names.map((n) => ` ${servers[n]?.disabled ? '[disabled]' : '[enabled] '} ${n}${servers[n]?.url ? ` ${servers[n].url}` : ''}`).join('\n')}`, role: 'assistant' });
|
|
1444
|
+
}
|
|
1445
|
+
else if ((sub === 'enable' || sub === 'disable') && arg) {
|
|
1446
|
+
if (!servers[arg]) {
|
|
1447
|
+
queuedAppend({ id: `mcp-err-${Date.now()}`, kind: 'error', message: `no MCP server: ${arg}` });
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
servers[arg].disabled = sub === 'disable';
|
|
1451
|
+
const { writeFileSync } = await import('node:fs');
|
|
1452
|
+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
1453
|
+
queuedAppend({ id: `mcp3-${Date.now()}`, kind: 'text', text: `MCP server ${arg} ${sub}d`, role: 'assistant' });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
else if (sub === 'reconnect' && arg) {
|
|
1457
|
+
queuedAppend({ id: `mcp4-${Date.now()}`, kind: 'text', text: servers[arg] ? `MCP ${arg}: reconnect queued (connections are lazy — next tool use reconnects)` : `no MCP server: ${arg}`, role: 'assistant' });
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
queuedAppend({ id: `mcp5-${Date.now()}`, kind: 'text', text: 'usage: /mcp [list|status|enable <n>|disable <n>|reconnect <n>]', role: 'assistant' });
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
catch (err) {
|
|
1464
|
+
queuedAppend({ id: `mcp-err2-${Date.now()}`, kind: 'error', message: `mcp failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1465
|
+
}
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
case 'agents': {
|
|
1469
|
+
queuedAppend({ id: `agents-${Date.now()}`, kind: 'text', text: `Agents (active: ${activeAgent}):\n${AGENT_ROLES.map((a) => ` ${a === activeAgent ? '*' : ' '} ${a}`).join('\n')}\nswitch via /agent <name>, spawn via /subtask <task>`, role: 'assistant' });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
case 'agent': {
|
|
1473
|
+
const n = cmd.name?.trim().toLowerCase();
|
|
1474
|
+
if (!n) {
|
|
1475
|
+
queuedAppend({ id: `agent-${Date.now()}`, kind: 'text', text: `active agent: ${activeAgent}\nusage: /agent <${AGENT_ROLES.join('|')}>`, role: 'assistant' });
|
|
1476
|
+
}
|
|
1477
|
+
else if (!AGENT_ROLES.includes(n)) {
|
|
1478
|
+
queuedAppend({ id: `agent-err-${Date.now()}`, kind: 'error', message: `unknown agent: ${n} (expected ${AGENT_ROLES.join('|')})` });
|
|
1479
|
+
}
|
|
1480
|
+
else {
|
|
1481
|
+
activeAgent = n;
|
|
1482
|
+
queuedAppend({ id: `agent2-${Date.now()}`, kind: 'text', text: `active agent: ${n} (role label for future tasks)`, role: 'assistant' });
|
|
1483
|
+
}
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
case 'subagents': {
|
|
1487
|
+
queuedAppend({ id: `subagents-${Date.now()}`, kind: 'text', text: `Subagents: ${AGENT_ROLES.filter((a) => a !== 'default').join(', ')}\nspawn via /subtask <task> — runs a full agent loop and reports back.`, role: 'assistant' });
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
case 'subtask': {
|
|
1491
|
+
if (!cmd.task) {
|
|
1492
|
+
queuedAppend({ id: `subtask-${Date.now()}`, kind: 'text', text: 'usage: /subtask <task>', role: 'assistant' });
|
|
1493
|
+
}
|
|
1494
|
+
else {
|
|
1495
|
+
queuedAppend({ id: `subtask-run-${Date.now()}`, kind: 'text', text: `[subagent:${activeAgent}] starting: ${cmd.task.slice(0, 120)}`, role: 'assistant' });
|
|
1496
|
+
await runWithBridge(`[subagent task] ${cmd.task}`);
|
|
1497
|
+
}
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
case 'background': {
|
|
1501
|
+
if (!cmd.task) {
|
|
1502
|
+
queuedAppend({ id: `bg-${Date.now()}`, kind: 'text', text: bgAgentTasks.length === 0 ? 'No agent background tasks.\nusage: /background <task>' : `Agent background tasks:\n${bgAgentTasks.map((t) => ` ${t.id} [${t.status}] ${t.task.slice(0, 80)}`).join('\n')}`, role: 'assistant' });
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
const id = `bg-${Date.now().toString(36)}`;
|
|
1506
|
+
bgAgentTasks.push({ id, task: cmd.task, status: 'running' });
|
|
1507
|
+
queuedAppend({ id: `bg-run-${Date.now()}`, kind: 'text', text: `[background] ${id} started: ${cmd.task.slice(0, 120)}`, role: 'assistant' });
|
|
1508
|
+
void runWithBridge(cmd.task).then(() => {
|
|
1509
|
+
const t = bgAgentTasks.find((x) => x.id === id);
|
|
1510
|
+
if (t && t.status === 'running')
|
|
1511
|
+
t.status = 'done';
|
|
1512
|
+
}).catch(() => {
|
|
1513
|
+
const t = bgAgentTasks.find((x) => x.id === id);
|
|
1514
|
+
if (t && t.status === 'running')
|
|
1515
|
+
t.status = 'failed';
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
case 'add-dir': {
|
|
1521
|
+
const { existsSync, statSync } = await import('node:fs');
|
|
1522
|
+
const { resolve } = await import('node:path');
|
|
1523
|
+
const p = cmd.path?.trim();
|
|
1524
|
+
if (!p) {
|
|
1525
|
+
queuedAppend({ id: `adddir-${Date.now()}`, kind: 'text', text: 'usage: /add-dir <path>', role: 'assistant' });
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
const abs = resolve(cwd, p);
|
|
1529
|
+
try {
|
|
1530
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
1531
|
+
queuedAppend({ id: `adddir-err-${Date.now()}`, kind: 'error', message: `not a directory: ${p}` });
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
const cfg = policy.config;
|
|
1535
|
+
if (!cfg.additionalDirs?.includes(abs))
|
|
1536
|
+
cfg.additionalDirs = [...(cfg.additionalDirs ?? []), abs];
|
|
1537
|
+
queuedAppend({ id: `adddir2-${Date.now()}`, kind: 'text', text: `added allowed directory: ${abs}`, role: 'assistant' });
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
catch (err) {
|
|
1541
|
+
queuedAppend({ id: `adddir-err2-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
case 'cd': {
|
|
1547
|
+
const { existsSync, statSync } = await import('node:fs');
|
|
1548
|
+
const { resolve } = await import('node:path');
|
|
1549
|
+
const p = cmd.path?.trim();
|
|
1550
|
+
if (!p) {
|
|
1551
|
+
queuedAppend({ id: `cd-${Date.now()}`, kind: 'text', text: `cwd: ${cwd}\nusage: /cd <path>`, role: 'assistant' });
|
|
1552
|
+
}
|
|
1553
|
+
else {
|
|
1554
|
+
const abs = resolve(cwd, p);
|
|
1555
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
1556
|
+
queuedAppend({ id: `cd-err-${Date.now()}`, kind: 'error', message: `not a directory: ${p}` });
|
|
1557
|
+
}
|
|
1558
|
+
else {
|
|
1559
|
+
cwd = abs;
|
|
1560
|
+
queuedAppend({ id: `cd2-${Date.now()}`, kind: 'text', text: `cwd → ${abs} (header refreshes on restart)`, role: 'assistant' });
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
case 'attach': {
|
|
1566
|
+
if (!cmd.file) {
|
|
1567
|
+
queuedAppend({ id: `att-${Date.now()}`, kind: 'text', text: 'usage: /attach <file>', role: 'assistant' });
|
|
1568
|
+
}
|
|
1569
|
+
else {
|
|
1570
|
+
try {
|
|
1571
|
+
const r = await registry.execute('read_file', { path: cmd.file }, { cwd, env: process.env, nonInteractive: true });
|
|
1572
|
+
if (!r.ok) {
|
|
1573
|
+
queuedAppend({ id: `att-err-${Date.now()}`, kind: 'error', message: `attach failed: ${r.error.message ?? 'read error'}` });
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
const body = String(r.value.content ?? '');
|
|
1577
|
+
attachedFiles.set(cmd.file, body.length);
|
|
1578
|
+
queuedAppend({ id: `att2-${Date.now()}`, kind: 'text', text: `attached ${cmd.file} (${body.length} chars) — in context for future prompts`, role: 'assistant' });
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
catch (err) {
|
|
1582
|
+
queuedAppend({ id: `att-err2-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
case 'drop': {
|
|
1588
|
+
if (!cmd.file) {
|
|
1589
|
+
attachedFiles.clear();
|
|
1590
|
+
queuedAppend({ id: `drop-${Date.now()}`, kind: 'text', text: 'dropped all attached files', role: 'assistant' });
|
|
1591
|
+
}
|
|
1592
|
+
else if (attachedFiles.delete(cmd.file)) {
|
|
1593
|
+
queuedAppend({ id: `drop2-${Date.now()}`, kind: 'text', text: `dropped ${cmd.file}`, role: 'assistant' });
|
|
1594
|
+
}
|
|
1595
|
+
else {
|
|
1596
|
+
queuedAppend({ id: `drop-err-${Date.now()}`, kind: 'error', message: `not attached: ${cmd.file}` });
|
|
1597
|
+
}
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
case 'files': {
|
|
1601
|
+
queuedAppend({ id: `files-${Date.now()}`, kind: 'text', text: attachedFiles.size === 0 ? 'No files in context (attach via /attach, @path, or /mention).' : `Files in context:\n${[...attachedFiles.entries()].map(([f, n]) => ` ${f} (${n} chars)`).join('\n')}`, role: 'assistant' });
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
case 'image': {
|
|
1605
|
+
if (!cmd.path) {
|
|
1606
|
+
queuedAppend({ id: `img-${Date.now()}`, kind: 'text', text: 'usage: /image <path> (or @<image> inline in a prompt)', role: 'assistant' });
|
|
1607
|
+
}
|
|
1608
|
+
else {
|
|
1609
|
+
attachedFiles.set(cmd.path, 0);
|
|
1610
|
+
queuedAppend({ id: `img2-${Date.now()}`, kind: 'text', text: `attached image ${cmd.path} — reference it in your next prompt`, role: 'assistant' });
|
|
1611
|
+
}
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
case 'paste': {
|
|
1615
|
+
try {
|
|
1616
|
+
const { execSync } = await import('node:child_process');
|
|
1617
|
+
const probe = process.platform === 'win32' ? 'powershell -NoProfile -Command Get-Clipboard' : process.platform === 'darwin' ? 'pbpaste' : 'xclip -o -selection clipboard';
|
|
1618
|
+
const text = execSync(probe, { encoding: 'utf-8' }).trim();
|
|
1619
|
+
if (!text) {
|
|
1620
|
+
queuedAppend({ id: `paste-${Date.now()}`, kind: 'text', text: 'Clipboard is empty.', role: 'assistant' });
|
|
1621
|
+
}
|
|
1622
|
+
else {
|
|
1623
|
+
queuedAppend({ id: `paste2-${Date.now()}`, kind: 'text', text: `pasted ${text.length} chars into context:\n${text.slice(0, 3000)}`, role: 'assistant' });
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
queuedAppend({ id: `paste-err-${Date.now()}`, kind: 'error', message: 'clipboard unavailable on this system' });
|
|
1628
|
+
}
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
case 'ls': {
|
|
1632
|
+
try {
|
|
1633
|
+
const r = await registry.execute('list_directory', { path: cmd.path || '.' }, { cwd, env: process.env, nonInteractive: true });
|
|
1634
|
+
if (!r.ok) {
|
|
1635
|
+
queuedAppend({ id: `ls-err-${Date.now()}`, kind: 'error', message: `ls failed: ${r.error.message ?? 'error'}` });
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
const v = r.value;
|
|
1639
|
+
const lines = (v.entries ?? []).map((e) => ` ${e.type === 'directory' ? e.name + '/' : e.name}`);
|
|
1640
|
+
queuedAppend({ id: `ls2-${Date.now()}`, kind: 'text', text: `${cmd.path || '.'}:\n${outCap(lines.join('\n') || '(empty)')}`, role: 'assistant' });
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
catch (err) {
|
|
1644
|
+
queuedAppend({ id: `ls-err2-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1645
|
+
}
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
case 'tree': {
|
|
1649
|
+
try {
|
|
1650
|
+
const { readdirSync, statSync } = await import('node:fs');
|
|
1651
|
+
const { join, relative } = await import('node:path');
|
|
1652
|
+
const root = cmd.path ? join(cwd, cmd.path) : cwd;
|
|
1653
|
+
const out = [];
|
|
1654
|
+
const skip = new Set(['node_modules', '.git', 'dist', '.klyro', '.next']);
|
|
1655
|
+
const walk = (dir, depth) => {
|
|
1656
|
+
if (depth > 3 || out.length > 100)
|
|
1657
|
+
return;
|
|
1658
|
+
let entries = [];
|
|
1659
|
+
try {
|
|
1660
|
+
entries = readdirSync(dir);
|
|
1661
|
+
}
|
|
1662
|
+
catch {
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
for (const e of entries) {
|
|
1666
|
+
if (skip.has(e))
|
|
1667
|
+
continue;
|
|
1668
|
+
const full = join(dir, e);
|
|
1669
|
+
let isDir = false;
|
|
1670
|
+
try {
|
|
1671
|
+
isDir = statSync(full).isDirectory();
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
out.push(`${' '.repeat(depth)}${isDir ? e + '/' : e}`);
|
|
1677
|
+
if (isDir)
|
|
1678
|
+
walk(full, depth + 1);
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
walk(root, 0);
|
|
1682
|
+
queuedAppend({ id: `tree-${Date.now()}`, kind: 'text', text: `${relative(cwd, root) || '.'}/\n${out.join('\n').slice(0, 4000)}`, role: 'assistant' });
|
|
1683
|
+
}
|
|
1684
|
+
catch (err) {
|
|
1685
|
+
queuedAppend({ id: `tree-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1686
|
+
}
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
case 'search': {
|
|
1690
|
+
if (!cmd.query) {
|
|
1691
|
+
queuedAppend({ id: `search-${Date.now()}`, kind: 'text', text: 'usage: /search <query>', role: 'assistant' });
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
try {
|
|
1695
|
+
const r = await registry.execute('grep', { pattern: cmd.query, maxResults: 50 }, { cwd, env: process.env, nonInteractive: true });
|
|
1696
|
+
if (!r.ok) {
|
|
1697
|
+
queuedAppend({ id: `search-err-${Date.now()}`, kind: 'error', message: `search failed: ${r.error.message ?? 'error'}` });
|
|
1698
|
+
}
|
|
1699
|
+
else {
|
|
1700
|
+
queuedAppend({ id: `search2-${Date.now()}`, kind: 'text', text: `Results for "${cmd.query}":\n${outCap(JSON.stringify(r.value, null, 2))}`, role: 'assistant' });
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
catch (err) {
|
|
1704
|
+
queuedAppend({ id: `search-err2-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
case 'web': {
|
|
1710
|
+
if (!cmd.url) {
|
|
1711
|
+
queuedAppend({ id: `web-${Date.now()}`, kind: 'text', text: 'usage: /web <url>', role: 'assistant' });
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
try {
|
|
1715
|
+
const ctrl = new AbortController();
|
|
1716
|
+
const t = setTimeout(() => ctrl.abort(), 15_000);
|
|
1717
|
+
const res = await fetch(cmd.url, { signal: ctrl.signal });
|
|
1718
|
+
clearTimeout(t);
|
|
1719
|
+
const text = (await res.text()).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 6000);
|
|
1720
|
+
queuedAppend({ id: `web2-${Date.now()}`, kind: 'text', text: `${cmd.url} [${res.status}]:\n${text || '(no text content)'}`, role: 'assistant' });
|
|
1721
|
+
}
|
|
1722
|
+
catch (err) {
|
|
1723
|
+
queuedAppend({ id: `web-err-${Date.now()}`, kind: 'error', message: `fetch failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
case 'read': {
|
|
1729
|
+
if (!cmd.path) {
|
|
1730
|
+
queuedAppend({ id: `read-${Date.now()}`, kind: 'text', text: 'usage: /read <path>', role: 'assistant' });
|
|
1731
|
+
}
|
|
1732
|
+
else {
|
|
1733
|
+
try {
|
|
1734
|
+
const r = await registry.execute('read_file', { path: cmd.path }, { cwd, env: process.env, nonInteractive: true });
|
|
1735
|
+
if (!r.ok) {
|
|
1736
|
+
queuedAppend({ id: `read-err-${Date.now()}`, kind: 'error', message: `read failed: ${r.error.message ?? 'error'}` });
|
|
1737
|
+
}
|
|
1738
|
+
else {
|
|
1739
|
+
queuedAppend({ id: `read2-${Date.now()}`, kind: 'text', text: `${cmd.path}:\n${outCap(String(r.value.content ?? ''))}`, role: 'assistant' });
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
catch (err) {
|
|
1743
|
+
queuedAppend({ id: `read-err2-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
case 'map': {
|
|
1749
|
+
try {
|
|
1750
|
+
const r = await registry.execute('repo_map', {}, { cwd, env: process.env, nonInteractive: true });
|
|
1751
|
+
if (!r.ok) {
|
|
1752
|
+
queuedAppend({ id: `map-err-${Date.now()}`, kind: 'error', message: `map failed: ${r.error.message ?? 'error'}` });
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
queuedAppend({ id: `map2-${Date.now()}`, kind: 'text', text: `Repository map:\n${outCap(typeof r.value === 'string' ? r.value : JSON.stringify(r.value, null, 2))}`, role: 'assistant' });
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
catch (err) {
|
|
1759
|
+
queuedAppend({ id: `map-err2-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1760
|
+
}
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
case 'tokens': {
|
|
1764
|
+
const { getModelInfo } = await import('../providers/model-info.js');
|
|
1765
|
+
const info = getModelInfo(model);
|
|
1766
|
+
const inp = lastStatus?.usageInput ?? 0;
|
|
1767
|
+
const outp = lastStatus?.usageOutput ?? 0;
|
|
1768
|
+
const total = inp + outp;
|
|
1769
|
+
const pct = ((total / info.contextWindow) * 100).toFixed(1);
|
|
1770
|
+
queuedAppend({ id: `tokens-${Date.now()}`, kind: 'text', text: `Tokens — ${model} (window ${info.contextWindow.toLocaleString()}):\n in ${inp.toLocaleString()} / out ${outp.toLocaleString()} / total ${total.toLocaleString()} (${pct}%)`, role: 'assistant' });
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
case 'commit': {
|
|
1774
|
+
if (!cmd.message) {
|
|
1775
|
+
queuedAppend({ id: `commit-${Date.now()}`, kind: 'text', text: 'usage: /commit <message> (commits staged changes only — stage with git add first)', role: 'assistant' });
|
|
1776
|
+
}
|
|
1777
|
+
else {
|
|
1778
|
+
try {
|
|
1779
|
+
const st = await execShell('git status --porcelain');
|
|
1780
|
+
const staged = st.stdout.split('\n').filter((l) => /^[MADRC]/.test(l));
|
|
1781
|
+
if (staged.length === 0) {
|
|
1782
|
+
queuedAppend({ id: `commit2-${Date.now()}`, kind: 'text', text: 'Nothing staged — stage changes with `git add` first.', role: 'assistant' });
|
|
1783
|
+
}
|
|
1784
|
+
else {
|
|
1785
|
+
const safeMsg = cmd.message.replace(/"/g, "'");
|
|
1786
|
+
const v = await execShell(`git commit -m "${safeMsg}"`);
|
|
1787
|
+
queuedAppend({ id: `commit3-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `committed ${staged.length} file(s):\n${outCap(v.stdout)}` : `commit failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
catch (err) {
|
|
1791
|
+
queuedAppend({ id: `commit-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
case 'push': {
|
|
1797
|
+
try {
|
|
1798
|
+
queuedAppend({ id: `push-run-${Date.now()}`, kind: 'text', text: '[push] running `git push`...', role: 'assistant' });
|
|
1799
|
+
const v = await execShell('git push', 300_000);
|
|
1800
|
+
queuedAppend({ id: `push-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `[push] done:\n${outCap(v.stdout + v.stderr)}` : `[push] failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
|
|
1801
|
+
}
|
|
1802
|
+
catch (err) {
|
|
1803
|
+
queuedAppend({ id: `push-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1804
|
+
}
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
case 'pull': {
|
|
1808
|
+
try {
|
|
1809
|
+
const v = await execShell('git pull --ff-only', 300_000);
|
|
1810
|
+
queuedAppend({ id: `pull-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `[pull] done:\n${outCap(v.stdout + v.stderr)}` : `[pull] failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
|
|
1811
|
+
}
|
|
1812
|
+
catch (err) {
|
|
1813
|
+
queuedAppend({ id: `pull-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1814
|
+
}
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
case 'pr': {
|
|
1818
|
+
try {
|
|
1819
|
+
const hasGh = await execShell('gh --version').then(() => true).catch(() => false);
|
|
1820
|
+
if (!hasGh) {
|
|
1821
|
+
queuedAppend({ id: `pr-${Date.now()}`, kind: 'text', text: 'gh CLI not installed — install from https://cli.github.com then use /pr [create|status|view].', role: 'assistant' });
|
|
1822
|
+
}
|
|
1823
|
+
else {
|
|
1824
|
+
const v = await execShell(`gh pr ${cmd.args || 'status'}`);
|
|
1825
|
+
queuedAppend({ id: `pr2-${Date.now()}`, kind: 'text', text: outCap(v.stdout + v.stderr) || '(no output)', role: 'assistant' });
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
catch (err) {
|
|
1829
|
+
queuedAppend({ id: `pr-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1830
|
+
}
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
case 'issue': {
|
|
1834
|
+
try {
|
|
1835
|
+
const hasGh = await execShell('gh --version').then(() => true).catch(() => false);
|
|
1836
|
+
if (!hasGh) {
|
|
1837
|
+
queuedAppend({ id: `issue-${Date.now()}`, kind: 'text', text: 'gh CLI not installed — install from https://cli.github.com.', role: 'assistant' });
|
|
1838
|
+
}
|
|
1839
|
+
else {
|
|
1840
|
+
const v = await execShell(cmd.id ? `gh issue view ${cmd.id}` : 'gh issue status');
|
|
1841
|
+
queuedAppend({ id: `issue2-${Date.now()}`, kind: 'text', text: outCap(v.stdout + v.stderr) || '(no output)', role: 'assistant' });
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
catch (err) {
|
|
1845
|
+
queuedAppend({ id: `issue-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1846
|
+
}
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
case 'editor': {
|
|
1850
|
+
const file = cmd.file?.trim();
|
|
1851
|
+
const ed = process.env.EDITOR ?? process.env.VISUAL ?? (process.platform === 'win32' ? 'notepad' : 'vi');
|
|
1852
|
+
if (!file) {
|
|
1853
|
+
queuedAppend({ id: `ed-${Date.now()}`, kind: 'text', text: `editor: ${ed}\nusage: /editor <file>`, role: 'assistant' });
|
|
1854
|
+
}
|
|
1855
|
+
else {
|
|
1856
|
+
try {
|
|
1857
|
+
const { startBackground } = await import('../tools/shell/background.js');
|
|
1858
|
+
const { resolve } = await import('node:path');
|
|
1859
|
+
const abs = resolve(cwd, file);
|
|
1860
|
+
const opener = process.platform === 'win32' ? `start "" "${abs}"` : process.platform === 'darwin' ? `open "${abs}"` : `xdg-open "${abs}"`;
|
|
1861
|
+
startBackground(opener, cwd);
|
|
1862
|
+
queuedAppend({ id: `ed2-${Date.now()}`, kind: 'text', text: `opened ${abs} in background`, role: 'assistant' });
|
|
1863
|
+
}
|
|
1864
|
+
catch (err) {
|
|
1865
|
+
queuedAppend({ id: `ed-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
case 'keymap':
|
|
1871
|
+
case 'vim':
|
|
1872
|
+
case 'theme':
|
|
1873
|
+
case 'statusline':
|
|
1874
|
+
case 'output-style': {
|
|
1875
|
+
const key = cmd.kind === 'keymap' ? 'klyro.keymap' : cmd.kind === 'vim' ? 'klyro.vim' : cmd.kind === 'theme' ? 'klyro.theme' : cmd.kind === 'statusline' ? 'klyro.statusline' : 'klyro.outputStyle';
|
|
1876
|
+
const val = (cmd.kind === 'keymap' ? cmd.name : cmd.kind === 'vim' ? cmd.state : cmd.kind === 'theme' ? cmd.name : cmd.kind === 'statusline' ? cmd.format : cmd.style)?.trim();
|
|
1877
|
+
const { runConfig } = await import('./config.js');
|
|
1878
|
+
const capture = async (args) => {
|
|
1879
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
1880
|
+
let out = '';
|
|
1881
|
+
process.stdout.write = ((c) => { out += String(c); return true; });
|
|
1882
|
+
try {
|
|
1883
|
+
await runConfig(args);
|
|
1884
|
+
}
|
|
1885
|
+
finally {
|
|
1886
|
+
process.stdout.write = orig;
|
|
1887
|
+
}
|
|
1888
|
+
return out;
|
|
1889
|
+
};
|
|
1890
|
+
if (!val) {
|
|
1891
|
+
const out = await capture(['get', key]);
|
|
1892
|
+
queuedAppend({ id: `${cmd.kind}-${Date.now()}`, kind: 'text', text: out.trim() || `${cmd.kind}: (not set)\nusage: /${cmd.kind} <value>`, role: 'assistant' });
|
|
1893
|
+
}
|
|
1894
|
+
else {
|
|
1895
|
+
await capture(['set', key, val]);
|
|
1896
|
+
queuedAppend({ id: `${cmd.kind}2-${Date.now()}`, kind: 'text', text: `${cmd.kind} set to ${val}`, role: 'assistant' });
|
|
1897
|
+
}
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
case 'debug': {
|
|
1901
|
+
const info = [
|
|
1902
|
+
`klyro debug:`,
|
|
1903
|
+
` node ${process.version} platform ${process.platform}/${process.arch}`,
|
|
1904
|
+
` cwd ${cwd}`,
|
|
1905
|
+
` provider ${currentProvider} ${currentBaseUrl}`,
|
|
1906
|
+
` model ${model} effort ${effortLevel} maxSteps ${currentMaxSteps} fast ${fastMode ? 'on' : 'off'}`,
|
|
1907
|
+
` mode ${displayMode} agent ${activeAgent}`,
|
|
1908
|
+
` apiKey: ${currentApiKey ? 'set (' + currentApiKey.length + ' chars)' : 'empty'}`,
|
|
1909
|
+
` session ${tuiSessionId?.slice(0, 8) ?? '(none)'} attached ${attachedFiles.size} aliases ${aliases.size} prompts ${savedPrompts.size}`,
|
|
1910
|
+
];
|
|
1911
|
+
queuedAppend({ id: `debug-${Date.now()}`, kind: 'text', text: info.join('\n'), role: 'assistant' });
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
case 'whoami': {
|
|
1915
|
+
let user = process.env.USER ?? process.env.USERNAME ?? 'unknown';
|
|
1916
|
+
try {
|
|
1917
|
+
user = (await import('node:os')).userInfo().username;
|
|
1918
|
+
}
|
|
1919
|
+
catch { /* keep env */ }
|
|
1920
|
+
queuedAppend({ id: `who-${Date.now()}`, kind: 'text', text: `user: ${user}\nprovider: ${currentProvider} (${currentBaseUrl})\nmodel: ${model}`, role: 'assistant' });
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
case 'reload': {
|
|
1924
|
+
try {
|
|
1925
|
+
const ctx = await buildLevel6Context({ cwd });
|
|
1926
|
+
ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
|
|
1927
|
+
const md = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
|
|
1928
|
+
klyroBlock = md ? `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>` : '';
|
|
1929
|
+
queuedAppend({ id: `reload-${Date.now()}`, kind: 'text', text: 'reloaded project context + KLYRO.md', role: 'assistant' });
|
|
1930
|
+
}
|
|
1931
|
+
catch (err) {
|
|
1932
|
+
queuedAppend({ id: `reload-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1933
|
+
}
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
case 'reset': {
|
|
1937
|
+
effortLevel = 'medium';
|
|
1938
|
+
currentMaxSteps = EFFORT_STEPS.medium;
|
|
1939
|
+
fastMode = false;
|
|
1940
|
+
displayMode = 'default';
|
|
1941
|
+
policy.config.mode = 'default';
|
|
1942
|
+
activeAgent = 'default';
|
|
1943
|
+
verboseMode = false;
|
|
1944
|
+
detailsMode = false;
|
|
1945
|
+
rawMode = false;
|
|
1946
|
+
queuedStatus({ maxSteps: currentMaxSteps });
|
|
1947
|
+
queuedAppend({ id: `reset-${Date.now()}`, kind: 'text', text: 'settings reset to defaults (effort medium, mode default, agent default)', role: 'assistant' });
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
case 'bug': {
|
|
1951
|
+
queuedAppend({ id: `bug-${Date.now()}`, kind: 'text', text: `Report a bug: https://github.com/Siddu-lingampelli/Klyro/issues\nInclude: klyro --version, node ${process.version}, provider ${currentProvider}, steps to reproduce.`, role: 'assistant' });
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
case 'changelog': {
|
|
1955
|
+
try {
|
|
1956
|
+
const v = await execShell('git log --oneline -15');
|
|
1957
|
+
queuedAppend({ id: `cl-${Date.now()}`, kind: 'text', text: v.exitCode === 0 && v.stdout.trim() ? `Recent changes:\n${v.stdout.slice(0, 3000)}` : 'No git history here — see npm klyro versions for releases.', role: 'assistant' });
|
|
1958
|
+
}
|
|
1959
|
+
catch (err) {
|
|
1960
|
+
queuedAppend({ id: `cl-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
1961
|
+
}
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
case 'promptcmd': {
|
|
1965
|
+
const rest = cmd.args?.trim() ?? '';
|
|
1966
|
+
if (!rest) {
|
|
1967
|
+
queuedAppend({ id: `pc-${Date.now()}`, kind: 'text', text: savedPrompts.size === 0 ? 'No saved prompts.\nusage: /prompt save <name> <text> | /prompt <name>' : `Saved prompts:\n${[...savedPrompts.keys()].map((k) => ` ${k}`).join('\n')}\nrun via /prompt <name>`, role: 'assistant' });
|
|
1968
|
+
}
|
|
1969
|
+
else if (rest.startsWith('save ')) {
|
|
1970
|
+
const m = /^save\s+(\S+)\s+([\s\S]+)$/.exec(rest);
|
|
1971
|
+
if (!m) {
|
|
1972
|
+
queuedAppend({ id: `pc-err-${Date.now()}`, kind: 'error', message: 'usage: /prompt save <name> <text>' });
|
|
1973
|
+
}
|
|
1974
|
+
else {
|
|
1975
|
+
savedPrompts.set(m[1], m[2]);
|
|
1976
|
+
persistMap(promptFile, savedPrompts);
|
|
1977
|
+
queuedAppend({ id: `pc2-${Date.now()}`, kind: 'text', text: `saved prompt "${m[1]}"`, role: 'assistant' });
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
else {
|
|
1981
|
+
const name = rest.split(/\s+/)[0];
|
|
1982
|
+
const text = savedPrompts.get(name);
|
|
1983
|
+
if (!text) {
|
|
1984
|
+
queuedAppend({ id: `pc-err2-${Date.now()}`, kind: 'error', message: `no saved prompt: ${name}` });
|
|
1985
|
+
}
|
|
1986
|
+
else {
|
|
1987
|
+
await runWithBridge(text);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
case 'alias': {
|
|
1993
|
+
const rest = cmd.args?.trim() ?? '';
|
|
1994
|
+
if (!rest) {
|
|
1995
|
+
queuedAppend({ id: `al-${Date.now()}`, kind: 'text', text: aliases.size === 0 ? 'No aliases.\nusage: /alias <name> <command>' : `Aliases:\n${[...aliases.entries()].map(([k, v]) => ` /${k} → ${v}`).join('\n')}`, role: 'assistant' });
|
|
1996
|
+
}
|
|
1997
|
+
else {
|
|
1998
|
+
const m = /^(\S+)\s+([\s\S]+)$/.exec(rest);
|
|
1999
|
+
if (!m) {
|
|
2000
|
+
queuedAppend({ id: `al-err-${Date.now()}`, kind: 'error', message: 'usage: /alias <name> <command>' });
|
|
2001
|
+
}
|
|
2002
|
+
else {
|
|
2003
|
+
aliases.set(m[1], m[2]);
|
|
2004
|
+
persistMap(aliasFile, aliases);
|
|
2005
|
+
queuedAppend({ id: `al2-${Date.now()}`, kind: 'text', text: `alias /${m[1]} → ${m[2]}`, role: 'assistant' });
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
case 'commands': {
|
|
2011
|
+
const { COMMAND_DEFS } = await import('./slash/parser.js');
|
|
2012
|
+
const custom = [...aliases.keys()].map((k) => ` /${k} (alias)`);
|
|
2013
|
+
void custom;
|
|
2014
|
+
queuedAppend({ id: `cmds-${Date.now()}`, kind: 'text', text: `Commands (${COMMAND_DEFS.length + aliases.size + savedPrompts.size}):\n${COMMAND_DEFS.map((d) => ` /${d.name} — ${d.hint}`).join('\n')}${aliases.size > 0 ? `\ncustom aliases:\n${[...aliases.entries()].map(([k, v]) => ` /${k} → ${v}`).join('\n')}` : ''}${savedPrompts.size > 0 ? `\nsaved prompts: ${[...savedPrompts.keys()].join(', ')}` : ''}`, role: 'assistant' });
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
case 'env': {
|
|
2018
|
+
const { existsSync } = await import('node:fs');
|
|
2019
|
+
void existsSync;
|
|
2020
|
+
const a = cmd.args?.trim() ?? '';
|
|
2021
|
+
if (!a) {
|
|
2022
|
+
const rows = Object.keys(process.env).filter((k) => k.startsWith('KLYRO_') || ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'NO_COLOR'].includes(k)).map((k) => {
|
|
2023
|
+
const v = process.env[k] ?? '';
|
|
2024
|
+
const secret = /KEY|SECRET|TOKEN/.test(k);
|
|
2025
|
+
return ` ${k}=${secret ? (v ? '(set, ' + v.length + ' chars)' : '(empty)') : v || '(empty)'}`;
|
|
2026
|
+
});
|
|
2027
|
+
queuedAppend({ id: `env-${Date.now()}`, kind: 'text', text: rows.length === 0 ? 'No KLYRO_* env set.\nusage: /env KEY=value (session-only)' : `Environment:\n${rows.join('\n')}\nset via /env KEY=value (session-only)`, role: 'assistant' });
|
|
2028
|
+
}
|
|
2029
|
+
else {
|
|
2030
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(a);
|
|
2031
|
+
if (!m) {
|
|
2032
|
+
queuedAppend({ id: `env-err-${Date.now()}`, kind: 'error', message: 'usage: /env KEY=value' });
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
process.env[m[1]] = m[2];
|
|
2036
|
+
queuedAppend({ id: `env2-${Date.now()}`, kind: 'text', text: `set ${m[1]} (session-only)`, role: 'assistant' });
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
case 'deps': {
|
|
2042
|
+
try {
|
|
2043
|
+
const { readFileSync } = await import('node:fs');
|
|
2044
|
+
const { join } = await import('node:path');
|
|
2045
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
2046
|
+
const d = Object.entries(pkg.dependencies ?? {}).map(([k, v]) => ` ${k}@${v}`);
|
|
2047
|
+
const dd = Object.entries(pkg.devDependencies ?? {}).map(([k, v]) => ` ${k}@${v} (dev)`);
|
|
2048
|
+
queuedAppend({ id: `deps-${Date.now()}`, kind: 'text', text: d.length + dd.length === 0 ? 'No dependencies in package.json.' : `Dependencies:\n${[...d, ...dd].join('\n').slice(0, 3000)}`, role: 'assistant' });
|
|
2049
|
+
}
|
|
2050
|
+
catch {
|
|
2051
|
+
queuedAppend({ id: `deps-err-${Date.now()}`, kind: 'error', message: 'no package.json in cwd' });
|
|
2052
|
+
}
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
case 'install': {
|
|
2056
|
+
try {
|
|
2057
|
+
const { existsSync } = await import('node:fs');
|
|
2058
|
+
const { join } = await import('node:path');
|
|
2059
|
+
const mgr = existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm install' : existsSync(join(cwd, 'package-lock.json')) ? 'npm install' : existsSync(join(cwd, 'bun.lockb')) ? 'bun install' : existsSync(join(cwd, 'yarn.lock')) ? 'yarn install' : 'npm install';
|
|
2060
|
+
queuedAppend({ id: `inst-run-${Date.now()}`, kind: 'text', text: `[install] running \`${mgr}\`...`, role: 'assistant' });
|
|
2061
|
+
const v = await execShell(mgr, 600_000);
|
|
2062
|
+
queuedAppend({ id: `inst-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? '[install] done' : `[install] failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
|
|
2063
|
+
}
|
|
2064
|
+
catch (err) {
|
|
2065
|
+
queuedAppend({ id: `inst-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
2066
|
+
}
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
case 'prompt': {
|
|
2070
|
+
// Regular prompts never reach onSlash — no-op for exhaustiveness.
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
case 'unknown': {
|
|
2074
|
+
// Alias expansion: /alias <name> <command> redirects unknown commands
|
|
2075
|
+
const m = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(cmd.raw.trim());
|
|
2076
|
+
const target = m ? aliases.get(m[1].toLowerCase()) : undefined;
|
|
2077
|
+
if (m && target) {
|
|
2078
|
+
const extra = m[2] ? ` ${m[2]}` : '';
|
|
2079
|
+
await handleSlash(parse(target + extra));
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
queuedAppend({
|
|
2083
|
+
id: `unk-${Date.now()}`,
|
|
2084
|
+
kind: 'error',
|
|
2085
|
+
message: `unknown command: ${cmd.raw} (try /help or /commands)`,
|
|
2086
|
+
});
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
732
2089
|
}
|
|
733
2090
|
}
|
|
734
2091
|
// Keep process alive until user quits; resolve on unmount or SIGINT.
|