@zhijiewang/openharness 2.5.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,1107 +3,33 @@
3
3
  *
4
4
  * Commands are processed in the REPL before being sent to the LLM.
5
5
  * If input starts with /, it's treated as a command.
6
+ *
7
+ * Command implementations are split into domain-specific modules:
8
+ * session.ts — /clear, /compact, /export, /history, /browse, /resume, /fork, /pin, /unpin
9
+ * git.ts — /diff, /undo, /rewind, /commit, /log
10
+ * info.ts — /help, /cost, /status, /config, /files, /model, /memory, /doctor, /context, /mcp, /init
11
+ * settings.ts — /theme, /companion, /fast, /keys, /effort, /sandbox, /permissions, /allowed-tools
12
+ * ai.ts — /plan, /review, /roles, /agents, /plugins, /btw, /loop, /cybergotchi
13
+ * skills.ts — /skill-create, /skill-delete, /skill-edit, /skill-search, /skill-install
6
14
  */
7
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
- import { homedir } from "node:os";
9
- import { dirname, join } from "node:path";
10
- import { gitBranch, gitCommit, gitDiff, gitLog, gitUndo, isGitRepo, isInMergeOrRebase } from "../git/index.js";
11
- import { readOhConfig } from "../harness/config.js";
12
- import { estimateMessageTokens } from "../harness/context-warning.js";
13
- import { getContextWindow } from "../harness/cost.js";
14
- import { loadKeybindings } from "../harness/keybindings.js";
15
- import { createSession, listSessions, loadSession, saveSession } from "../harness/session.js";
16
- import { connectedMcpServers } from "../mcp/loader.js";
17
- import { compressMessages } from "../query/index.js";
18
- import { handleCybergotchiCommand } from "./cybergotchi.js";
15
+ import { registerAICommands } from "./ai.js";
16
+ import { registerGitCommands } from "./git.js";
17
+ import { registerInfoCommands } from "./info.js";
18
+ import { registerSessionCommands } from "./session.js";
19
+ import { registerSettingsCommands } from "./settings.js";
20
+ import { registerSkillCommands } from "./skills.js";
21
+ // ── Command Registry ──
19
22
  const commands = new Map();
20
23
  function register(name, description, handler) {
21
24
  commands.set(name, { description, handler });
22
25
  }
