Skip to content

Commit b10cb34

Browse files
oratisclaude
andauthored
feat(cli): /hooks /permissions /agents /skills /export slash commands (#127)
Adds five read-only inspector slash commands (all from the deferred parity list), reusing existing data sources: - /hooks — events configured in settings.json (matcher + handler types) - /permissions — allow/ask/deny rules + default mode (read-only view) - /agents — sub-agents from .deepcode/agents/ (loadSubAgents) - /skills — built-in + user + project skills (reuses list-cmd.listSkills) - /export — write the current conversation to a markdown file Registered in BUILTIN_COMMANDS; BEHAVIOR_PARITY updated (these + the already- shipped /vim flip from 🔄 to ✅/🟡). Tests: +6 (commands.test.ts) — /hooks empty + populated, /permissions rules, /agents + /skills against a temp project dir, /export writes a file + the empty-history case. CLI suite 91 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 15f61e3 commit b10cb34

3 files changed

Lines changed: 281 additions & 49 deletions

File tree

apps/cli/src/commands.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,90 @@ describe('built-in command behavior', () => {
333333
});
334334
});
335335
});
336+
337+
describe('inspector + export commands', () => {
338+
const reg = new CommandRegistry();
339+
let cwd: string;
340+
beforeEach(async () => {
341+
cwd = await mkdtemp(join(tmpdir(), 'dc-cmds-'));
342+
});
343+
afterEach(async () => {
344+
await rm(cwd, { recursive: true, force: true });
345+
});
346+
347+
it('/hooks lists configured events or reports none', async () => {
348+
const none = await reg.match('/hooks')!.cmd.run([], makeContext());
349+
expect(none.join('\n')).toMatch(/No hooks configured/);
350+
351+
const ctx = makeContext({
352+
settings: {
353+
hooks: { Stop: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo hi' }] }] },
354+
},
355+
});
356+
const out = await reg.match('/hooks')!.cmd.run([], ctx);
357+
expect(out.join('\n')).toMatch(/Stop:/);
358+
expect(out.join('\n')).toMatch(/command \(match: Bash\)/);
359+
});
360+
361+
it('/permissions shows rules + default mode', async () => {
362+
const none = await reg.match('/permissions')!.cmd.run([], makeContext());
363+
expect(none.join('\n')).toMatch(/No permission rules/);
364+
365+
const ctx = makeContext({
366+
settings: {
367+
permissions: { defaultMode: 'plan', allow: ['Bash(npm test:*)'], deny: ['Bash(rm:*)'] },
368+
},
369+
});
370+
const out = (await reg.match('/permissions')!.cmd.run([], ctx)).join('\n');
371+
expect(out).toMatch(/default mode: plan/);
372+
expect(out).toMatch(/Bash\(npm test:\*\)/);
373+
expect(out).toMatch(/Bash\(rm:\*\)/);
374+
});
375+
376+
it('/agents lists a project sub-agent', async () => {
377+
const dir = join(cwd, '.deepcode', 'agents');
378+
const fs = await import('node:fs/promises');
379+
await fs.mkdir(dir, { recursive: true });
380+
await fs.writeFile(
381+
join(dir, 'explorer.md'),
382+
'---\nname: explorer\ndescription: read-only explorer\n---\nExplore.\n',
383+
);
384+
const out = (await reg.match('/agents')!.cmd.run([], makeContext({ cwd }))).join('\n');
385+
expect(out).toMatch(/explorer/);
386+
expect(out).toMatch(/read-only explorer/);
387+
});
388+
389+
it('/skills lists a project skill', async () => {
390+
const dir = join(cwd, '.deepcode', 'skills', 'greet');
391+
const fs = await import('node:fs/promises');
392+
await fs.mkdir(dir, { recursive: true });
393+
await fs.writeFile(
394+
join(dir, 'SKILL.md'),
395+
'---\nname: greet\ndescription: say hi\n---\nGreet.\n',
396+
);
397+
const out = (await reg.match('/skills')!.cmd.run([], makeContext({ cwd }))).join('\n');
398+
expect(out).toMatch(/greet/);
399+
expect(out).toMatch(/\[project\]/);
400+
});
401+
402+
it('/export writes a markdown file and reports the path', async () => {
403+
const fs = await import('node:fs/promises');
404+
const ctx = makeContext({
405+
cwd,
406+
history: [
407+
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
408+
{ role: 'assistant', content: [{ type: 'text', text: 'hi there' }] },
409+
],
410+
});
411+
const out = (await reg.match('/export')!.cmd.run([], ctx)).join('\n');
412+
expect(out).toMatch(/Exported 2 messages/);
413+
const written = await fs.readFile(join(cwd, 'deepcode-sess-xyz.md'), 'utf8');
414+
expect(written).toContain('## User');
415+
expect(written).toContain('hi there');
416+
});
417+
418+
it('/export reports nothing to export with empty history', async () => {
419+
const out = await reg.match('/export')!.cmd.run([], makeContext({ history: [] }));
420+
expect(out.join('\n')).toMatch(/Nothing to export/);
421+
});
422+
});

apps/cli/src/commands.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,146 @@ function restoreCodeMessage(
567567
return `✓ Restored ${target.filePath} from snapshot #${target.seq}`;
568568
}
569569

570+
export const HooksCommand: SlashCommand = {
571+
name: '/hooks',
572+
description: 'List hooks configured in settings.json.',
573+
run(_args, ctx) {
574+
const hooks = ctx.settings.hooks ?? {};
575+
const events = Object.keys(hooks);
576+
if (events.length === 0) {
577+
return ['No hooks configured.', '', 'Add them in settings.json under "hooks".'];
578+
}
579+
const lines = ['Configured hooks:'];
580+
for (const event of events) {
581+
const matchers = hooks[event as keyof typeof hooks] ?? [];
582+
lines.push(` ${event}:`);
583+
for (const m of matchers) {
584+
const match = m.matcher ? ` (match: ${m.matcher})` : '';
585+
const types = m.hooks.map((h) => h.type).join(', ');
586+
lines.push(` - ${types}${match}`);
587+
}
588+
}
589+
return lines;
590+
},
591+
};
592+
593+
export const PermissionsCommand: SlashCommand = {
594+
name: '/permissions',
595+
description: 'Show permission rules from settings.json.',
596+
run(_args, ctx) {
597+
const p = ctx.settings.permissions;
598+
if (!p) return ['No permission rules configured (DeepCode asks before risky tools).'];
599+
const lines = ['Permissions:'];
600+
if (p.defaultMode) lines.push(` default mode: ${p.defaultMode}`);
601+
for (const kind of ['allow', 'ask', 'deny'] as const) {
602+
const rules = p[kind] ?? [];
603+
if (rules.length > 0) {
604+
lines.push(` ${kind}:`);
605+
for (const r of rules) lines.push(` ${r}`);
606+
}
607+
}
608+
if ((p.additionalDirectories ?? []).length > 0) {
609+
lines.push(` additionalDirectories: ${p.additionalDirectories!.join(', ')}`);
610+
}
611+
return lines.length === 1 ? ['Permissions: (no rules; default mode only)'] : lines;
612+
},
613+
};
614+
615+
export const AgentsCommand: SlashCommand = {
616+
name: '/agents',
617+
description: 'List available sub-agents (.deepcode/agents/*.md).',
618+
async run(_args, ctx) {
619+
try {
620+
const { loadSubAgents } = await import('@deepcode/core');
621+
const agents = await loadSubAgents({ cwd: ctx.cwd });
622+
if (agents.length === 0) {
623+
return [
624+
'No sub-agents found.',
625+
'Add one as .deepcode/agents/<name>.md with a name/description frontmatter.',
626+
];
627+
}
628+
const lines = [`Sub-agents (${agents.length}):`];
629+
for (const a of agents) {
630+
lines.push(
631+
` ${a.qualifiedName} [${a.source}]` +
632+
(a.frontmatter.description ? ` — ${a.frontmatter.description}` : ''),
633+
);
634+
}
635+
return lines;
636+
} catch (err) {
637+
return [`(Error loading sub-agents: ${(err as Error).message})`];
638+
}
639+
},
640+
};
641+
642+
export const SkillsCommand: SlashCommand = {
643+
name: '/skills',
644+
description: 'List available skills (built-in + user + project).',
645+
async run(_args, ctx) {
646+
try {
647+
const { listSkills } = await import('./list-cmd.js');
648+
const rows = await listSkills({ cwd: ctx.cwd });
649+
if (rows.length === 0) return ['No skills found.'];
650+
const lines = [`Skills (${rows.length}):`];
651+
for (const s of rows) {
652+
lines.push(` ${s.name} [${s.source}]` + (s.description ? ` — ${s.description}` : ''));
653+
}
654+
return lines;
655+
} catch (err) {
656+
return [`(Error loading skills: ${(err as Error).message})`];
657+
}
658+
},
659+
};
660+
661+
export const ExportCommand: SlashCommand = {
662+
name: '/export',
663+
description: 'Export the current conversation to a markdown file (/export [path]).',
664+
async run(args, ctx) {
665+
const history = ctx.history ?? [];
666+
if (history.length === 0) return ['Nothing to export yet.'];
667+
try {
668+
const fs = await import('node:fs/promises');
669+
const path = await import('node:path');
670+
const target = args[0]
671+
? path.resolve(ctx.cwd, args[0])
672+
: path.join(ctx.cwd, `deepcode-${ctx.sessionId}.md`);
673+
await fs.writeFile(target, historyToMarkdown(history), 'utf8');
674+
return [`✓ Exported ${history.length} messages → ${target}`];
675+
} catch (err) {
676+
return [`(Export failed: ${(err as Error).message})`];
677+
}
678+
},
679+
};
680+
681+
/** Render a conversation as readable markdown (text + tool calls). */
682+
function historyToMarkdown(history: StoredMessage[]): string {
683+
const out: string[] = ['# DeepCode conversation export', ''];
684+
for (const msg of history) {
685+
out.push(`## ${msg.role === 'user' ? 'User' : 'Assistant'}`, '');
686+
for (const block of msg.content) {
687+
if (block.type === 'text') out.push(block.text, '');
688+
else if (block.type === 'thinking') out.push(`> _(thinking)_ ${block.text}`, '');
689+
else if (block.type === 'tool_use')
690+
out.push(
691+
'```json',
692+
`// tool: ${block.name}`,
693+
JSON.stringify(block.input, null, 2),
694+
'```',
695+
'',
696+
);
697+
else if (block.type === 'tool_result')
698+
out.push(
699+
'```',
700+
`// result${block.is_error ? ' (error)' : ''}`,
701+
block.content.slice(0, 4000),
702+
'```',
703+
'',
704+
);
705+
}
706+
}
707+
return out.join('\n');
708+
}
709+
570710
export const BUILTIN_COMMANDS: SlashCommand[] = [
571711
HelpCommand,
572712
ClearCommand,
@@ -587,6 +727,11 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
587727
KeybindingsCommand,
588728
VimCommand,
589729
RewindCommand,
730+
HooksCommand,
731+
PermissionsCommand,
732+
AgentsCommand,
733+
SkillsCommand,
734+
ExportCommand,
590735
];
591736

592737
// ──────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)