23
- // ── Register all commands ──
24
- register("help", "Show available commands", () => {
25
- const categories = {
26
- Session: ["clear", "compact", "export", "history", "browse", "resume", "fork", "pin", "unpin"],
27
- Git: ["diff", "undo", "rewind", "commit", "log"],
28
- Info: [
29
- "help",
30
- "cost",
31
- "status",
32
- "config",
33
- "files",
34
- "model",
35
- "memory",
36
- "doctor",
37
- "context",
38
- "mcp",
39
- "mcp-registry",
40
- "init",
41
- ],
42
- Settings: ["theme", "vim", "companion", "fast", "keys", "effort", "sandbox", "permissions", "allowed-tools"],
43
- AI: ["plan", "review", "roles", "agents", "plugins", "btw", "loop"],
44
- Pet: ["cybergotchi"],
45
- };
46
- const lines = [];
47
- for (const [category, names] of Object.entries(categories)) {
48
- lines.push(`${category}:`);
49
- for (const name of names) {
50
- const cmd = commands.get(name);
51
- if (cmd)
52
- lines.push(` /${name.padEnd(12)} ${cmd.description}`);
53
- }
54
- lines.push("");
55
- }
56
- // Include any uncategorized commands
57
- const categorized = new Set(Object.values(categories).flat());
58
- const uncategorized = [...commands.keys()].filter((n) => !categorized.has(n));
59
- if (uncategorized.length > 0) {
60
- lines.push("Other:");
61
- for (const name of uncategorized) {
62
- const cmd = commands.get(name);
63
- lines.push(` /${name.padEnd(12)} ${cmd.description}`);
64
- }
65
- }
66
- return { output: lines.join("\n"), handled: true };
67
- });
68
- register("clear", "Clear conversation history", () => {
69
- return { output: "Conversation cleared.", handled: true, clearMessages: true };
70
- });
71
- register("cost", "Show session cost and token usage", (_args, ctx) => {
72
- const lines = [
73
- `Cost: $${ctx.totalCost.toFixed(4)}`,
74
- `Tokens: ${ctx.totalInputTokens.toLocaleString()} input, ${ctx.totalOutputTokens.toLocaleString()} output`,
75
- `Model: ${ctx.model}`,
76
- `Session: ${ctx.sessionId}`,
77
- ];
78
- return { output: lines.join("\n"), handled: true };
79
- });
80
- register("status", "Show session status", (_args, ctx) => {
81
- const lines = [
82
- `Model: ${ctx.model}`,
83
- `Mode: ${ctx.permissionMode}`,
84
- `Messages: ${ctx.messages.length}`,
85
- `Cost: $${ctx.totalCost.toFixed(4)}`,
86
- `Session: ${ctx.sessionId}`,
87
- ];
88
- if (isGitRepo()) {
89
- lines.push(`Git branch: ${gitBranch()}`);
90
- }
91
- const mcp = connectedMcpServers();
92
- if (mcp.length > 0) {
93
- lines.push(`MCP servers: ${mcp.join(", ")}`);
94
- }
95
- return { output: lines.join("\n"), handled: true };
96
- });
97
- register("diff", "Show uncommitted git changes", () => {
98
- if (!isGitRepo()) {
99
- return { output: "Not a git repository.", handled: true };
100
- }
101
- const diff = gitDiff();
102
- return { output: diff || "No uncommitted changes.", handled: true };
103
- });
104
- register("undo", "Undo last AI commit", () => {
105
- if (!isGitRepo()) {
106
- return { output: "Not a git repository.", handled: true };
107
- }
108
- const success = gitUndo();
109
- return {
110
- output: success ? "Undone. Last AI commit reverted." : "Nothing to undo (last commit wasn't from OpenHarness).",
111
- handled: true,
112
- };
113
- });
114
- register("rewind", "Restore files from checkpoint (interactive picker or last)", (args) => {
115
- const { rewindLastCheckpoint, listCheckpoints, checkpointCount } = require("../harness/checkpoints.js");
116
- const checkpoints = listCheckpoints();
117
- if (checkpoints.length === 0) {
118
- return { output: "No checkpoints available. Checkpoints are created before file modifications.", handled: true };
119
- }
120
- const idx = args.trim();
121
- // /rewind (no args) — show checkpoint list
122
- if (!idx) {
123
- const lines = [`Checkpoints (${checkpoints.length}):\n`];
124
- for (let i = checkpoints.length - 1; i >= 0; i--) {
125
- const cp = checkpoints[i];
126
- const age = Math.round((Date.now() - cp.timestamp) / 60_000);
127
- lines.push(` ${i + 1}. [${age}m ago] ${cp.description}`);
128
- lines.push(` Files: ${cp.files.join(", ")}`);
129
- }
130
- lines.push("");
131
- lines.push("Usage: /rewind <number> to restore a specific checkpoint");
132
- lines.push(" /rewind last to restore the most recent");
133
- return { output: lines.join("\n"), handled: true };
134
- }
135
- // /rewind last — restore most recent
136
- if (idx === "last") {
137
- const cp = rewindLastCheckpoint();
138
- if (!cp)
139
- return { output: "No checkpoints.", handled: true };
140
- return {
141
- output: `Rewound: ${cp.description}\nRestored ${cp.files.length} file(s): ${cp.files.join(", ")}\n${checkpointCount()} checkpoint(s) remaining.`,
142
- handled: true,
143
- };
144
- }
145
- // /rewind <n> — restore specific checkpoint
146
- const num = parseInt(idx, 10);
147
- if (Number.isNaN(num) || num < 1 || num > checkpoints.length) {
148
- return { output: `Invalid checkpoint number. Use 1-${checkpoints.length}.`, handled: true };
149
- }
150
- // Rewind to specific checkpoint (restore all from that point)
151
- let restored = 0;
152
- while (checkpointCount() >= num) {
153
- const cp = rewindLastCheckpoint();
154
- if (!cp)
155
- break;
156
- restored++;
157
- if (checkpointCount() < num)
158
- break;
159
- }
160
- return {
161
- output: `Rewound ${restored} checkpoint(s) to point #${num}.\n${checkpointCount()} checkpoint(s) remaining.`,
162
- handled: true,
163
- };
164
- });
165
- register("commit", "Create a git commit", (args) => {
166
- if (!isGitRepo()) {
167
- return { output: "Not a git repository.", handled: true };
168
- }
169
- const message = args.trim() || "manual commit";
170
- const success = gitCommit(message);
171
- return { output: success ? `Committed: ${message}` : "Nothing to commit.", handled: true };
172
- });
173
- register("log", "Show recent git commits", () => {
174
- if (!isGitRepo()) {
175
- return { output: "Not a git repository.", handled: true };
176
- }
177
- return { output: gitLog(10) || "No commits yet.", handled: true };
178
- });
179
- register("history", "List recent sessions or search across them", (args) => {
180
- const parts = args.trim().split(/\s+/);
181
- const sessionDir = join(homedir(), ".oh", "sessions");
182
- if (parts[0] === "search" && parts[1]) {
183
- const term = parts.slice(1).join(" ").toLowerCase();
184
- const sessions = listSessions(sessionDir);
185
- const matches = [];
186
- for (const s of sessions) {
187
- try {
188
- const full = loadSession(s.id, sessionDir);
189
- const hit = full.messages.find((m) => typeof m.content === "string" && m.content.toLowerCase().includes(term));
190
- if (hit) {
191
- const date = new Date(s.updatedAt).toLocaleDateString();
192
- matches.push(` ${s.id} ${date} ${s.model || "?"}`);
193
- }
194
- }
195
- catch {
196
- /* skip */
197
- }
198
- }
199
- if (matches.length === 0)
200
- return { output: `No sessions matching "${term}".`, handled: true };
201
- return { output: `Sessions matching "${term}":\n${matches.join("\n")}`, handled: true };
202
- }
203
- const n = parseInt(parts[0] ?? "10", 10) || 10;
204
- const sessions = listSessions(sessionDir).slice(0, n);
205
- if (sessions.length === 0)
206
- return { output: "No saved sessions.", handled: true };
207
- const lines = sessions.map((s) => {
208
- const date = new Date(s.updatedAt).toLocaleDateString();
209
- const cost = s.cost > 0 ? ` $${s.cost.toFixed(4)}` : "";
210
- return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}`;
211
- });
212
- return { output: `Recent sessions (use /resume <id> to continue):\n${lines.join("\n")}`, handled: true };
213
- });
214
- register("theme", "Switch theme (dark/light)", (args) => {
215
- const theme = args.trim().toLowerCase();
216
- if (theme !== "dark" && theme !== "light") {
217
- return { output: "Usage: /theme dark or /theme light", handled: true };
218
- }
219
- return { output: `__SWITCH_THEME__:${theme}`, handled: true };
220
- });
221
- register("browse", "Open interactive session browser", () => {
222
- return { output: "__OPEN_SESSION_BROWSER__", handled: true };
223
- });
224
- register("resume", "Resume a saved session by ID", (args) => {
225
- const id = args.trim();
226
- if (!id)
227
- return { output: "Usage: /resume <session-id>", handled: true };
228
- const sessionDir = join(homedir(), ".oh", "sessions");
229
- try {
230
- loadSession(id, sessionDir); // validate it exists
231
- return { output: `Resuming session ${id}...`, handled: true, resumeSessionId: id };
232
- }
233
- catch {
234
- return { output: `Session not found: ${id}`, handled: true };
235
- }
236
- });
237
- register("fork", "Fork current session (create a branch you can resume later)", (_args, ctx) => {
238
- const forked = createSession("", "");
239
- forked.messages = [...ctx.messages];
240
- saveSession(forked);
241
- return {
242
- output: `Session forked as ${forked.id}. Resume later with: oh --resume ${forked.id}`,
243
- handled: true,
244
- };
245
- });
246
- register("files", "List files in context", (_args, ctx) => {
247
- const files = new Set();
248
- for (const msg of ctx.messages) {
249
- // Extract file paths from tool calls
250
- if (msg.toolCalls) {
251
- for (const tc of msg.toolCalls) {
252
- const path = tc.arguments?.file_path ?? tc.arguments?.path;
253
- if (path)
254
- files.add(String(path));
255
- }
256
- }
257
- }
258
- if (files.size === 0)
259
- return { output: "No files in context yet.", handled: true };
260
- return { output: `Files in context:\n${[...files].map((f) => ` ${f}`).join("\n")}`, handled: true };
261
- });
262
- register("model", "Switch model (e.g., /model llama3.2 or /model ollama/llama3.2)", (args, ctx) => {
263
- const model = args.trim();
264
- if (!model)
265
- return { output: "Usage: /model <model-name> (prefix with provider/ to switch providers)", handled: true };
266
- // Detect the provider implied by the new model
267
- let newProviderName;
268
- if (model.includes("/")) {
269
- newProviderName = model.split("/")[0];
270
- }
271
- else {
272
- // No prefix — assume current session's provider (don't guess)
273
- newProviderName = ctx.providerName;
274
- }
275
- if (newProviderName !== ctx.providerName) {
276
- return {
277
- output: `Cannot switch to '${model}': requires the '${newProviderName}' provider but current session uses '${ctx.providerName}'.\nRestart with: oh --model ${newProviderName}/${model.includes("/") ? model.split("/").slice(1).join("/") : model}`,
278
- handled: true,
279
- };
280
- }
281
- // Strip provider prefix if present (provider is already correct)
282
- const modelName = model.includes("/") ? model.split("/").slice(1).join("/") : model;
283
- return { output: `Switched to ${modelName}.`, handled: true, newModel: modelName };
284
- });
285
- register("compact", "Compress conversation history (optional: focus keyword or message number)", (args, ctx) => {
286
- const focus = args.trim();
287
- const before = ctx.messages.length;
288
- const targetTokens = Math.floor(getContextWindow(ctx.model) * 0.6);
289
- if (focus && /^\d+$/.test(focus)) {
290
- // Numeric: compact messages 1-N, keep N+1 onwards
291
- const cutoff = parseInt(focus, 10);
292
- if (cutoff < 1 || cutoff >= before) {
293
- return { output: `Invalid: use 1-${before - 1}`, handled: true };
294
- }
295
- const kept = ctx.messages.slice(cutoff);
296
- return {
297
- output: `Compacted: removed first ${cutoff} messages, kept ${kept.length}.`,
298
- handled: true,
299
- compactedMessages: kept,
300
- };
301
- }
302
- if (focus) {
303
- // Keyword focus: compress but preserve messages containing the keyword
304
- const focusLower = focus.toLowerCase();
305
- const preserved = ctx.messages.filter((m) => m.content.toLowerCase().includes(focusLower) || m.meta?.pinned);
306
- const others = ctx.messages.filter((m) => !m.content.toLowerCase().includes(focusLower) && !m.meta?.pinned);
307
- const compactedOthers = compressMessages(others, targetTokens);
308
- const merged = [...compactedOthers, ...preserved].sort((a, b) => a.timestamp - b.timestamp);
309
- return {
310
- output: `Compacted with focus "${focus}": ${before} → ${merged.length} messages (preserved ${preserved.length} matching).`,
311
- handled: true,
312
- compactedMessages: merged,
313
- };
314
- }
315
- // Default: compress everything
316
- const compacted = compressMessages(ctx.messages, targetTokens);
317
- const dropped = before - compacted.length;
318
- return {
319
- output: `Compacted: ${before} → ${compacted.length} messages (dropped ${dropped} older turns).`,
320
- handled: true,
321
- compactedMessages: compacted,
322
- };
323
- });
324
- register("export", "Export conversation to file", (_args, ctx) => {
325
- const lines = ctx.messages
326
- .filter((m) => m.role === "user" || m.role === "assistant")
327
- .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
328
- .join("\n\n");
329
- const filename = `.oh/export-${ctx.sessionId}.md`;
330
- try {
331
- mkdirSync(dirname(filename), { recursive: true });
332
- writeFileSync(filename, lines);
333
- return { output: `Exported to ${filename}`, handled: true };
334
- }
335
- catch {
336
- return { output: `Export failed. Content:\n\n${lines.slice(0, 500)}`, handled: true };
337
- }
338
- });
339
- register("config", "Show current configuration", (_args, ctx) => {
340
- const saved = readOhConfig();
341
- const lines = ["Configuration:"];
342
- if (saved) {
343
- lines.push(` Provider: ${saved.provider}`);
344
- lines.push(` Model: ${saved.model}`);
345
- lines.push(` Permission: ${saved.permissionMode}`);
346
- if (saved.baseUrl)
347
- lines.push(` Base URL: ${saved.baseUrl}`);
348
- if (saved.apiKey)
349
- lines.push(` API key: ${"*".repeat(8)}...`);
350
- lines.push(` Source: .oh/config.yaml`);
351
- }
352
- else {
353
- lines.push(` No .oh/config.yaml found — run oh init to create one`);
354
- }
355
- lines.push("");
356
- lines.push(` Active model: ${ctx.model}`);
357
- lines.push(` Permission mode: ${ctx.permissionMode}`);
358
- const mcp = connectedMcpServers();
359
- if (mcp.length > 0)
360
- lines.push(` MCP servers: ${mcp.join(", ")}`);
361
- return { output: lines.join("\n"), handled: true };
362
- });
363
- register("memory", "View and search memories in .oh/memory/", (args) => {
364
- const memDir = join(process.cwd(), ".oh", "memory");
365
- if (!existsSync(memDir)) {
366
- return { output: "No .oh/memory/ directory found. Memories are stored there by the AI.", handled: true };
367
- }
368
- const term = args.trim().toLowerCase();
369
- let files;
370
- try {
371
- files = readdirSync(memDir).filter((f) => f.endsWith(".md"));
372
- }
373
- catch {
374
- return { output: "Could not read .oh/memory/", handled: true };
375
- }
376
- if (files.length === 0)
377
- return { output: "No memories stored yet.", handled: true };
378
- if (term) {
379
- // Search mode
380
- const matches = [];
381
- for (const file of files) {
382
- try {
383
- const content = readFileSync(join(memDir, file), "utf-8");
384
- if (content.toLowerCase().includes(term)) {
385
- const firstLine = content.split("\n").find((l) => l.trim() && !l.startsWith("---")) ?? file;
386
- matches.push(` ${file.padEnd(30)} ${firstLine.slice(0, 50)}`);
387
- }
388
- }
389
- catch {
390
- /* skip */
391
- }
392
- }
393
- if (matches.length === 0)
394
- return { output: `No memories matching "${term}".`, handled: true };
395
- return { output: `Memories matching "${term}":\n${matches.join("\n")}`, handled: true };
396
- }
397
- // List mode
398
- const lines = [`Memories (${files.length}) — use /memory <term> to search:\n`];
399
- for (const file of files) {
400
- try {
401
- const content = readFileSync(join(memDir, file), "utf-8");
402
- const nameLine = content.match(/^name:\s*(.+)$/m)?.[1] ?? file.replace(".md", "");
403
- const typeLine = content.match(/^type:\s*(.+)$/m)?.[1] ?? "?";
404
- const descLine = content.match(/^description:\s*(.+)$/m)?.[1] ?? "";
405
- lines.push(` [${typeLine.padEnd(8)}] ${nameLine.padEnd(24)} ${descLine.slice(0, 40)}`);
406
- }
407
- catch {
408
- lines.push(` ${file}`);
409
- }
410
- }
411
- return { output: lines.join("\n"), handled: true };
412
- });
413
- register("companion", "Toggle companion visibility (off/on)", (args) => {
414
- const arg = args.trim().toLowerCase();
415
- if (arg === "off")
416
- return { output: "__COMPANION_OFF__", handled: true };
417
- if (arg === "on")
418
- return { output: "__COMPANION_ON__", handled: true };
419
- return { output: "Usage: /companion off or /companion on", handled: true };
420
- });
421
- register("cybergotchi", "Manage your cybergotchi — feed · pet · rest · status · rename · reset", (args) => {
422
- return handleCybergotchiCommand(args);
423
- });
424
- register("roles", "List available agent specialization roles", () => {
425
- const { listRoles } = require("../agents/roles.js");
426
- const roles = listRoles();
427
- const lines = ["Available agent roles:\n"];
428
- for (const role of roles) {
429
- lines.push(` ${role.id.padEnd(18)} ${role.name}`);
430
- lines.push(` ${"".padEnd(18)} ${role.description}`);
431
- if (role.suggestedTools?.length) {
432
- lines.push(` ${"".padEnd(18)} Tools: ${role.suggestedTools.join(", ")}`);
433
- }
434
- lines.push("");
435
- }
436
- lines.push("Usage: Agent({ subagent_type: 'code-reviewer', prompt: '...' })");
437
- return { output: lines.join("\n"), handled: true };
438
- });
439
- register("agents", "Discover running openHarness agents on this machine", () => {
440
- const { discoverAgents } = require("../services/a2a.js");
441
- const agents = discoverAgents();
442
- if (agents.length === 0) {
443
- return {
444
- output: "No other openHarness agents running on this machine.\n\nOther oh sessions will appear here automatically via the A2A protocol.",
445
- handled: true,
446
- };
447
- }
448
- const lines = [`Running Agents (${agents.length}):\n`];
449
- for (const agent of agents) {
450
- const age = Math.round((Date.now() - agent.registeredAt) / 60_000);
451
- lines.push(` ${agent.name}`);
452
- lines.push(` ID: ${agent.id}`);
453
- lines.push(` Provider: ${agent.provider ?? "unknown"} / ${agent.model ?? "unknown"}`);
454
- lines.push(` Dir: ${agent.workingDir ?? "unknown"}`);
455
- lines.push(` Endpoint: ${agent.endpoint.type}${agent.endpoint.port ? `:${agent.endpoint.port}` : ""}`);
456
- lines.push(` Uptime: ${age}m`);
457
- lines.push(` Caps: ${agent.capabilities.map((c) => c.name).join(", ")}`);
458
- lines.push("");
459
- }
460
- lines.push("Send messages with: Agent({ prompt: 'ask the other agent...', allowed_tools: ['SendMessage'] })");
461
- return { output: lines.join("\n"), handled: true };
462
- });
463
- register("fast", "Toggle fast mode (optimized for speed)", () => {
464
- return { output: "", handled: true, toggleFastMode: true };
465
- });
466
- register("keys", "Show keyboard shortcuts", () => {
467
- const bindings = loadKeybindings();
468
- const shortcuts = [
469
- "Keyboard Shortcuts:",
470
- "",
471
- " Navigation:",
472
- " ↑ / ↓ Input history",
473
- " Tab Cycle autocomplete suggestions",
474
- " Escape Cancel / clear autocomplete",
475
- " Ctrl+C Abort current request / exit",
476
- " Scroll wheel Scroll through messages",
477
- "",
478
- " Editing:",
479
- " Alt+Enter Insert newline (multi-line input)",
480
- " Ctrl+A Move cursor to start of line",
481
- " Ctrl+E Move cursor to end of line",
482
- "",
483
- " Display:",
484
- " Ctrl+K Toggle code block expansion",
485
- " Ctrl+O Toggle thinking block expansion",
486
- " Tab (in output) Expand/collapse tool call output",
487
- "",
488
- " Custom keybindings (~/.oh/keybindings.json):",
489
- ];
490
- for (const b of bindings) {
491
- shortcuts.push(` ${b.key.padEnd(18)} ${b.action}`);
492
- }
493
- shortcuts.push("", " Session:", " /vim Toggle Vim mode", " /browse Interactive session browser", " /theme dark|light Switch theme");
494
- return { output: shortcuts.join("\n"), handled: true };
495
- });
496
- register("sandbox", "Show sandbox status and restrictions", () => {
497
- const { sandboxStatus } = require("../harness/sandbox.js");
498
- return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
499
- });
500
- register("effort", "Set reasoning effort level (low/medium/high/max)", (args) => {
501
- const level = args.trim().toLowerCase();
502
- const valid = ["low", "medium", "high", "max"];
503
- if (!valid.includes(level)) {
504
- return {
505
- output: `Usage: /effort <${valid.join("|")}>\n\nlow — fast, minimal reasoning\nmedium — balanced (default)\nhigh — thorough reasoning\nmax — maximum depth (Opus only)`,
506
- handled: true,
507
- };
508
- }
509
- return { output: `Effort level set to: ${level}`, handled: true };
510
- });
511
- register("btw", "Ask a side question (ephemeral, no tools, not saved to history)", (args) => {
512
- if (!args.trim()) {
513
- return { output: "Usage: /btw <your question>", handled: true };
514
- }
515
- // Side questions are answered directly without tools or history
516
- // The output is shown but NOT added to conversation history
517
- return {
518
- output: `[btw] ${args.trim()}`,
519
- handled: false,
520
- prependToPrompt: `[Side question — answer briefly without using any tools. This is ephemeral and not part of the main conversation.]\n\n${args.trim()}`,
521
- };
522
- });
523
- register("loop", "Run a prompt repeatedly with self-paced timing", (args) => {
524
- const input = args.trim();
525
- if (!input) {
526
- return {
527
- output: "Usage: /loop [interval] <prompt or /command>\n\nExamples:\n /loop check if the build passed\n /loop 5m /review\n\nOmit the interval to let the model self-pace via ScheduleWakeup.",
528
- handled: true,
529
- };
530
- }
531
- // Check for optional interval prefix like "5m", "30s", "2h"
532
- const intervalMatch = input.match(/^(\d+)(s|m|h)\s+(.+)$/);
533
- let intervalMs = null;
534
- let prompt;
535
- if (intervalMatch) {
536
- const [, num, unit, rest] = intervalMatch;
537
- const multipliers = { s: 1000, m: 60000, h: 3600000 };
538
- intervalMs = parseInt(num, 10) * multipliers[unit];
539
- prompt = rest;
540
- }
541
- else {
542
- prompt = input;
543
- }
544
- const mode = intervalMs
545
- ? `Fixed interval: ${intervalMatch[1]}${intervalMatch[2]}`
546
- : "Dynamic (model self-paces via ScheduleWakeup)";
547
- return {
548
- output: `[loop] ${mode}\nPrompt: ${prompt}`,
549
- handled: false,
550
- prependToPrompt: intervalMs
551
- ? `You are in LOOP MODE (fixed interval: ${intervalMs / 1000}s). Execute this task, then use ScheduleWakeup with delaySeconds=${intervalMs / 1000} to schedule the next iteration.\n\nTask: ${prompt}`
552
- : `You are in LOOP MODE (dynamic pacing). Execute this task, then use ScheduleWakeup to schedule the next iteration at an appropriate interval. Choose your delay based on what you're waiting for. Omit the ScheduleWakeup call to end the loop.\n\nTask: ${prompt}`,
553
- };
554
- });
555
- register("plan", "Enter plan mode", (_args, _ctx) => {
556
- const task = _args.trim();
557
- if (!task) {
558
- return { output: "Usage: /plan <what you want to build>", handled: true };
559
- }
560
- return {
561
- output: `[plan mode] ${task}`,
562
- handled: false,
563
- prependToPrompt: `You are in PLAN MODE. Do NOT write any code yet.\n\n1. Call EnterPlanMode to create a plan file in .oh/plans/\n2. Write your detailed implementation plan to that file (files to create/modify, key functions/types, data flow, edge cases)\n3. When the plan is complete, call ExitPlanMode to signal readiness for review\n\nTask: ${task}`,
564
- };
565
- });
566
- register("review", "Review recent code changes", () => {
567
- if (!isGitRepo()) {
568
- return { output: "Not a git repository.", handled: true };
569
- }
570
- const diff = gitDiff();
571
- if (!diff)
572
- return { output: "No changes to review.", handled: true };
573
- const lines = diff.split("\n").length;
574
- return {
575
- output: `[review] ${lines} lines of diff`,
576
- handled: false,
577
- prependToPrompt: `Review these uncommitted changes and give feedback on correctness, style, and potential issues:\n\n\`\`\`diff\n${diff}\n\`\`\`\n\n`,
578
- };
579
- });
580
- register("doctor", "Run diagnostic health checks", (_args, ctx) => {
581
- const lines = [];
582
- const issues = [];
583
- lines.push("─── Health Check ───");
584
- lines.push("");
585
- // Provider & Model
586
- lines.push(` Provider: ${ctx.providerName || "⚠ not set"}`);
587
- lines.push(` Model: ${ctx.model || "⚠ not set"}`);
588
- lines.push(` Permission: ${ctx.permissionMode}`);
589
- if (!ctx.model)
590
- issues.push("No model configured. Use --model or set in .oh/config.yaml");
591
- // API Key check
592
- const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
593
- const hasOpenAIKey = !!process.env.OPENAI_API_KEY;
594
- if (ctx.providerName === "anthropic" && !hasAnthropicKey) {
595
- issues.push("ANTHROPIC_API_KEY not set. Run: export ANTHROPIC_API_KEY=sk-...");
596
- }
597
- if (ctx.providerName === "openai" && !hasOpenAIKey) {
598
- issues.push("OPENAI_API_KEY not set. Run: export OPENAI_API_KEY=sk-...");
599
- }
600
- if (ctx.providerName === "ollama") {
601
- lines.push(` Ollama: checking...`);
602
- try {
603
- // Quick check if Ollama is running (sync fetch isn't available, just note it)
604
- lines.pop();
605
- lines.push(` Ollama: configured (ensure 'ollama serve' is running)`);
606
- }
607
- catch {
608
- issues.push("Ollama may not be running. Start with: ollama serve");
609
- }
610
- }
611
- // Context window
612
- const ctxWindow = getContextWindow(ctx.model);
613
- const totalTokens = estimateMessageTokens(ctx.messages);
614
- const usage = ctxWindow > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
615
- lines.push(` Context: ~${totalTokens.toLocaleString()} / ${ctxWindow.toLocaleString()} tokens (${usage}%)`);
616
- if (usage > 80)
617
- issues.push(`Context ${usage}% full. Consider /compact to free space.`);
618
- // Git
619
- lines.push("");
620
- if (isGitRepo()) {
621
- lines.push(` Git: ✓ (branch: ${gitBranch()})`);
622
- if (isInMergeOrRebase()) {
623
- lines.push(` ⚠ Merge/rebase in progress`);
624
- issues.push("Git merge/rebase in progress. Resolve before making changes.");
625
- }
626
- }
627
- else {
628
- lines.push(` Git: ✗ (not a git repo)`);
629
- }
630
- // MCP
631
- const mcp = connectedMcpServers();
632
- lines.push(` MCP servers: ${mcp.length > 0 ? mcp.join(", ") : "none"}`);
633
- // Config
634
- const cfg = readOhConfig();
635
- lines.push(` Config: ${cfg ? ".oh/config.yaml ✓" : "not found"}`);
636
- // Session
637
- lines.push("");
638
- lines.push(` Session: ${ctx.sessionId}`);
639
- lines.push(` Messages: ${ctx.messages.length}`);
640
- lines.push(` Cost: $${ctx.totalCost.toFixed(4)}`);
641
- // Disk space & storage
642
- try {
643
- const ohDir = join(homedir(), ".oh");
644
- if (existsSync(ohDir)) {
645
- const sessionsDir = join(ohDir, "sessions");
646
- const sessCount = existsSync(sessionsDir)
647
- ? readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).length
648
- : 0;
649
- lines.push(` Sessions: ${sessCount} saved`);
650
- if (sessCount > 80)
651
- issues.push(`${sessCount} saved sessions. Consider cleaning old ones.`);
652
- // Memory stats
653
- const memDir = join(ohDir, "memory");
654
- const memCount = existsSync(memDir) ? readdirSync(memDir).filter((f) => f.endsWith(".md")).length : 0;
655
- lines.push(` Memories: ${memCount} global`);
656
- // Cron stats
657
- const cronDir = join(ohDir, "crons");
658
- const cronCount = existsSync(cronDir) ? readdirSync(cronDir).filter((f) => f.endsWith(".json")).length : 0;
659
- lines.push(` Cron tasks: ${cronCount}`);
660
- }
661
- }
662
- catch {
663
- /* ignore */
664
- }
665
- // Project-level stats
666
- try {
667
- const projMemDir = join(".oh", "memory");
668
- const projMemCount = existsSync(projMemDir) ? readdirSync(projMemDir).filter((f) => f.endsWith(".md")).length : 0;
669
- if (projMemCount > 0)
670
- lines.push(` Project mems: ${projMemCount}`);
671
- const skillsDir = join(".oh", "skills");
672
- const skillCount = existsSync(skillsDir) ? readdirSync(skillsDir).filter((f) => f.endsWith(".md")).length : 0;
673
- if (skillCount > 0)
674
- lines.push(` Skills: ${skillCount}`);
675
- }
676
- catch {
677
- /* ignore */
678
- }
679
- // Global config
680
- const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
681
- lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
682
- // Verification config
683
- try {
684
- const { getVerificationConfig } = require("../harness/verification.js");
685
- const vCfg = getVerificationConfig();
686
- if (vCfg?.enabled) {
687
- lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
688
- }
689
- else {
690
- lines.push(` Verification: off (no rules detected)`);
691
- }
692
- }
693
- catch {
694
- /* ignore */
695
- }
696
- // Tools
697
- lines.push("");
698
- lines.push(` Tools: ${ctx.messages.length > 0 ? "ready" : "loaded"}`);
699
- // Node.js version
700
- lines.push(` Node.js: ${process.version}`);
701
- const [major] = process.version.slice(1).split(".").map(Number);
702
- if (major && major < 18)
703
- issues.push(`Node.js ${process.version} is below minimum (18+). Upgrade Node.js.`);
704
- // Issues summary
705
- if (issues.length > 0) {
706
- lines.push("");
707
- lines.push("─── Issues Found ───");
708
- for (const issue of issues) {
709
- lines.push(` ⚠ ${issue}`);
710
- }
711
- }
712
- else {
713
- lines.push("");
714
- lines.push(" ✓ No issues found");
715
- }
716
- return { output: lines.join("\n"), handled: true };
717
- });
718
- register("context", "Show context window usage breakdown", (_args, ctx) => {
719
- const ctxWindow = getContextWindow(ctx.model);
720
- // Categorize messages by type
721
- let userTokens = 0, assistantTokens = 0, toolTokens = 0, systemTokens = 0;
722
- for (const msg of ctx.messages) {
723
- const tokens = Math.ceil((msg.content?.length ?? 0) / 4);
724
- switch (msg.role) {
725
- case "user":
726
- userTokens += tokens;
727
- break;
728
- case "assistant":
729
- assistantTokens += tokens;
730
- break;
731
- case "tool":
732
- toolTokens += tokens;
733
- break;
734
- case "system":
735
- systemTokens += tokens;
736
- break;
737
- }
738
- }
739
- const totalTokens = userTokens + assistantTokens + toolTokens + systemTokens;
740
- const freeTokens = ctxWindow - totalTokens;
741
- const usage = totalTokens / ctxWindow;
742
- // Visual bar (30 chars wide)
743
- const barWidth = 30;
744
- const filled = Math.round(usage * barWidth);
745
- const bar = "\u2588".repeat(filled) + "\u2591".repeat(barWidth - filled);
746
- const pct = (n) => `${((n / ctxWindow) * 100).toFixed(1)}%`;
747
- const pad = (s, n) => s.padEnd(n);
748
- const lines = [
749
- `Context Window (${ctxWindow.toLocaleString()} tokens):`,
750
- "",
751
- ` ${pad("User messages:", 20)} ${userTokens.toLocaleString().padStart(8)} tokens (${pct(userTokens)})`,
752
- ` ${pad("Assistant:", 20)} ${assistantTokens.toLocaleString().padStart(8)} tokens (${pct(assistantTokens)})`,
753
- ` ${pad("Tool results:", 20)} ${toolTokens.toLocaleString().padStart(8)} tokens (${pct(toolTokens)})`,
754
- ` ${pad("System/info:", 20)} ${systemTokens.toLocaleString().padStart(8)} tokens (${pct(systemTokens)})`,
755
- "",
756
- ` ${pad("Total used:", 20)} ${totalTokens.toLocaleString().padStart(8)} tokens (${pct(totalTokens)})`,
757
- ` ${pad("Free:", 20)} ${freeTokens.toLocaleString().padStart(8)} tokens (${pct(freeTokens)})`,
758
- "",
759
- ` ${bar} ${Math.round(usage * 100)}%`,
760
- "",
761
- ` Messages: ${ctx.messages.length} | Compress at: ${Math.round(ctxWindow * 0.8).toLocaleString()} (80%)`,
762
- ];
763
- return { output: lines.join("\n"), handled: true };
764
- });
765
- register("mcp", "Show MCP server status", () => {
766
- const mcp = connectedMcpServers();
767
- if (mcp.length === 0) {
768
- return {
769
- output: "No MCP servers connected.\nConfigure in .oh/config.yaml under mcpServers.\nRun /mcp-registry to browse available servers.",
770
- handled: true,
771
- };
772
- }
773
- const lines = [`MCP Servers (${mcp.length} connected):\n`];
774
- for (const name of mcp) {
775
- lines.push(` ✓ ${name}`);
776
- }
777
- lines.push("\nRun /mcp-registry to browse and add more servers.");
778
- return { output: lines.join("\n"), handled: true };
779
- });
780
- register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
781
- const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require("../mcp/registry.js");
782
- const query = args.trim();
783
- if (!query) {
784
- // Show full registry
785
- const output = `MCP Server Registry (${MCP_REGISTRY.length} servers)\n${"─".repeat(50)}\n\n${formatRegistry()}\n\nUsage:\n /mcp-registry <name> Show install config for a server\n /mcp-registry <keyword> Search by name, description, or category`;
786
- return { output, handled: true };
787
- }
788
- // Search or show specific server
789
- const results = searchRegistry(query);
790
- if (results.length === 0) {
791
- return { output: `No MCP servers found matching "${query}".`, handled: true };
792
- }
793
- if (results.length === 1) {
794
- // Show install instructions
795
- const entry = results[0];
796
- const config = generateConfigBlock(entry);
797
- const envNote = entry.envVars?.length
798
- ? `\n\nRequired environment variables:\n${entry.envVars.map((v) => ` - ${v}`).join("\n")}`
799
- : "";
800
- return {
801
- output: `${entry.name} — ${entry.description}\nPackage: ${entry.package}\nRisk: ${entry.riskLevel ?? "medium"}${envNote}\n\nAdd to .oh/config.yaml under mcpServers:\n\n${config}`,
802
- handled: true,
803
- };
804
- }
805
- // Multiple results
806
- return { output: `Found ${results.length} servers:\n\n${formatRegistry(results)}`, handled: true };
807
- });
808
- function setPinned(args, ctx, pinned) {
809
- const idx = parseInt(args.trim(), 10);
810
- if (Number.isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
811
- return { output: `Usage: /${pinned ? "pin" : "unpin"} <message-number> (1-${ctx.messages.length})`, handled: true };
812
- }
813
- // Immutable update — replace message with updated meta
814
- const updatedMessages = ctx.messages.map((m, i) => (i === idx - 1 ? { ...m, meta: { ...m.meta, pinned } } : m));
815
- return {
816
- output: `Message #${idx} ${pinned ? "pinned" : "unpinned"}.`,
817
- handled: true,
818
- compactedMessages: updatedMessages,
819
- };
820
- }
821
- register("pin", "Pin a message (survives /compact)", (args, ctx) => setPinned(args, ctx, true));
822
- register("unpin", "Unpin a message", (args, ctx) => setPinned(args, ctx, false));
823
- register("plugins", "Manage plugins: list, search, install, uninstall, marketplace", (args) => {
824
- const { discoverPlugins, discoverSkills } = require("../harness/plugins.js");
825
- const { searchMarketplace, installPlugin, uninstallPlugin, getInstalledPlugins, listMarketplaces, addMarketplace, removeMarketplace, formatMarketplaceSearch, formatInstalledPlugins, } = require("../harness/marketplace.js");
826
- const parts = args.trim().split(/\s+/);
827
- const subcommand = parts[0] ?? "";
828
- const rest = parts.slice(1).join(" ");
829
- // /plugins marketplace add <source>
830
- if (subcommand === "marketplace") {
831
- const action = parts[1];
832
- const source = parts.slice(2).join(" ");
833
- if (action === "add" && source) {
834
- const mp = addMarketplace(source);
835
- if (mp)
836
- return { output: `Added marketplace "${mp.name}" (${mp.plugins.length} plugins)`, handled: true };
837
- return { output: `Failed to add marketplace from "${source}"`, handled: true };
838
- }
839
- if (action === "remove" && source) {
840
- return {
841
- output: removeMarketplace(source) ? `Removed marketplace "${source}"` : `Marketplace "${source}" not found`,
842
- handled: true,
843
- };
844
- }
845
- // List marketplaces
846
- const mps = listMarketplaces();
847
- if (mps.length === 0) {
848
- return {
849
- output: "No marketplaces configured.\n\nAdd one:\n /plugins marketplace add owner/repo\n /plugins marketplace add https://example.com/plugins",
850
- handled: true,
851
- };
852
- }
853
- const lines = [`Marketplaces (${mps.length}):\n`];
854
- for (const mp of mps) {
855
- lines.push(` ${mp.name} — ${mp.plugins.length} plugins`);
856
- }
857
- return { output: lines.join("\n"), handled: true };
858
- }
859
- // /plugins search <query>
860
- if (subcommand === "search") {
861
- const query = rest || "all";
862
- const results = searchMarketplace(query === "all" ? "" : query);
863
- return { output: formatMarketplaceSearch(results), handled: true };
864
- }
865
- // /plugins install <name>
866
- if (subcommand === "install" && rest) {
867
- const [name, marketplace] = rest.split("@");
868
- const result = installPlugin(name, marketplace);
869
- if (result) {
870
- return {
871
- output: `Installed ${result.name}@${result.version} from ${result.marketplace}\nCached at: ${result.cachePath}`,
872
- handled: true,
873
- };
874
- }
875
- return {
876
- output: `Failed to install "${rest}". Is it listed in a marketplace?\nRun /plugins search ${name} to check.`,
877
- handled: true,
878
- };
879
- }
880
- // /plugins uninstall <name>
881
- if (subcommand === "uninstall" && rest) {
882
- return { output: uninstallPlugin(rest) ? `Uninstalled "${rest}"` : `Plugin "${rest}" not found`, handled: true };
883
- }
884
- // /plugins (no args) — show everything
885
- const plugins = discoverPlugins();
886
- const skills = discoverSkills();
887
- const marketplacePlugins = getInstalledPlugins();
888
- const lines = [];
889
- if (marketplacePlugins.length > 0) {
890
- lines.push(formatInstalledPlugins(marketplacePlugins));
891
- lines.push("");
892
- }
893
- if (plugins.length > 0) {
894
- lines.push(`Local Plugins (${plugins.length}):`);
895
- for (const p of plugins) {
896
- lines.push(` ${p.name}@${p.version} — ${p.description || "no description"}`);
897
- }
898
- lines.push("");
899
- }
900
- if (skills.length > 0) {
901
- lines.push(`Skills (${skills.length}):`);
902
- for (const s of skills) {
903
- lines.push(` ${s.source}:${s.name} — ${s.description || ""}`);
904
- }
905
- lines.push("");
906
- }
907
- if (lines.length === 0) {
908
- lines.push("No plugins or skills installed.");
909
- }
910
- lines.push("");
911
- lines.push("Commands:");
912
- lines.push(" /plugins search <query> Search marketplaces");
913
- lines.push(" /plugins install <name> Install from marketplace");
914
- lines.push(" /plugins uninstall <name> Remove a plugin");
915
- lines.push(" /plugins marketplace add <src> Add a marketplace");
916
- lines.push(" /plugins marketplace List marketplaces");
917
- return { output: lines.join("\n"), handled: true };
918
- });
919
- // ── Project Init ──
920
- register("init", "Initialize project with .oh/ config", () => {
921
- const ohDir = join(process.cwd(), ".oh");
922
- if (existsSync(ohDir)) {
923
- return { output: ".oh/ directory already exists. Project is already initialized.", handled: true };
924
- }
925
- mkdirSync(ohDir, { recursive: true });
926
- const rulesPath = join(ohDir, "RULES.md");
927
- if (!existsSync(rulesPath)) {
928
- writeFileSync(rulesPath, `# Project Rules
929
-
930
- <!-- Add project-specific instructions here. These are loaded into every session. -->
931
- <!-- Examples: coding conventions, testing requirements, deployment guidelines. -->
932
- `);
933
- }
934
- const configPath = join(ohDir, "config.yaml");
935
- if (!existsSync(configPath)) {
936
- writeFileSync(configPath, `# OpenHarness project config
937
- # provider: ollama
938
- # model: llama3
939
- # permissionMode: ask
940
- `);
941
- }
942
- return {
943
- output: `Initialized .oh/ with:\n .oh/RULES.md — project rules\n .oh/config.yaml — project config\n\nEdit these files to customize your project.`,
944
- handled: true,
945
- };
946
- });
947
- // ── Permissions ──
948
- register("permissions", "View or change permission mode", (args, ctx) => {
949
- const mode = args.trim().toLowerCase();
950
- if (!mode) {
951
- return {
952
- output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only`,
953
- handled: true,
954
- };
955
- }
956
- const valid = ["ask", "trust", "deny", "acceptedits", "plan", "auto", "bypasspermissions"];
957
- if (!valid.includes(mode)) {
958
- return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")}`, handled: true };
959
- }
960
- return {
961
- output: `Permission mode set to: ${mode}\n(Note: takes effect for new tool calls in this session)`,
962
- handled: true,
963
- };
964
- });
965
- register("allowed-tools", "View tool permission rules", () => {
966
- const config = readOhConfig();
967
- const rules = config?.toolPermissions;
968
- if (!rules || rules.length === 0) {
969
- return {
970
- output: 'No custom tool permission rules configured.\n\nAdd rules to .oh/config.yaml:\n\ntoolPermissions:\n - tool: Bash\n action: ask\n pattern: "^rm .*"',
971
- handled: true,
972
- };
973
- }
974
- const lines = rules.map((r) => {
975
- const parts = [` ${r.tool}: ${r.action}`];
976
- if (r.pattern)
977
- parts.push(`(pattern: ${r.pattern})`);
978
- return parts.join(" ");
979
- });
980
- return { output: `Tool permission rules:\n${lines.join("\n")}`, handled: true };
981
- });
982
- register("rebuild-sessions", "Rebuild session search index", () => {
983
- // Fire async rebuild, return immediately with status
984
- import("../harness/session-db.js")
985
- .then(({ openSessionDb, rebuildIndex, closeSessionDb }) => {
986
- const db = openSessionDb();
987
- const count = rebuildIndex(db);
988
- closeSessionDb(db);
989
- console.log(`Rebuilt session search index: ${count} sessions indexed.`);
990
- })
991
- .catch((err) => {
992
- console.log(`Failed to rebuild index: ${err.message}`);
993
- });
994
- return { output: "Rebuilding session search index...", handled: true };
995
- });
996
- // ── Skill Management ──
997
- register("skill-create", "Create a new skill file", (args) => {
998
- const name = args.trim();
999
- if (!name)
1000
- return { output: "Usage: /skill-create <name>", handled: true };
1001
- if (name.includes("..") || name.includes("/") || name.includes("\\")) {
1002
- return { output: "Error: Invalid skill name.", handled: true };
1003
- }
1004
- const dir = join(process.cwd(), ".oh", "skills");
1005
- mkdirSync(dir, { recursive: true });
1006
- const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
1007
- const filePath = join(dir, `${slug}.md`);
1008
- if (existsSync(filePath)) {
1009
- return { output: `Skill "${slug}" already exists at ${filePath}`, handled: true };
1010
- }
1011
- const template = `---
1012
- name: ${slug}
1013
- description: TODO — describe what this skill does
1014
- trigger: ${slug}
1015
- ---
1016
-
1017
- # ${name}
1018
-
1019
- ## When to Use
1020
- Describe when this skill should be triggered.
1021
-
1022
- ## Procedure
1023
- 1. Step one
1024
- 2. Step two
1025
- 3. Step three
1026
-
1027
- ## Pitfalls
1028
- - Common mistakes to avoid
1029
-
1030
- ## Verification
1031
- How to confirm the skill worked correctly.
1032
- `;
1033
- writeFileSync(filePath, template);
1034
- return { output: `Created skill: ${filePath}\nEdit the file to customize it.`, handled: true };
1035
- });
1036
- register("skill-delete", "Delete a skill file", (args) => {
1037
- const name = args.trim();
1038
- if (!name)
1039
- return { output: "Usage: /skill-delete <name>", handled: true };
1040
- const { findSkill } = require("../harness/plugins.js");
1041
- const skill = findSkill(name);
1042
- if (!skill)
1043
- return { output: `Skill "${name}" not found.`, handled: true };
1044
- try {
1045
- const { unlinkSync } = require("node:fs");
1046
- unlinkSync(skill.filePath);
1047
- return { output: `Deleted skill: ${skill.filePath}`, handled: true };
1048
- }
1049
- catch (err) {
1050
- return { output: `Error deleting skill: ${err.message}`, handled: true };
1051
- }
1052
- });
1053
- register("skill-edit", "Show skill file path for editing", (args) => {
1054
- const name = args.trim();
1055
- if (!name)
1056
- return { output: "Usage: /skill-edit <name>", handled: true };
1057
- const { findSkill } = require("../harness/plugins.js");
1058
- const skill = findSkill(name);
1059
- if (!skill)
1060
- return { output: `Skill "${name}" not found.`, handled: true };
1061
- return { output: `Skill file: ${skill.filePath}\nEdit this file to update the skill.`, handled: true };
1062
- });
1063
- register("skill-search", "Search the skills registry", (args) => {
1064
- const query = args.trim();
1065
- if (!query)
1066
- return { output: "Usage: /skill-search <query>", handled: true };
1067
- // Async search — fire and return message
1068
- import("../harness/skill-registry.js").then(async ({ fetchRegistry, searchRegistry }) => {
1069
- try {
1070
- const registry = await fetchRegistry();
1071
- const results = searchRegistry(registry, query);
1072
- if (results.length === 0) {
1073
- console.log(`No skills found matching "${query}".`);
1074
- }
1075
- else {
1076
- const lines = results.map((s) => ` ${s.name.padEnd(20)} ${s.description} [${s.tags.join(", ")}]`);
1077
- console.log(`Found ${results.length} skill(s):\n${lines.join("\n")}\n\nInstall: /skill-install <name>`);
1078
- }
1079
- }
1080
- catch (err) {
1081
- console.log(`Registry search failed: ${err.message}`);
1082
- }
1083
- });
1084
- return { output: "Searching skills registry...", handled: true };
1085
- });
1086
- register("skill-install", "Install a skill from the registry", (args) => {
1087
- const name = args.trim();
1088
- if (!name)
1089
- return { output: "Usage: /skill-install <name>", handled: true };
1090
- import("../harness/skill-registry.js").then(async ({ fetchRegistry, installSkill }) => {
1091
- try {
1092
- const registry = await fetchRegistry();
1093
- const skill = registry.skills.find((s) => s.name.toLowerCase() === name.toLowerCase());
1094
- if (!skill) {
1095
- console.log(`Skill "${name}" not found in registry. Try /skill-search first.`);
1096
- return;
1097
- }
1098
- const path = await installSkill(skill);
1099
- console.log(`Installed skill "${skill.name}" to ${path}`);
1100
- }
1101
- catch (err) {
1102
- console.log(`Installation failed: ${err.message}`);
1103
- }
1104
- });
1105
- return { output: `Installing skill "${name}"...`, handled: true };
1106
- });
26
+ // Register all command groups
27
+ registerSessionCommands(register);
28
+ registerGitCommands(register);
29
+ registerInfoCommands(register, () => commands);
30
+ registerSettingsCommands(register);
31
+ registerAICommands(register);
32
+ registerSkillCommands(register);
1107
33
  // ── Command Parser ──
1108
34
  /**
1109
35
  * Check if input is a slash command. If so, execute it.