From 2e739eddc317f1357cadacb75e781afbce8d7d93 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:14 +0800 Subject: [PATCH 1/9] feat(slash): add extraHandlers fallback for skill + custom command dispatch - SlashContext gets extraHandlers/reloadExtraHandlers hooks - handleSlash falls back to ctx.extraHandlers after static HANDLERS - Skill interface adds disableModelInvocation frontmatter support - SkillStore.roots() adds .reasonix/agents/ + .claude/agents/ scan - Agent files force runAs:subagent; tools frontmatter maps to allowedTools --- src/cli/ui/slash/dispatch.ts | 16 +++++++++-- src/cli/ui/slash/types.ts | 5 ++++ src/skills.ts | 51 ++++++++++++++++++++++++++++-------- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/cli/ui/slash/dispatch.ts b/src/cli/ui/slash/dispatch.ts index cb5080d17..087ea542f 100644 --- a/src/cli/ui/slash/dispatch.ts +++ b/src/cli/ui/slash/dispatch.ts @@ -58,9 +58,21 @@ export function handleSlash( loop: CacheFirstLoop, ctx: SlashContext = {}, ): SlashResult { - const h = HANDLERS[resolveSlashAlias(cmd)]; + const resolved = resolveSlashAlias(cmd); + const h = HANDLERS[resolved]; if (h) return h(args, loop, ctx); - const suggestions = nearestCommands(cmd, Object.keys(HANDLERS)); + + // Fallback to extra handlers (skill auto-registration + custom commands) + if (ctx.extraHandlers) { + const extra = ctx.extraHandlers[resolved]; + if (extra) return extra(args, loop, ctx); + } + + const allKeys = [ + ...Object.keys(HANDLERS), + ...(ctx.extraHandlers ? Object.keys(ctx.extraHandlers) : []), + ]; + const suggestions = nearestCommands(cmd, allKeys); if (suggestions.length > 0) { const list = suggestions.map((name) => `/${name}`).join(", "); return { unknown: true, info: t("handlers.basic.unknownCommand", { cmd, list }) }; diff --git a/src/cli/ui/slash/types.ts b/src/cli/ui/slash/types.ts index ff4b58d0c..47b7f8aa8 100644 --- a/src/cli/ui/slash/types.ts +++ b/src/cli/ui/slash/types.ts @@ -167,6 +167,11 @@ export interface SlashContext { }; /** Current session id — included in `/feedback`'s diagnostic block when present. */ sessionId?: string; + /** Extra slash-command handlers (skill auto-registration + custom commands from settings.json). + * Checked after the static HANDLERS record; project/global scope merge handled upstream. */ + extraHandlers?: Record; + /** Callback to reload extra handlers mid-session (skill changes + custom command edits). */ + reloadExtraHandlers?: () => number; } export type SlashGroup = diff --git a/src/skills.ts b/src/skills.ts index c25b80d5a..4beeac774 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -11,7 +11,7 @@ import { } from "node:fs"; import { accessSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { parseFrontmatter } from "./frontmatter.js"; import { t } from "./i18n/index.js"; import { NEGATIVE_CLAIM_RULE, TUI_FORMATTING_RULES } from "./prompt-fragments.js"; @@ -46,6 +46,8 @@ export interface Skill { runAs: SkillRunAs; /** Subagent model override; only meaningful when `runAs === "subagent"`. */ model?: string; + /** When true, this skill is excluded from the model's skills index and is only available as a user-invoked slash command `/name`. Compatible with Claude Code's `disable-model-invocation` frontmatter. Default false. */ + disableModelInvocation?: boolean; } export interface SkillRoot { @@ -84,6 +86,16 @@ function isValidSkillName(name: string): boolean { return VALID_SKILL_NAME.test(name); } +/** Allow CJK characters in frontmatter-provided names (used by agent files). + * Stem-based names from filenames still require ASCII validation. */ +const VALID_FRONTMATTER_NAME = /^[a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf_./-]{1,64}$/; + +function resolveSkillName(fmName: string | undefined, stem: string): string { + if (fmName && VALID_FRONTMATTER_NAME.test(fmName)) return fmName; + if (isValidSkillName(stem)) return stem; + return fmName ?? stem; +} + function parseAllowedTools(raw: string | undefined): readonly string[] | undefined { if (raw === undefined) return undefined; const names = raw @@ -140,6 +152,15 @@ export class SkillStore { dir: join(this.projectRoot, ".claude", SKILLS_DIRNAME), scope: "project", }); + // Agent dirs — Reasonix native + Claude Code compat + out.push({ + dir: join(this.projectRoot, ".reasonix", "agents"), + scope: "project", + }); + out.push({ + dir: join(this.projectRoot, ".claude", "agents"), + scope: "project", + }); } for (const dir of this.customSkillPaths) out.push({ dir, scope: "custom" }); out.push({ dir: join(this.homeDir, ".reasonix", SKILLS_DIRNAME), scope: "global" }); @@ -251,20 +272,23 @@ export class SkillStore { private readEntry(dir: string, scope: SkillScope, entry: import("node:fs").Dirent): Skill | null { const isDir = entry.isDirectory() || (entry.isSymbolicLink() && this.isSymlinkDirectory(dir, entry.name)); - // Symlinked flat `.md` files: `entry.isFile()` returns false for symlinks. - // Reuse the same `statSync` pattern as `isSymlinkDirectory` (#2104). const isFile = entry.isFile() || (entry.isSymbolicLink() && !isDir && this.isSymlinkFile(dir, entry.name)); + // Agent dirs use flat .md files with CJK names; allow them through. + // Agents always run as subagent (isolated child loop). + const isAgentDir = basename(dir) === "agents"; if (isDir) { if (!isValidSkillName(entry.name)) return null; const file = join(dir, entry.name, SKILL_FILE); if (!existsSync(file)) return null; - return this.parse(file, entry.name, scope); + return this.parse(file, entry.name, scope, isAgentDir); } if (isFile && entry.name.endsWith(".md")) { const stem = entry.name.slice(0, -3); - if (!isValidSkillName(stem)) return null; - return this.parse(join(dir, entry.name), stem, scope); + // Agent dirs allow CJK names; skill dirs require ASCII + if (!isAgentDir && !isValidSkillName(stem)) return null; + if (isAgentDir && !VALID_FRONTMATTER_NAME.test(stem)) return null; + return this.parse(join(dir, entry.name), stem, scope, isAgentDir); } return null; } @@ -287,7 +311,7 @@ export class SkillStore { } } - private parse(path: string, stem: string, scope: SkillScope): Skill | null { + private parse(path: string, stem: string, scope: SkillScope, isAgent = false): Skill | null { let raw: string; try { raw = readFileSync(path, "utf8"); @@ -295,7 +319,10 @@ export class SkillStore { return null; } const { data, body } = parseFrontmatter(raw); - const name = data.name && isValidSkillName(data.name) ? data.name : stem; + // Frontmatter `name:` can include CJK characters (used by agent files). + // Stem-based names (from filenames) still require ASCII validation. + const fmName = data.name?.trim(); + const name = resolveSkillName(fmName, stem); const description = (data.description ?? "").trim(); // Surface the silent-pin failure mode at parse time. Builtins always have // a description so user-authored files are the only ones that hit this. @@ -310,9 +337,11 @@ export class SkillStore { body: loadBodyWithReferences(path, body.trim()), scope, path, - allowedTools: parseAllowedTools(data["allowed-tools"]), - runAs: parseRunAs(data.runAs, data.context, data.agent), + allowedTools: parseAllowedTools(data["allowed-tools"] ?? data.tools), + runAs: isAgent ? "subagent" : parseRunAs(data.runAs, data.context, data.agent), model: data.model?.startsWith("deepseek-") ? data.model : undefined, + disableModelInvocation: + data["disable-model-invocation"] === "true" || data.disableModelInvocation === "true", }; } } @@ -439,7 +468,7 @@ const MISSING_DESCRIPTION_PLACEHOLDER = /** Bodies stay out — prefix must stay short + cacheable; bodies load on demand. */ export function applySkillsIndex(basePrompt: string, opts: SkillStoreOptions = {}): string { const store = new SkillStore(opts); - const skills = store.list(); + const skills = store.list().filter((s) => !s.disableModelInvocation); if (skills.length === 0) return basePrompt; const lines = skills.map((s) => skillIndexLine(s.description ? s : { ...s, description: MISSING_DESCRIPTION_PLACEHOLDER }), From 31a99fb6e410e812597f01ed0122d23ee26d47cc Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:19 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(slash):=20CustomSlashRegistry=20?= =?UTF-8?q?=E2=80=94=20settings.json=20slashCommands=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads project/global settings.json slashCommands, executes shell commands synchronously as SlashResult, pure TUI-side only. --- src/cli/ui/slash/custom.ts | 133 +++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/cli/ui/slash/custom.ts diff --git a/src/cli/ui/slash/custom.ts b/src/cli/ui/slash/custom.ts new file mode 100644 index 000000000..bf7676883 --- /dev/null +++ b/src/cli/ui/slash/custom.ts @@ -0,0 +1,133 @@ +// settings.json "slashCommands" → /name shell-command executor. Pure TUI-side, never injected into model context. + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { globalSettingsPath, projectSettingsPath } from "../../../hooks.js"; +import { t } from "../../../i18n/index.js"; +import type { SlashCommandSpec, SlashResult } from "./types.js"; + +/** Per-command config shape inside settings.json["slashCommands"]. */ +export interface CustomSlashCommandConfig { + /** Shell command to execute. */ + command: string; + /** One-line description shown in /help and suggestion picker. */ + description?: string; + /** Optional argument hint shown after the command name. */ + argsHint?: string; +} + +/** Parsed form of settings.json — only the slashCommands key we care about. */ +interface SlashSettings { + slashCommands?: Record; +} + +const DEFAULT_TIMEOUT_MS = 10_000; + +function readSlashSettings(path: string): SlashSettings | null { + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as SlashSettings; + } catch { + /* malformed JSON → treat as no custom commands; do NOT throw */ + } + return null; +} + +export interface CustomSlashRegistryOptions { + /** Absolute project root, if any. Without it, only global commands load. */ + projectRoot?: string; + /** Override `~` for tests. */ + homeDir?: string; +} + +export class CustomSlashRegistry { + private commands: Record = {}; + private readonly projectRoot: string | undefined; + private readonly homeDir: string; + + constructor(opts: CustomSlashRegistryOptions = {}) { + this.projectRoot = opts.projectRoot; + this.homeDir = opts.homeDir ?? homedir(); + this.reload(); + } + + /** Re-read settings.json files and rebuild the command map. */ + reload(): number { + const merged: Record = {}; + + // Global scope loads first + const globalPath = globalSettingsPath(this.homeDir); + const globalSettings = readSlashSettings(globalPath); + if (globalSettings?.slashCommands) { + Object.assign(merged, globalSettings.slashCommands); + } + + // Project scope overrides global for same keys + if (this.projectRoot) { + const projPath = projectSettingsPath(this.projectRoot); + // Only load if the file exists, to avoid a confusing "no project" warning + if (existsSync(projPath)) { + const projSettings = readSlashSettings(projPath); + if (projSettings?.slashCommands) { + Object.assign(merged, projSettings.slashCommands); + } + } + } + + this.commands = merged; + return Object.keys(this.commands).length; + } + + /** Look up a command by name. Returns undefined if not found. */ + lookup(name: string): CustomSlashCommandConfig | undefined { + return this.commands[name]; + } + + /** List all command names (for /help and suggestions). */ + names(): string[] { + return Object.keys(this.commands); + } + + /** Build SlashCommandSpec entries for integration into the suggestion system. */ + specs(): SlashCommandSpec[] { + return Object.entries(this.commands).map(([cmd, cfg]) => ({ + cmd, + summary: cfg.description ?? `/${cmd}`, + group: "extend" as const, + argsHint: cfg.argsHint, + })); + } + + /** Count of loaded commands. */ + get size(): number { + return Object.keys(this.commands).length; + } + + /** Execute a custom command's shell command and return a SlashResult. + * This is the fallback handler — it runs the shell command synchronously, + * captures stdout/stderr, and returns the output as `info`. */ + execute(name: string, args: string[], command: string): SlashResult { + try { + // Append user-passed args to the command string + const cmd = args.length > 0 ? `${command} ${args.join(" ")}` : command; + const stdout = execSync(cmd, { + encoding: "utf8" as const, + timeout: DEFAULT_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + // @types/node ExecSyncOptions.shell is `string | undefined` but + // runtime Node accepts `boolean` since v6. + shell: true as unknown as string, + }).trim(); + if (stdout) { + return { info: stdout }; + } + return { info: t("handlers.admin.customExecOk", { name }) }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { info: t("handlers.admin.customExecFailed", { name, reason: message }) }; + } + } +} From 2f786fe755ddd7cea943b6e7aaa9e44286e2c5f9 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:23 +0800 Subject: [PATCH 3/9] feat(slash): markdown command/agent loader (.reasonix/ + .claude/) Scans .md files with YAML frontmatter as slash commands or agent definitions. Supports $ARGUMENTS substitution. First-write-wins across dirs; callers stack dirs in priority order. --- src/cli/ui/slash/md-commands.ts | 122 ++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/cli/ui/slash/md-commands.ts diff --git a/src/cli/ui/slash/md-commands.ts b/src/cli/ui/slash/md-commands.ts new file mode 100644 index 000000000..4475afdd9 --- /dev/null +++ b/src/cli/ui/slash/md-commands.ts @@ -0,0 +1,122 @@ +// Loads .md files with YAML frontmatter as slash commands / agent definitions. +// First-write-wins across dirs; callers stack dirs in priority order. + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { parseFrontmatter } from "../../../frontmatter.js"; + +export interface MarkdownCommandDef { + name: string; + description: string; + /** Hint shown after the command name in suggestions, e.g. "[场景文件]" */ + argumentHint?: string; + /** Full markdown body (post-frontmatter), with `$ARGUMENTS` placeholder intact. */ + body: string; + /** Which directory this was loaded from (for /slash listing). */ + source: string; + /** Optional model override from frontmatter `model:` */ + model?: string; +} + +export interface MarkdownAgentDef { + name: string; + description: string; + /** Comma-separated tool names from frontmatter `tools:` or `allowed-tools:` */ + tools?: string; + body: string; + source: string; + model?: string; +} + +function isValidMdName(name: string): boolean { + return /^[a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf_./-]{1,64}$/.test(name); +} + +/** Load commands from a single directory. .md files → commands; subdirectories with SKILL.md are treated as agent skills (loaded by SkillStore). */ +export function loadMarkdownCommands( + dirs: readonly string[], + sourceName: string, +): MarkdownCommandDef[] { + const byName = new Map(); + for (const dir of dirs) { + if (!existsSync(dir)) continue; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.endsWith(".md")) continue; + const stem = entry.slice(0, -3); + if (!isValidMdName(stem)) continue; + if (byName.has(stem)) continue; // first dir wins (priority handled by caller's dir order) + const filePath = join(dir, entry); + let raw: string; + try { + raw = readFileSync(filePath, "utf8"); + } catch { + continue; + } + const { data, body } = parseFrontmatter(raw); + byName.set(stem, { + name: stem, + description: (data.description ?? "").trim(), + argumentHint: data["argument-hint"]?.trim(), + body: body.trim(), + source: `${sourceName}/${entry}`, + model: data.model?.trim(), + }); + } + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Load agents from a directory of .md files. Each file must have `name` frontmatter. */ +export function loadMarkdownAgents( + dirs: readonly string[], + sourceName: string, +): MarkdownAgentDef[] { + const byName = new Map(); + for (const dir of dirs) { + if (!existsSync(dir)) continue; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.endsWith(".md")) continue; + const stem = entry.slice(0, -3); + const filePath = join(dir, entry); + let raw: string; + try { + raw = readFileSync(filePath, "utf8"); + } catch { + continue; + } + const { data, body } = parseFrontmatter(raw); + const name = (data.name ?? stem).trim(); + if (!isValidMdName(name)) continue; + if (byName.has(name)) continue; + byName.set(name, { + name, + description: (data.description ?? "").trim(), + tools: data.tools?.trim() || data["allowed-tools"]?.trim(), + body: body.trim(), + source: `${sourceName}/${entry}`, + model: data.model?.trim(), + }); + } + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Substitute `$ARGUMENTS` in a command body with user-supplied arguments. */ +export function substituteArguments(body: string, args: string[]): string { + if (args.length === 0) { + return body.replace(/\$ARGUMENTS/g, ""); + } + return body.replace(/\$ARGUMENTS/g, args.join(" ")); +} From 4a4243d81d9477d5137d5d9730478d82f0e97184 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:28 +0800 Subject: [PATCH 4/9] feat(slash): dynamic extra specs in suggestion system setExtraSlashSpecs() injects custom commands into suggestSlashCommands, countAdvancedCommands, detectSlashArgContext. Deduplication keeps built-in commands dominant. Alias map is cached and invalidated on spec change. --- src/cli/ui/slash.ts | 1 + src/cli/ui/slash/commands.ts | 56 ++++++++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/cli/ui/slash.ts b/src/cli/ui/slash.ts index 0cf77135a..1faa0c3d0 100644 --- a/src/cli/ui/slash.ts +++ b/src/cli/ui/slash.ts @@ -11,6 +11,7 @@ export { detectSlashArgContext, orderSlashCommandsByGroup, parseSlash, + setExtraSlashSpecs, suggestSlashCommands, } from "./slash/commands.js"; export { handleSlash } from "./slash/dispatch.js"; diff --git a/src/cli/ui/slash/commands.ts b/src/cli/ui/slash/commands.ts index 15f1a12aa..441bad7fd 100644 --- a/src/cli/ui/slash/commands.ts +++ b/src/cli/ui/slash/commands.ts @@ -388,6 +388,18 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [ argsHint: "[reload]", summary: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk", }, + { + cmd: "slash", + group: "advanced", + argsHint: "[list|reload]", + summary: "list custom slash commands (skills + settings.json) · reload re-reads from disk", + }, + { + cmd: "agents", + group: "extend", + argsHint: "[list|show |new |run [args]]", + summary: "list / inspect agents (.reasonix/agents/ + .claude/agents/)", + }, { cmd: "permissions", group: "advanced", @@ -428,13 +440,35 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [ { cmd: "exit", group: "advanced", summary: "quit the TUI", aliases: ["quit", "q"] }, ]; +/** Extra slash-command specs injected by the TUI at startup + * (skill auto-registration + custom commands from settings.json). + * Checked by `suggestSlashCommands` alongside the built-in SLASH_COMMANDS. */ +let _extraSlashSpecs: readonly SlashCommandSpec[] = []; + +/** Replace the extra command specs. Returns the new count. Called by App.tsx at + * startup and on `/slash reload`. Pass [] to clear. */ +export function setExtraSlashSpecs(specs: readonly SlashCommandSpec[]): number { + _extraSlashSpecs = Object.freeze([...specs]); + _cachedAliasMap = null; // invalidate cache + return _extraSlashSpecs.length; +} + +function allSlashCommands(): readonly SlashCommandSpec[] { + if (_extraSlashSpecs.length === 0) return SLASH_COMMANDS; + // Dedup: built-in commands take priority; extra specs with same cmd name are ignored. + const builtinNames = new Set(SLASH_COMMANDS.map((c) => c.cmd)); + const uniqueExtras = _extraSlashSpecs.filter((c) => !builtinNames.has(c.cmd)); + return [...SLASH_COMMANDS, ...uniqueExtras]; +} + export function suggestSlashCommands( prefix: string, codeMode = false, counts?: Readonly>, ): SlashCommandSpec[] { const p = prefix.toLowerCase(); - const matches = SLASH_COMMANDS.filter((c) => { + const all = allSlashCommands(); + const matches = all.filter((c) => { // Empty prefix = browsing the menu — show the full release command surface except // advanced rows, which remain collapsed behind the footer hint. if (p === "") return c.group !== "advanced"; @@ -453,23 +487,29 @@ export function suggestSlashCommands( } export function countAdvancedCommands(codeMode: boolean): number { - return SLASH_COMMANDS.filter( + return allSlashCommands().filter( (c) => c.group === "advanced" && (c.contextual !== "code" || codeMode), ).length; } -/** alias → canonical cmd map, derived from SLASH_COMMANDS at module init. */ -const ALIAS_TO_CMD: Readonly> = (() => { +/** alias → canonical cmd map, derived from ALL available commands (built-in + extra). + * Cached; invalidated when `setExtraSlashSpecs` is called. */ +let _cachedAliasMap: Record | null = null; + +function getAliasMap(): Record { + if (_cachedAliasMap) return _cachedAliasMap; const m: Record = {}; - for (const spec of SLASH_COMMANDS) { + for (const spec of allSlashCommands()) { if (!spec.aliases) continue; for (const a of spec.aliases) m[a] = spec.cmd; } + _cachedAliasMap = m; return m; -})(); +} export function resolveSlashAlias(name: string): string { - return ALIAS_TO_CMD[name] ?? name; + const m = getAliasMap(); + return m[name] ?? name; } /** Picker fires only when arg tail has no internal whitespace; past that it's a usage hint. */ @@ -478,7 +518,7 @@ export function detectSlashArgContext(input: string, codeMode = false): SlashArg if (!m) return null; const cmdName = resolveSlashAlias(m[1]!.toLowerCase()); const tail = m[2] ?? ""; - const spec = SLASH_COMMANDS.find( + const spec = allSlashCommands().find( (s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode), ); if (!spec) return null; From a1f2971541a068f8d195ce8ac7a51dd20a7834e3 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:32 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat(slash):=20useExtraSlashHandlers=20hook?= =?UTF-8?q?=20=E2=80=94=204-tier=20priority=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges .reasonix/commands/ > .claude/commands/ > settings.json > skill/agent auto-registration. Reloadable via /slash reload. --- src/cli/ui/App.tsx | 8 ++ src/cli/ui/hooks/useExtraSlashHandlers.ts | 136 ++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/cli/ui/hooks/useExtraSlashHandlers.ts diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 5f410619b..54a7d8cbe 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -164,6 +164,7 @@ import { useActivityLabel } from "./hooks/useActivityPhase.js"; import { useAgentSession } from "./hooks/useAgentSession.js"; import { useCodeMode } from "./hooks/useCodeMode.js"; import { useEditGate } from "./hooks/useEditGate.js"; +import { useExtraSlashHandlers } from "./hooks/useExtraSlashHandlers.js"; import { useHookList } from "./hooks/useHookList.js"; import { useInputRecall } from "./hooks/useInputRecall.js"; import { useLanguageReload } from "./hooks/useLanguageReload.js"; @@ -592,6 +593,9 @@ function AppInner({ codeMode?.rootDir, ); const { hookList, reloadHooks } = useHookList(codeMode?.rootDir); + const { handlers: extraHandlers, reload: reloadExtraHandlers } = useExtraSlashHandlers( + codeMode?.rootDir, + ); // Session-scoped edit history + undo banner + /undo, /history, /show // handlers. Kept in a custom hook so App.tsx only sees the small API // it needs —append an edit, arm the banner, answer the slash @@ -3124,6 +3128,8 @@ function AppInner({ return added; }, reloadHooks: () => reloadHooks(codeMode ? currentRootDir : undefined), + extraHandlers, + reloadExtraHandlers: () => reloadExtraHandlers(), switchCwd: codeMode?.reregisterTools ? switchWorkspaceRoot : undefined, reloadMcp: mcpRuntime ? async () => { @@ -3697,6 +3703,8 @@ function AppInner({ generateCurrentSessionTitle, switchWorkspaceRoot, system, + extraHandlers, + reloadExtraHandlers, ], ); diff --git a/src/cli/ui/hooks/useExtraSlashHandlers.ts b/src/cli/ui/hooks/useExtraSlashHandlers.ts new file mode 100644 index 000000000..e0aaf416d --- /dev/null +++ b/src/cli/ui/hooks/useExtraSlashHandlers.ts @@ -0,0 +1,136 @@ +// Combined extra slash-command handler map: .md commands > settings.json > skills/agents. + +import { join } from "node:path"; +import { useCallback, useMemo, useRef } from "react"; +import { SkillStore } from "../../../skills.js"; +import { setExtraSlashSpecs } from "../slash/commands.js"; +import { CustomSlashRegistry } from "../slash/custom.js"; +import type { SlashHandler } from "../slash/dispatch.js"; +import { loadMarkdownCommands, substituteArguments } from "../slash/md-commands.js"; +import type { SlashCommandSpec } from "../slash/types.js"; + +export interface ExtraSlashHandlers { + /** Combined handler map. Keys are slash-command names. */ + handlers: Record; + /** Reload all sources from disk. Returns total extra handler count. */ + reload: () => number; +} + +function dirs(root: string | undefined, ...segments: string[]): string[] { + if (!root) return []; + return [join(root, ...segments)]; +} + +export function useExtraSlashHandlers( + projectRoot: string | undefined, + homeDir?: string, +): ExtraSlashHandlers { + const projectRootRef = useRef(projectRoot); + projectRootRef.current = projectRoot; + + const build = useCallback((): Record => { + const root = projectRootRef.current; + const handlers: Record = {}; + + // Skill auto-registration (lowest priority) + const store = new SkillStore({ projectRoot: root, homeDir }); + const skills = store.list(); + const skillStoreRef = { current: store }; + + for (const skill of skills) { + handlers[skill.name] = (_args, _loop, _ctx) => { + const fresh = skillStoreRef.current.read(skill.name); + const body = fresh?.body ?? skill.body; + const desc = fresh?.description ?? skill.description; + const header = `# Skill: ${skill.name}${desc ? `\n> ${desc}` : ""}`; + const extraArgs = _args.join(" ").trim(); + const argsLine = extraArgs ? `\n\nArguments: ${extraArgs}` : ""; + return { + info: `▸ running skill "${skill.name}"${extraArgs ? ` — ${extraArgs}` : ""}`, + resubmit: `${header}\n\n${body}${argsLine}`, + }; + }; + } + + // settings.json slashCommands + const registry = new CustomSlashRegistry({ projectRoot: root, homeDir }); + for (const name of registry.names()) { + const cfg = registry.lookup(name); + if (!cfg) continue; + handlers[name] = (_args, _loop, _ctx) => { + return registry.execute(name, _args, cfg.command); + }; + } + + // .claude/commands/*.md + const claudeCommands = loadMarkdownCommands( + dirs(root, ".claude", "commands"), + ".claude/commands", + ); + for (const cmd of claudeCommands) { + handlers[cmd.name] = (args, _loop, _ctx) => { + const body = substituteArguments(cmd.body, args); + const argsLine = args.length > 0 ? ` — ${args.join(" ")}` : ""; + return { + info: `▸ running command "${cmd.name}"${argsLine}`, + resubmit: `# Command: ${cmd.name}${cmd.description ? `\n> ${cmd.description}` : ""}\n\n${body}`, + }; + }; + } + + // .reasonix/commands/*.md (highest priority) + const reasonixCommands = loadMarkdownCommands( + dirs(root, ".reasonix", "commands"), + ".reasonix/commands", + ); + for (const cmd of reasonixCommands) { + handlers[cmd.name] = (args, _loop, _ctx) => { + const body = substituteArguments(cmd.body, args); + const argsLine = args.length > 0 ? ` — ${args.join(" ")}` : ""; + return { + info: `▸ running command "${cmd.name}"${argsLine}`, + resubmit: `# Command: ${cmd.name}${cmd.description ? `\n> ${cmd.description}` : ""}\n\n${body}`, + }; + }; + } + + // Build suggestion specs from all sources + const skillSpecs: SlashCommandSpec[] = skills.map((s) => ({ + cmd: s.name, + summary: s.description || s.name, + group: "extend" as const, + })); + const cmdSpecs = (specs: ReturnType): SlashCommandSpec[] => + specs.map((c) => ({ + cmd: c.name, + summary: c.description || c.name, + group: "extend" as const, + argsHint: c.argumentHint, + })); + const extraSpecs: SlashCommandSpec[] = [ + ...skillSpecs, + ...registry.specs(), + ...cmdSpecs(claudeCommands), + ...cmdSpecs(reasonixCommands), + ]; + setExtraSlashSpecs(extraSpecs); + + return handlers; + }, [homeDir]); + + const handlersRef = useRef>(build()); + const reload = useCallback((): number => { + handlersRef.current = build(); + return Object.keys(handlersRef.current).length; + }, [build]); + + return useMemo( + () => ({ + get handlers() { + return handlersRef.current; + }, + reload, + }), + [reload], + ); +} From 2c65bc0453570a97b09ba641d672f4559ba5ebc6 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:36 +0800 Subject: [PATCH 6/9] feat(slash): /slash + /agents commands (list/show/new/run) --- src/cli/ui/slash/handlers/basic.ts | 119 +++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/cli/ui/slash/handlers/basic.ts b/src/cli/ui/slash/handlers/basic.ts index 53cdcafaa..b341f5e4d 100644 --- a/src/cli/ui/slash/handlers/basic.ts +++ b/src/cli/ui/slash/handlers/basic.ts @@ -1,9 +1,12 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { wrapToCells } from "@/cli/ui/text-width.js"; import { t, tObj } from "@/i18n/index.js"; import { VERSION } from "@/version.js"; import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js"; import { SLASH_COMMANDS, SLASH_GROUP_ORDER, orderSlashCommandsByGroup } from "../commands.js"; import type { SlashHandler } from "../dispatch.js"; +import { loadMarkdownAgents } from "../md-commands.js"; import type { SlashCommandSpec, SlashGroup } from "../types.js"; const ABOUT_WEBSITE = "https://esengine.github.io/DeepSeek-Reasonix/"; @@ -162,6 +165,120 @@ const about: SlashHandler = () => { return { info: lines.join("\n") }; }; +const slashCmd: SlashHandler = (args, _loop, ctx) => { + const sub = (args[0] ?? "").toLowerCase(); + + if (sub === "reload") { + if (!ctx.reloadExtraHandlers) { + return { info: t("handlers.admin.slashReloadUnavailable") }; + } + const count = ctx.reloadExtraHandlers(); + return { info: t("handlers.admin.slashReloaded", { count }) }; + } + + // list (default) + const extraKeys = ctx.extraHandlers ? Object.keys(ctx.extraHandlers) : []; + if (extraKeys.length === 0) { + return { info: t("handlers.admin.slashNone") }; + } + const lines = [t("handlers.admin.slashHeader", { count: extraKeys.length })]; + for (const name of extraKeys.sort()) { + lines.push(` /${name}`); + } + lines.push(""); + lines.push(t("handlers.admin.slashFooter")); + return { info: lines.join("\n") }; +}; + +const agents: SlashHandler = (args, _loop, ctx) => { + const projectRoot = ctx.codeRoot; + const dirs = (root: string) => [ + join(root, ".reasonix", "agents"), + join(root, ".claude", "agents"), + ]; + + const sub = (args[0] ?? "").toLowerCase(); + + // /agents new + if (sub === "new" || sub === "init") { + const name = args[1]; + if (!name) return { info: "usage: /agents new " }; + if (!projectRoot) + return { info: "agent creation requires a project root (run from reasonix code)." }; + const targetDir = join(projectRoot, ".reasonix", "agents"); + try { + mkdirSync(targetDir, { recursive: true }); + } catch { + return { info: `cannot create directory: ${targetDir}` }; + } + const filePath = join(targetDir, `${name}.md`); + if (existsSync(filePath)) return { info: `agent "${name}" already exists at ${filePath}` }; + const stub = `--- +name: ${name} +description: What does this agent do? +tools: read_file, search_content, glob +--- + +# ${name} + +Replace this body with the agent's system prompt. +The agent runs as an isolated subagent (subagent mode). +`; + writeFileSync(filePath, stub, "utf8"); + return { + info: `▸ agent "${name}" created at ${filePath}\n edit it, then /slash reload to pick it up.`, + }; + } + + // /agents run [args] + if (sub === "run") { + const name = args[1]; + if (!name) return { info: "usage: /agents run [args]" }; + if (!projectRoot) return { info: "agent invocation requires a project root." }; + const all = loadMarkdownAgents(dirs(projectRoot), "agents"); + const found = all.find((a) => a.name === name); + if (!found) return { info: `agent "${name}" not found.` }; + const extraArgs = args.slice(2).join(" ").trim(); + const body = extraArgs ? found.body.replace(/\$ARGUMENTS/g, extraArgs) : found.body; + return { + info: `▸ running agent "${name}"${extraArgs ? ` — ${extraArgs}` : ""}`, + resubmit: `# Agent: ${found.name}${found.description ? `\n> ${found.description}` : ""}\n\n${body}`, + }; + } + + // /agents show + if (sub === "show" || sub === "cat") { + const target = args[1]; + if (!target) return { info: "usage: /agents show " }; + if (!projectRoot) return { info: "agent inspection requires a project root." }; + const all = loadMarkdownAgents(dirs(projectRoot), "agents"); + const found = all.find((a) => a.name === target); + if (!found) return { info: `agent "${target}" not found.` }; + const lines = [ + `▸ ${found.name} (${found.source})`, + found.description ? ` ${found.description}` : "", + found.tools ? ` tools: ${found.tools}` : "", + found.model ? ` model: ${found.model}` : "", + "", + found.body, + ].filter((l) => l !== ""); + return { info: lines.join("\n") }; + } + + // /agents list (default) + if (!projectRoot) return { info: "agent listing requires a project root." }; + const all = loadMarkdownAgents(dirs(projectRoot), "agents"); + if (all.length === 0) { + return { info: "no agents found in .reasonix/agents/ or .claude/agents/" }; + } + const lines = [`▸ agents · ${all.length} available:`]; + for (const a of all) { + const desc = a.description ? ` — ${a.description}` : ""; + lines.push(` /${a.name}${desc}`); + } + return { info: lines.join("\n") }; +}; + export const handlers: Record = { exit, new: resetLog, @@ -170,4 +287,6 @@ export const handlers: Record = { loop, keys, about, + slash: slashCmd, + agents, }; From f6813a99411a076d05391d86c36f80c8ae690e1d Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:40 +0800 Subject: [PATCH 7/9] feat(i18n): slash/agents command descriptions (en/zh-CN/ja/de/ru) --- src/i18n/EN.ts | 20 ++++++++++++++++++++ src/i18n/JA.ts | 6 ++++++ src/i18n/de.ts | 6 ++++++ src/i18n/ru.ts | 6 ++++++ src/i18n/zh-CN.ts | 8 ++++++++ 5 files changed, 46 insertions(+) diff --git a/src/i18n/EN.ts b/src/i18n/EN.ts index ef542766d..bf06194d7 100644 --- a/src/i18n/EN.ts +++ b/src/i18n/EN.ts @@ -318,6 +318,15 @@ export const EN: TranslationSchema = { description: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk", argsHint: "[reload]", }, + slash: { + description: + "list custom slash commands (skills + settings.json) · reload re-reads from disk", + argsHint: "[list|reload]", + }, + agents: { + description: "list / inspect / create / run agents (.reasonix/agents/ + .claude/agents/)", + argsHint: "[list|show |new |run [args]]", + }, permissions: { description: "show / edit shell allowlist (builtin read-only · per-project: ~/.reasonix/config.json)", @@ -1021,6 +1030,17 @@ export const EN: TranslationSchema = { hooksReloadUnavailable: "/hooks reload is not available in this context (no reload callback wired).", hooksReloaded: "▸ reloaded hooks · {count} active", + slashReloadUnavailable: + "/slash reload is not available in this context (no reload callback wired).", + slashReloaded: "▸ reloaded custom slash commands · {count} active", + slashUsage: + "usage: /slash list custom slash commands\n /slash reload re-read skills + settings.json", + slashNone: "no custom slash commands configured.", + slashHeader: "▸ custom slash commands · {count} active:", + slashFooter: "sources: project/.reasonix/settings.json · ~/.reasonix/settings.json · skills", + customExecOk: "▸ /{name} — command completed (no output)", + customExecFailed: "▸ /{name} failed: {reason}", + customNoCommand: "usage: / [args] — runs the custom command defined in settings.json", hooksUsage: "usage: /hooks list active hooks\n /hooks reload re-read settings.json files", hooksNone: "no hooks configured.", diff --git a/src/i18n/JA.ts b/src/i18n/JA.ts index e78135b93..099fda1f1 100644 --- a/src/i18n/JA.ts +++ b/src/i18n/JA.ts @@ -353,6 +353,12 @@ export const JA: TranslationSchema = { "アクティブなフックを一覧 (.reasonix/ 下の settings.json) · reload でディスクから再読込", argsHint: "[reload]", }, + slash: { + ...EN.slash.slash, + description: + "カスタムスラッシュコマンドを一覧 (skills + settings.json) · reload でディスクから再読込", + argsHint: "[list|reload]", + }, permissions: { ...EN.slash.permissions, description: diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 514e0da6a..d9717235a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -241,6 +241,12 @@ export const de: TranslationSchema = { description: "Aktive Hooks auflisten (settings.json unter .reasonix/) · reload liest von Platte neu", }, + slash: { + ...EN.slash.slash, + description: + "Benutzerdefinierte Slash-Befehle auflisten (Skills + settings.json) · reload liest von Platte neu", + argsHint: "[Liste|Neu laden]", + }, permissions: { ...EN.slash.permissions, argsHint: diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 92ebb5ace..97429d882 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -224,6 +224,12 @@ export const ru: TranslationSchema = { description: "список активных хуков (settings.json в .reasonix/) · reload перечитывает с диска", }, + slash: { + ...EN.slash.slash, + description: + "список пользовательских slash-команд (скиллы + settings.json) · reload перечитывает с диска", + argsHint: "[list|reload]", + }, permissions: { ...EN.slash.permissions, description: diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index 79d13cdbb..539622d4f 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -306,6 +306,14 @@ export const zhCN: TranslationSchema = { description: "列出活跃的 hooks(.reasonix/ 下的 settings.json)· reload 从磁盘重新读取", argsHint: "[reload]", }, + slash: { + description: "列出自定义 slash 命令(skills + settings.json)· reload 从磁盘重新读取", + argsHint: "[list|reload]", + }, + agents: { + description: "列出 / 查看 agents(.reasonix/agents/ + .claude/agents/)", + argsHint: "[list|show ]", + }, permissions: { description: "显示 / 编辑 shell 允许列表(内置只读 · 项目级:~/.reasonix/config.json)", argsHint: "[list|add |remove |clear confirm]", From 226db127c29ffbddfa786538ccd49a49df8cbfeb Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:05:45 +0800 Subject: [PATCH 8/9] test(slash): 64 tests for custom commands, agents, md loader - slash-custom.test.ts: 23 tests (registry, dispatch, suggestions, skills) - slash-md-commands.test.ts: 31 tests (md loader, agents, /agents handler) - Adjusted ui-slash-suggestions and slash test counts for new commands - PlanPanel.tsx: biome-ignore useImportType (classic JSX transform) --- src/cli/ui/PlanPanel.tsx | 1 + tests/slash-custom.test.ts | 311 ++++++++++++++++++++++ tests/slash-md-commands.test.ts | 388 ++++++++++++++++++++++++++++ tests/slash.test.ts | 2 +- tests/ui-slash-suggestions.test.tsx | 8 +- 5 files changed, 705 insertions(+), 5 deletions(-) create mode 100644 tests/slash-custom.test.ts create mode 100644 tests/slash-md-commands.test.ts diff --git a/src/cli/ui/PlanPanel.tsx b/src/cli/ui/PlanPanel.tsx index 59d0a1215..fa2accc8c 100644 --- a/src/cli/ui/PlanPanel.tsx +++ b/src/cli/ui/PlanPanel.tsx @@ -16,6 +16,7 @@ */ import { Box, type Color, Text } from "ink"; +// biome-ignore lint/style/useImportType: classic JSX transform needs React in scope import React, { useMemo, useState } from "react"; import { t } from "../../i18n/index.js"; import type { PlanStep, StepCompletion } from "../../tools/plan.js"; diff --git a/tests/slash-custom.test.ts b/tests/slash-custom.test.ts new file mode 100644 index 000000000..6c667a20a --- /dev/null +++ b/tests/slash-custom.test.ts @@ -0,0 +1,311 @@ +/** Custom slash-command tests — skill auto-registration, settings.json commands, + * dispatch integration, and the /slash management command. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleSlash, parseSlash } from "../src/cli/ui/slash.js"; +import { setExtraSlashSpecs, suggestSlashCommands } from "../src/cli/ui/slash/commands.js"; +import { CustomSlashRegistry } from "../src/cli/ui/slash/custom.js"; +import { globalSettingsPath, projectSettingsPath } from "../src/hooks.js"; +import { DeepSeekClient, ImmutablePrefix } from "../src/index.js"; +import { CacheFirstLoop } from "../src/loop.js"; +import { SkillStore, applySkillsIndex } from "../src/skills.js"; + +function makeLoop() { + const client = new DeepSeekClient({ + apiKey: "sk-test", + fetch: vi.fn() as unknown as typeof fetch, + }); + return new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + }); +} + +// CustomSlashRegistry +describe("CustomSlashRegistry", () => { + let home: string; + let project: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "reasonix-slah-home-")); + project = mkdtempSync(join(tmpdir(), "reasonix-slah-proj-")); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + }); + + function writeSlashSettings(dir: string, commands: Record): string { + const reasonixDir = join(dir, ".reasonix"); + mkdirSync(reasonixDir, { recursive: true }); + const path = join(reasonixDir, "settings.json"); + writeFileSync(path, JSON.stringify({ slashCommands: commands }), "utf8"); + return path; + } + + it("loads zero commands when no settings exist", () => { + const reg = new CustomSlashRegistry({ projectRoot: project, homeDir: home }); + expect(reg.size).toBe(0); + expect(reg.names()).toEqual([]); + }); + + it("loads global commands from settings.json", () => { + writeSlashSettings(home, { + deploy: { command: "echo deployed", description: "Deploy" }, + logs: { command: "tail log", description: "Show logs" }, + }); + const reg = new CustomSlashRegistry({ homeDir: home }); + expect(reg.size).toBe(2); + expect(reg.names().sort()).toEqual(["deploy", "logs"]); + expect(reg.lookup("deploy")?.command).toBe("echo deployed"); + }); + + it("project commands override global commands for the same name", () => { + writeSlashSettings(home, { deploy: { command: "echo global", description: "G" } }); + writeSlashSettings(project, { deploy: { command: "echo project", description: "P" } }); + const reg = new CustomSlashRegistry({ projectRoot: project, homeDir: home }); + expect(reg.size).toBe(1); + expect(reg.lookup("deploy")?.command).toBe("echo project"); + }); + + it("project commands add to global commands for different names", () => { + writeSlashSettings(home, { globalOnly: { command: "echo g" } }); + writeSlashSettings(project, { projectOnly: { command: "echo p" } }); + const reg = new CustomSlashRegistry({ projectRoot: project, homeDir: home }); + expect(reg.size).toBe(2); + expect(reg.names().sort()).toEqual(["globalOnly", "projectOnly"]); + }); + + it("reload() re-reads from disk", () => { + const homePath = writeSlashSettings(home, { a: { command: "echo v1" } }); + const reg = new CustomSlashRegistry({ homeDir: home }); + expect(reg.lookup("a")?.command).toBe("echo v1"); + + writeFileSync( + homePath, + JSON.stringify({ slashCommands: { b: { command: "echo v2" } } }), + "utf8", + ); + reg.reload(); + expect(reg.size).toBe(1); + expect(reg.lookup("b")?.command).toBe("echo v2"); + expect(reg.lookup("a")).toBeUndefined(); + }); + + it("specs() returns SlashCommandSpec entries for suggestion system", () => { + writeSlashSettings(home, { + deploy: { command: "echo hi", description: "Deploy to prod", argsHint: "[env]" }, + }); + const reg = new CustomSlashRegistry({ homeDir: home }); + const specs = reg.specs(); + expect(specs).toHaveLength(1); + expect(specs[0]!.cmd).toBe("deploy"); + expect(specs[0]!.summary).toBe("Deploy to prod"); + expect(specs[0]!.group).toBe("extend"); + expect(specs[0]!.argsHint).toBe("[env]"); + }); + + it("execute() runs a shell command and returns stdout as info", () => { + const reg = new CustomSlashRegistry({ homeDir: home }); + const result = reg.execute("test", [], "echo hello world"); + expect(result.info).toBe("hello world"); + }); + + it("execute() appends user args to the command", () => { + const reg = new CustomSlashRegistry({ homeDir: home }); + const result = reg.execute("test", ["--verbose", "foo"], "echo"); + expect(result.info).toBe("--verbose foo"); + }); + + it("execute() returns error info when command fails", () => { + const reg = new CustomSlashRegistry({ homeDir: home }); + const result = reg.execute("fail", [], "nonexistent_command_xyz 2>/dev/null; exit 1"); + // exit code 1 should produce an error string + expect(result.info).toBeTruthy(); + }); + + it("handles malformed settings.json gracefully", () => { + const reasonixDir = join(home, ".reasonix"); + mkdirSync(reasonixDir, { recursive: true }); + writeFileSync(join(reasonixDir, "settings.json"), "{ not json", "utf8"); + const reg = new CustomSlashRegistry({ homeDir: home }); + expect(reg.size).toBe(0); + }); +}); + +// Dispatch integration (extraHandlers fallback) +describe("handleSlash with extraHandlers", () => { + let loop: CacheFirstLoop; + + beforeEach(() => { + loop = makeLoop(); + }); + + it("falls back to extraHandlers when built-in handler not found", () => { + const extra = { + deploy: () => ({ info: "deployed!" }), + }; + const result = handleSlash("deploy", [], loop, { extraHandlers: extra }); + expect(result.info).toBe("deployed!"); + expect(result.unknown).toBeUndefined(); + }); + + it("built-in handlers take priority over extra handlers", () => { + const extra = { + help: () => ({ info: "custom help" }), + }; + const result = handleSlash("help", [], loop, { extraHandlers: extra }); + // The built-in /help handler should run, not the custom one + expect(result.info).toContain("Commands"); + }); + + it("returns unknown when no handler matches", () => { + const result = handleSlash("nonexistent", [], loop, { extraHandlers: {} }); + expect(result.unknown).toBe(true); + }); + + it("resolves aliases for extra handlers", () => { + const extra = { + mycommand: () => ({ info: "ok" }), + }; + // Aliases are only resolved from static SLASH_COMMANDS via the alias map + // Extra handlers don't participate in alias resolution (simplifies design) + const result = handleSlash("mycommand", [], loop, { extraHandlers: extra }); + expect(result.info).toBe("ok"); + }); +}); + +// Suggestion system integration +describe("suggestSlashCommands with extra specs", () => { + afterEach(() => { + setExtraSlashSpecs([]); + }); + + it("returns extra specs alongside built-in commands", () => { + setExtraSlashSpecs([{ cmd: "deploy", summary: "Deploy to prod", group: "extend" }]); + const matches = suggestSlashCommands("dep", false); + expect(matches.some((m) => m.cmd === "deploy")).toBe(true); + }); + + it("extra specs appear in empty-prefix browse mode", () => { + setExtraSlashSpecs([{ cmd: "mycmd", summary: "My custom command", group: "extend" }]); + const matches = suggestSlashCommands("", false); + // extend group commands should appear in browse mode (not advanced) + expect(matches.some((m) => m.cmd === "mycmd")).toBe(true); + }); + + it("clearing extra specs removes them", () => { + setExtraSlashSpecs([{ cmd: "temp", summary: "Temporary", group: "extend" }]); + setExtraSlashSpecs([]); + const matches = suggestSlashCommands("temp", false); + expect(matches.some((m) => m.cmd === "temp")).toBe(false); + }); +}); + +// Skill disableModelInvocation +describe("Skill disableModelInvocation", () => { + let project: string; + + beforeEach(() => { + project = mkdtempSync(join(tmpdir(), "reasonix-skill-dmi-")); + }); + afterEach(() => { + rmSync(project, { recursive: true, force: true }); + }); + + function writeSkill(dir: string, name: string, frontmatter: string, body: string): string { + const skillsDir = join(dir, ".reasonix", "skills"); + mkdirSync(skillsDir, { recursive: true }); + const path = join(skillsDir, `${name}.md`); + writeFileSync(path, `---\n${frontmatter}\n---\n\n${body}`, "utf8"); + return path; + } + + it("skill with disableModelInvocation: true is excluded from index", () => { + writeSkill( + project, + "deployer", + "name: deployer\ndescription: Deploy helper\ndisable-model-invocation: true", + "Run npm run deploy", + ); + const store = new SkillStore({ projectRoot: project, homeDir: project, disableBuiltins: true }); + const skills = store.list(); + expect(skills).toHaveLength(1); + expect(skills[0]!.disableModelInvocation).toBe(true); + + // applySkillsIndex filters it out + const result = applySkillsIndex("base prompt", { + projectRoot: project, + homeDir: project, + disableBuiltins: true, + }); + expect(result).not.toContain("deployer"); + }); + + it("skill without disableModelInvocation appears in index", () => { + writeSkill( + project, + "helper", + "name: helper\ndescription: A helpful skill", + "Do helpful things", + ); + const store = new SkillStore({ projectRoot: project, homeDir: project, disableBuiltins: true }); + const skills = store.list(); + expect(skills).toHaveLength(1); + expect(skills[0]!.disableModelInvocation).toBeFalsy(); + + const result = applySkillsIndex("base prompt", { + projectRoot: project, + homeDir: project, + disableBuiltins: true, + }); + expect(result).toContain("helper"); + }); +}); + +// /slash handler +describe("/slash handler", () => { + let loop: CacheFirstLoop; + + beforeEach(() => { + loop = makeLoop(); + }); + + it("/slash with no extra handlers reports none", () => { + const result = handleSlash("slash", [], loop, { + extraHandlers: {}, + reloadExtraHandlers: () => 0, + }); + expect(result.info).toContain("no custom slash commands"); + }); + + it("/slash lists extra handler names", () => { + const result = handleSlash("slash", [], loop, { + extraHandlers: { deploy: () => ({ info: "ok" }), logs: () => ({ info: "ok" }) }, + reloadExtraHandlers: () => 2, + }); + expect(result.info).toContain("deploy"); + expect(result.info).toContain("logs"); + }); + + it("/slash reload calls reloadExtraHandlers", () => { + let called = false; + const result = handleSlash("slash", ["reload"], loop, { + extraHandlers: {}, + reloadExtraHandlers: () => { + called = true; + return 3; + }, + }); + expect(called).toBe(true); + expect(result.info).toContain("3"); + }); + + it("/slash reload without callback reports unavailable", () => { + const result = handleSlash("slash", ["reload"], loop, {}); + expect(result.info).toContain("not available"); + }); +}); diff --git a/tests/slash-md-commands.test.ts b/tests/slash-md-commands.test.ts new file mode 100644 index 000000000..59ec39fd0 --- /dev/null +++ b/tests/slash-md-commands.test.ts @@ -0,0 +1,388 @@ +/** Markdown command & agent tests — .reasonix/commands/, .claude/commands/, + * .reasonix/agents/, .claude/agents/ loading, priority, and integration. */ + +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleSlash } from "../src/cli/ui/slash.js"; +import { + loadMarkdownAgents, + loadMarkdownCommands, + substituteArguments, +} from "../src/cli/ui/slash/md-commands.js"; +import { DeepSeekClient, ImmutablePrefix } from "../src/index.js"; +import { CacheFirstLoop } from "../src/loop.js"; +import { SkillStore } from "../src/skills.js"; + +function makeLoop() { + const client = new DeepSeekClient({ + apiKey: "sk-test", + fetch: vi.fn() as unknown as typeof fetch, + }); + return new CacheFirstLoop({ + client, + prefix: new ImmutablePrefix({ system: "s" }), + }); +} + +// MarkdownCommandLoader +describe("loadMarkdownCommands", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "reasonix-mdc-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("loads .md files as commands", () => { + writeFileSync( + join(dir, "deploy.md"), + "---\ndescription: Deploy to prod\nargument-hint: [env]\n---\n\nRun deploy for $ARGUMENTS", + "utf8", + ); + writeFileSync(join(dir, "lint.md"), "---\ndescription: Lint\n---\n\nRun linter", "utf8"); + const cmds = loadMarkdownCommands([dir], "test"); + expect(cmds).toHaveLength(2); + expect(cmds[0]!.name).toBe("deploy"); + expect(cmds[0]!.description).toBe("Deploy to prod"); + expect(cmds[0]!.argumentHint).toBe("[env]"); + expect(cmds[0]!.body).toContain("$ARGUMENTS"); + expect(cmds[0]!.source).toBe("test/deploy.md"); + expect(cmds[1]!.name).toBe("lint"); + }); + + it("skips non-.md files", () => { + writeFileSync(join(dir, "readme.txt"), "hello", "utf8"); + writeFileSync(join(dir, "note"), "hello", "utf8"); + const cmds = loadMarkdownCommands([dir], "test"); + expect(cmds).toHaveLength(0); + }); + + it("first dir wins on name conflict", () => { + const dir2 = mkdtempSync(join(tmpdir(), "reasonix-mdc2-")); + writeFileSync(join(dir, "cmd.md"), "---\ndescription: V1\n---\n\nbody1", "utf8"); + writeFileSync(join(dir2, "cmd.md"), "---\ndescription: V2\n---\n\nbody2", "utf8"); + const cmds = loadMarkdownCommands([dir, dir2], "test"); + expect(cmds).toHaveLength(1); + expect(cmds[0]!.description).toBe("V1"); + rmSync(dir2, { recursive: true, force: true }); + }); + + it("handles missing directories gracefully", () => { + const cmds = loadMarkdownCommands([join(dir, "nonexistent")], "test"); + expect(cmds).toHaveLength(0); + }); + + it("handles CJK filenames", () => { + writeFileSync( + join(dir, "起草场景.md"), + "---\ndescription: 起草正文场景\n---\n\n起草一个场景:$ARGUMENTS", + "utf8", + ); + const cmds = loadMarkdownCommands([dir], "test"); + expect(cmds).toHaveLength(1); + expect(cmds[0]!.name).toBe("起草场景"); + expect(cmds[0]!.description).toBe("起草正文场景"); + }); + + it("parses model frontmatter field", () => { + writeFileSync( + join(dir, "heavy.md"), + "---\ndescription: Heavy task\nmodel: deepseek-v4-pro\n---\n\nbody", + "utf8", + ); + const cmds = loadMarkdownCommands([dir], "test"); + expect(cmds[0]!.model).toBe("deepseek-v4-pro"); + }); +}); + +// substituteArguments +describe("substituteArguments", () => { + it("replaces $ARGUMENTS with joined args", () => { + expect(substituteArguments("Process: $ARGUMENTS", ["file1", "file2"])).toBe( + "Process: file1 file2", + ); + }); + + it("replaces $ARGUMENTS with empty string when no args", () => { + expect(substituteArguments("Process: $ARGUMENTS end", [])).toBe("Process: end"); + }); + + it("replaces multiple occurrences", () => { + expect(substituteArguments("Start $ARGUMENTS mid $ARGUMENTS end", ["x"])).toBe( + "Start x mid x end", + ); + }); + + it("leaves body unchanged when no $ARGUMENTS present", () => { + expect(substituteArguments("Just do it", ["ignored"])).toBe("Just do it"); + }); +}); + +// loadMarkdownAgents +describe("loadMarkdownAgents", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "reasonix-mda-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("loads agent .md files", () => { + writeFileSync( + join(dir, "规划师.md"), + "---\nname: 规划师\ndescription: 规划章节\ntools: Read, Write, Grep\n---\n\n你是规划专员。", + "utf8", + ); + const agents = loadMarkdownAgents([dir], "test"); + expect(agents).toHaveLength(1); + expect(agents[0]!.name).toBe("规划师"); + expect(agents[0]!.description).toBe("规划章节"); + expect(agents[0]!.tools).toBe("Read, Write, Grep"); + expect(agents[0]!.source).toBe("test/规划师.md"); + }); + + it("falls back to filename stem when name missing", () => { + writeFileSync( + join(dir, "reviewer.md"), + "---\ndescription: Review code\n---\n\nYou are a reviewer.", + "utf8", + ); + const agents = loadMarkdownAgents([dir], "test"); + expect(agents).toHaveLength(1); + expect(agents[0]!.name).toBe("reviewer"); + }); + + it("supports allowed-tools as alias for tools", () => { + writeFileSync( + join(dir, "agent.md"), + "---\nname: agent\ndescription: Test\nallowed-tools: Read, Edit\n---\n\nbody", + "utf8", + ); + const agents = loadMarkdownAgents([dir], "test"); + expect(agents[0]!.tools).toBe("Read, Edit"); + }); +}); + +// SkillStore loads agents as skills +describe("SkillStore agents", () => { + let project: string; + + beforeEach(() => { + project = mkdtempSync(join(tmpdir(), "reasonix-sa-")); + }); + afterEach(() => { + rmSync(project, { recursive: true, force: true }); + }); + + it("loads .claude/agents/*.md as subagent skills", () => { + const agentsDir = join(project, ".claude", "agents"); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "规划师.md"), + "---\nname: 规划师\ndescription: 规划章节\ntools: Read, Write\n---\n\n你是规划专员。", + "utf8", + ); + const store = new SkillStore({ projectRoot: project, homeDir: project, disableBuiltins: true }); + const skills = store.list(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("规划师"); + expect(skills[0]!.description).toBe("规划章节"); + expect(skills[0]!.allowedTools).toEqual(["Read", "Write"]); + expect(skills[0]!.runAs).toBe("subagent"); // agents force subagent mode + }); + + it("loads .reasonix/agents/*.md as subagent skills", () => { + const agentsDir = join(project, ".reasonix", "agents"); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "reviewer.md"), + "---\nname: reviewer\ndescription: Review changes\n---\n\nYou review diffs.", + "utf8", + ); + const store = new SkillStore({ projectRoot: project, homeDir: project, disableBuiltins: true }); + const skills = store.list(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("reviewer"); + expect(skills[0]!.description).toBe("Review changes"); + }); +}); + +// dispatch integration — markdown commands as slash commands +describe("handleSlash with markdown commands", () => { + let loop: CacheFirstLoop; + let dir: string; + + beforeEach(() => { + loop = makeLoop(); + dir = mkdtempSync(join(tmpdir(), "reasonix-slash-md-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("executes a markdown command via extraHandlers", () => { + writeFileSync( + join(dir, "greet.md"), + "---\ndescription: Greeting\n---\n\nSay hello to $ARGUMENTS", + "utf8", + ); + const cmds = loadMarkdownCommands([dir], "test"); + const handler = (args: string[]) => { + const cmd = cmds[0]!; + const body = substituteArguments(cmd.body, args); + return { + info: `▸ running "${cmd.name}"`, + resubmit: `# Command: ${cmd.name}\n\n${body}`, + }; + }; + + const result = handleSlash("greet", ["world"], loop, { + extraHandlers: { greet: handler }, + }); + expect(result.info).toContain("greet"); + expect(result.resubmit).toContain("Say hello to world"); + }); + + it("replaces $ARGUMENTS with empty when no args provided", () => { + writeFileSync( + join(dir, "task.md"), + "---\ndescription: Task\n---\n\nDo: $ARGUMENTS end", + "utf8", + ); + const cmds = loadMarkdownCommands([dir], "test"); + const handler = (args: string[]) => { + const cmd = cmds[0]!; + const body = substituteArguments(cmd.body, args); + return { info: "ok", resubmit: body }; + }; + + const result = handleSlash("task", [], loop, { + extraHandlers: { task: handler }, + }); + expect(result.resubmit).toContain("Do: end"); + }); +}); + +// /agents handler — list, show, new, run +describe("/agents handler", () => { + let project: string; + let loop: CacheFirstLoop; + + beforeEach(() => { + project = mkdtempSync(join(tmpdir(), "reasonix-agents-cmd-")); + loop = makeLoop(); + }); + afterEach(() => { + rmSync(project, { recursive: true, force: true }); + }); + + function writeAgent(name: string, frontmatter: string, body: string): string { + const agentsDir = join(project, ".reasonix", "agents"); + mkdirSync(agentsDir, { recursive: true }); + const path = join(agentsDir, `${name}.md`); + writeFileSync(path, `---\n${frontmatter}\n---\n\n${body}`, "utf8"); + return path; + } + + it("/agents lists available agents", () => { + writeAgent("planner", "name: planner\ndescription: Plans", "Plan things."); + writeAgent("reviewer", "name: reviewer\ndescription: Reviews", "Review things."); + + const result = handleSlash("agents", [], loop, { codeRoot: project }); + expect(result.info).toContain("2 available"); + expect(result.info).toContain("/planner"); + expect(result.info).toContain("/reviewer"); + }); + + it("/agents reports none when no agents exist", () => { + const result = handleSlash("agents", [], loop, { codeRoot: project }); + expect(result.info).toContain("no agents"); + }); + + it("/agents requires project root", () => { + const result = handleSlash("agents", [], loop, {}); + expect(result.info).toContain("requires a project root"); + }); + + it("/agents show displays agent details", () => { + writeAgent( + "planner", + "name: planner\ndescription: Plans chapters\ntools: Read, Write", + "You are a planner.", + ); + + const result = handleSlash("agents", ["show", "planner"], loop, { codeRoot: project }); + expect(result.info).toContain("planner"); + expect(result.info).toContain("Plans chapters"); + expect(result.info).toContain("Read, Write"); + expect(result.info).toContain("You are a planner."); + }); + + it("/agents show reports not-found", () => { + const result = handleSlash("agents", ["show", "nonexistent"], loop, { codeRoot: project }); + expect(result.info).toContain("not found"); + }); + + it("/agents show without name shows usage", () => { + const result = handleSlash("agents", ["show"], loop, { codeRoot: project }); + expect(result.info).toContain("usage"); + }); + + it("/agents new creates a stub file", () => { + const result = handleSlash("agents", ["new", "myagent"], loop, { codeRoot: project }); + expect(result.info).toContain("created"); + + // Verify file exists + expect(existsSync(join(project, ".reasonix", "agents", "myagent.md"))).toBe(true); + }); + + it("/agents new without name shows usage", () => { + const result = handleSlash("agents", ["new"], loop, { codeRoot: project }); + expect(result.info).toContain("usage"); + }); + + it("/agents new refuses overwrite", () => { + writeAgent("existing", "name: existing\ndescription: Exists", "body"); + const result = handleSlash("agents", ["new", "existing"], loop, { codeRoot: project }); + expect(result.info).toContain("already exists"); + }); + + it("/agents new requires project root", () => { + const result = handleSlash("agents", ["new", "test"], loop, {}); + expect(result.info).toContain("requires a project root"); + }); + + it("/agents run invokes agent with resubmit", () => { + writeAgent("runner", "name: runner\ndescription: Runner", "Execute: $ARGUMENTS"); + + const result = handleSlash("agents", ["run", "runner", "task1"], loop, { codeRoot: project }); + expect(result.resubmit).toBeDefined(); + expect(result.resubmit).toContain("Execute: task1"); + expect(result.info).toContain("runner"); + expect(result.info).toContain("task1"); + }); + + it("/agents run without name shows usage", () => { + const result = handleSlash("agents", ["run"], loop, { codeRoot: project }); + expect(result.info).toContain("usage"); + }); + + it("/agents run reports not-found", () => { + const result = handleSlash("agents", ["run", "ghost"], loop, { codeRoot: project }); + expect(result.info).toContain("not found"); + }); + + it("/agents run without args keeps $ARGUMENTS placeholder", () => { + writeAgent("noargs", "name: noargs\ndescription: No args", "Do: $ARGUMENTS done"); + + const result = handleSlash("agents", ["run", "noargs"], loop, { codeRoot: project }); + // No extra args → $ARGUMENTS stays verbatim in the body + expect(result.resubmit).toContain("$ARGUMENTS"); + }); +}); diff --git a/tests/slash.test.ts b/tests/slash.test.ts index 44e820bcf..f1ce34b6a 100644 --- a/tests/slash.test.ts +++ b/tests/slash.test.ts @@ -700,7 +700,7 @@ describe("handleSlash", () => { // Case-insensitive. expect(suggestSlashCommands("HE").map((s) => s.cmd)).toEqual(["help"]); // Empty prefix returns the full non-advanced release list, including code commands. - expect(suggestSlashCommands("", true)).toHaveLength(48); + expect(suggestSlashCommands("", true)).toHaveLength(49); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("logs"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("language"); expect(suggestSlashCommands("", true).map((s) => s.cmd)).toContain("weixin"); diff --git a/tests/ui-slash-suggestions.test.tsx b/tests/ui-slash-suggestions.test.tsx index 3afb18021..68b844fdc 100644 --- a/tests/ui-slash-suggestions.test.tsx +++ b/tests/ui-slash-suggestions.test.tsx @@ -95,14 +95,14 @@ describe("SlashSuggestions", () => { const frame = lastFrame() ?? ""; unmount(); - expect(matches).toHaveLength(48); + expect(matches).toHaveLength(49); expect(names).toContain("language"); expect(names).toContain("weixin"); expect(names).toContain("btw"); expect(names).toContain("about"); - expect(countAdvancedCommands(true)).toBe(10); - expect(frame).toContain("48 commands"); - expect(frame).toContain("+ 10 advanced"); + expect(countAdvancedCommands(true)).toBe(11); + expect(frame).toContain("49 commands"); + expect(frame).toContain("+ 11 advanced"); }); it("surfaces /language for typed language prefixes", () => { From 4c36757fc9169a4bd7071120966ab3e674d32052 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 31 May 2026 09:32:52 +0800 Subject: [PATCH 9/9] fix(slash): restore subagent slash semantics --- src/cli/ui/App.tsx | 41 +++++-- src/cli/ui/hooks/useExtraSlashHandlers.ts | 46 +++++-- src/cli/ui/slash/custom.ts | 21 +++- src/cli/ui/slash/handlers/basic.ts | 55 ++++++++- src/cli/ui/slash/types.ts | 5 + src/skills.ts | 5 +- tests/slash-custom.test.ts | 139 ++++++++++++++++++++++ tests/slash-md-commands.test.ts | 49 ++++++-- 8 files changed, 326 insertions(+), 35 deletions(-) diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 54a7d8cbe..12127aeb7 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -593,9 +593,7 @@ function AppInner({ codeMode?.rootDir, ); const { hookList, reloadHooks } = useHookList(codeMode?.rootDir); - const { handlers: extraHandlers, reload: reloadExtraHandlers } = useExtraSlashHandlers( - codeMode?.rootDir, - ); + const extraSlash = useExtraSlashHandlers(currentRootDir); // Session-scoped edit history + undo banner + /undo, /history, /show // handlers. Kept in a custom hook so App.tsx only sees the small API // it needs —append an edit, arm the banner, answer the slash @@ -1115,6 +1113,33 @@ function AppInner({ [currentRootDir, loop.client, loop.log.entries, loop.model, model, onSwitchSession, session], ); + // biome-ignore lint/correctness/useExhaustiveDependencies: subagentSinkRef.current is a mutable ref intentionally read at call time. + const runSlashSubagent = useCallback( + async (skill: import("../../skills.js").Skill, task: string): Promise => { + if (!tools) { + return `▲ subagent "${skill.name}" is unavailable in this session (no tool registry wired).`; + } + const result = await spawnSubagent({ + client: loop.client, + parentRegistry: tools, + system: skill.body, + task, + model: skill.model, + allowedTools: skill.allowedTools, + sink: subagentSinkRef.current, + skillName: skill.name, + }); + if (result.forcedSummary) { + return `▸ subagent "${skill.name}" returned a partial answer\n\n${result.output}`; + } + if (!result.success) { + return `▲ subagent "${skill.name}" failed: ${result.error ?? "unknown subagent error"}`; + } + return result.output; + }, + [loop.client, tools], + ); + const switchWorkspaceRoot = useCallback( (newPath: string) => { if (!codeMode?.reregisterTools) return { ok: false, info: t("handlers.edits.cwdCodeOnly") }; @@ -3033,6 +3058,7 @@ function AppInner({ codeHistory: codeMode ? codeHistory : undefined, codeShowEdit: codeMode ? codeShowEdit : undefined, codeRoot: codeMode ? currentRootDir : undefined, + workspaceRoot: currentRootDir, pendingEditCount: codeMode ? pendingEdits.current.length : undefined, memoryRoot: currentRootDir, planMode, @@ -3073,6 +3099,7 @@ function AppInner({ status: weixin.status, }, sessionId: session, + runSlashSubagent, getEngineeringLifecycleSnapshot: codeMode ? () => engineeringLifecycleRef.current?.snapshot() ?? null : undefined, @@ -3128,8 +3155,8 @@ function AppInner({ return added; }, reloadHooks: () => reloadHooks(codeMode ? currentRootDir : undefined), - extraHandlers, - reloadExtraHandlers: () => reloadExtraHandlers(), + extraHandlers: extraSlash.handlers, + reloadExtraHandlers: () => extraSlash.reload(), switchCwd: codeMode?.reregisterTools ? switchWorkspaceRoot : undefined, reloadMcp: mcpRuntime ? async () => { @@ -3703,8 +3730,8 @@ function AppInner({ generateCurrentSessionTitle, switchWorkspaceRoot, system, - extraHandlers, - reloadExtraHandlers, + runSlashSubagent, + extraSlash, ], ); diff --git a/src/cli/ui/hooks/useExtraSlashHandlers.ts b/src/cli/ui/hooks/useExtraSlashHandlers.ts index e0aaf416d..440c4150c 100644 --- a/src/cli/ui/hooks/useExtraSlashHandlers.ts +++ b/src/cli/ui/hooks/useExtraSlashHandlers.ts @@ -1,7 +1,7 @@ // Combined extra slash-command handler map: .md commands > settings.json > skills/agents. import { join } from "node:path"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { SkillStore } from "../../../skills.js"; import { setExtraSlashSpecs } from "../slash/commands.js"; import { CustomSlashRegistry } from "../slash/custom.js"; @@ -25,11 +25,8 @@ export function useExtraSlashHandlers( projectRoot: string | undefined, homeDir?: string, ): ExtraSlashHandlers { - const projectRootRef = useRef(projectRoot); - projectRootRef.current = projectRoot; - const build = useCallback((): Record => { - const root = projectRootRef.current; + const root = projectRoot; const handlers: Record = {}; // Skill auto-registration (lowest priority) @@ -40,13 +37,38 @@ export function useExtraSlashHandlers( for (const skill of skills) { handlers[skill.name] = (_args, _loop, _ctx) => { const fresh = skillStoreRef.current.read(skill.name); - const body = fresh?.body ?? skill.body; - const desc = fresh?.description ?? skill.description; - const header = `# Skill: ${skill.name}${desc ? `\n> ${desc}` : ""}`; + const resolved = fresh ?? skill; + const body = resolved.body; + const desc = resolved.description; const extraArgs = _args.join(" ").trim(); + if (resolved.runAs === "subagent") { + if (!extraArgs) { + return { + info: `skill "${resolved.name}" runs as a subagent and requires a task argument.`, + }; + } + if (!_ctx.runSlashSubagent) { + return { + info: `skill "${resolved.name}" runs as a subagent, but this session cannot launch subagents from slash commands.`, + }; + } + void _ctx.runSlashSubagent(resolved, extraArgs).then( + (text) => { + if (text) _ctx.postInfo?.(text); + }, + (err) => { + const reason = err instanceof Error ? err.message : String(err); + _ctx.postInfo?.(`▲ subagent "${resolved.name}" failed: ${reason}`); + }, + ); + return { + info: `▸ running skill "${resolved.name}" — ${extraArgs}`, + }; + } + const header = `# Skill: ${skill.name}${desc ? `\n> ${desc}` : ""}`; const argsLine = extraArgs ? `\n\nArguments: ${extraArgs}` : ""; return { - info: `▸ running skill "${skill.name}"${extraArgs ? ` — ${extraArgs}` : ""}`, + info: `▸ running skill "${resolved.name}"${extraArgs ? ` — ${extraArgs}` : ""}`, resubmit: `${header}\n\n${body}${argsLine}`, }; }; @@ -116,9 +138,13 @@ export function useExtraSlashHandlers( setExtraSlashSpecs(extraSpecs); return handlers; - }, [homeDir]); + }, [homeDir, projectRoot]); const handlersRef = useRef>(build()); + useEffect(() => { + handlersRef.current = build(); + }, [build]); + const reload = useCallback((): number => { handlersRef.current = build(); return Object.keys(handlersRef.current).length; diff --git a/src/cli/ui/slash/custom.ts b/src/cli/ui/slash/custom.ts index bf7676883..d6f3c0d80 100644 --- a/src/cli/ui/slash/custom.ts +++ b/src/cli/ui/slash/custom.ts @@ -5,6 +5,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { globalSettingsPath, projectSettingsPath } from "../../../hooks.js"; import { t } from "../../../i18n/index.js"; +import { quoteForCmdExe } from "../../../tools/shell.js"; import type { SlashCommandSpec, SlashResult } from "./types.js"; /** Per-command config shape inside settings.json["slashCommands"]. */ @@ -24,6 +25,21 @@ interface SlashSettings { const DEFAULT_TIMEOUT_MS = 10_000; +function quotePosixShellArg(arg: string): string { + if (arg === "") return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, `'"'"'`)}'`; +} + +function quoteShellArg(arg: string): string { + return process.platform === "win32" ? quoteForCmdExe(arg) : quotePosixShellArg(arg); +} + +function appendShellArgs(command: string, args: readonly string[]): string { + if (args.length === 0) return command; + return `${command} ${args.map(quoteShellArg).join(" ")}`; +} + function readSlashSettings(path: string): SlashSettings | null { if (!existsSync(path)) return null; try { @@ -111,8 +127,9 @@ export class CustomSlashRegistry { * captures stdout/stderr, and returns the output as `info`. */ execute(name: string, args: string[], command: string): SlashResult { try { - // Append user-passed args to the command string - const cmd = args.length > 0 ? `${command} ${args.join(" ")}` : command; + // Shell commands remain shell-native, but user args must be quoted so + // spaces/metacharacters stay literal instead of changing execution. + const cmd = appendShellArgs(command, args); const stdout = execSync(cmd, { encoding: "utf8" as const, timeout: DEFAULT_TIMEOUT_MS, diff --git a/src/cli/ui/slash/handlers/basic.ts b/src/cli/ui/slash/handlers/basic.ts index b341f5e4d..df5d48039 100644 --- a/src/cli/ui/slash/handlers/basic.ts +++ b/src/cli/ui/slash/handlers/basic.ts @@ -3,15 +3,18 @@ import { join } from "node:path"; import { wrapToCells } from "@/cli/ui/text-width.js"; import { t, tObj } from "@/i18n/index.js"; import { VERSION } from "@/version.js"; +import type { Skill } from "../../../../skills.js"; import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js"; import { SLASH_COMMANDS, SLASH_GROUP_ORDER, orderSlashCommandsByGroup } from "../commands.js"; import type { SlashHandler } from "../dispatch.js"; -import { loadMarkdownAgents } from "../md-commands.js"; +import { type MarkdownAgentDef, loadMarkdownAgents } from "../md-commands.js"; import type { SlashCommandSpec, SlashGroup } from "../types.js"; const ABOUT_WEBSITE = "https://esengine.github.io/DeepSeek-Reasonix/"; const ABOUT_REPO = "https://github.com/esengine/DeepSeek-Reasonix"; const ABOUT_LICENSE = "MIT"; +const VALID_NEW_AGENT_NAME = + /^[a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf][a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf_.-]{0,63}$/; const exit: SlashHandler = () => ({ exit: true }); @@ -165,6 +168,32 @@ const about: SlashHandler = () => { return { info: lines.join("\n") }; }; +function currentWorkspaceRoot(ctx: Parameters[2]): string | undefined { + return ctx.workspaceRoot ?? ctx.codeRoot; +} + +function parseAgentAllowedTools(raw: string | undefined): readonly string[] | undefined { + if (!raw) return undefined; + const tools = raw + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean); + return tools.length > 0 ? tools : undefined; +} + +function toSubagentSkill(agent: MarkdownAgentDef): Skill { + return { + name: agent.name, + description: agent.description, + body: agent.body, + scope: "project", + path: agent.source, + allowedTools: parseAgentAllowedTools(agent.tools), + runAs: "subagent", + model: agent.model, + }; +} + const slashCmd: SlashHandler = (args, _loop, ctx) => { const sub = (args[0] ?? "").toLowerCase(); @@ -191,7 +220,7 @@ const slashCmd: SlashHandler = (args, _loop, ctx) => { }; const agents: SlashHandler = (args, _loop, ctx) => { - const projectRoot = ctx.codeRoot; + const projectRoot = currentWorkspaceRoot(ctx); const dirs = (root: string) => [ join(root, ".reasonix", "agents"), join(root, ".claude", "agents"), @@ -203,6 +232,9 @@ const agents: SlashHandler = (args, _loop, ctx) => { if (sub === "new" || sub === "init") { const name = args[1]; if (!name) return { info: "usage: /agents new " }; + if (!VALID_NEW_AGENT_NAME.test(name)) { + return { info: `invalid agent name: "${name}" — use letters, digits, CJK, _, -, .` }; + } if (!projectRoot) return { info: "agent creation requires a project root (run from reasonix code)." }; const targetDir = join(projectRoot, ".reasonix", "agents"); @@ -239,10 +271,25 @@ The agent runs as an isolated subagent (subagent mode). const found = all.find((a) => a.name === name); if (!found) return { info: `agent "${name}" not found.` }; const extraArgs = args.slice(2).join(" ").trim(); - const body = extraArgs ? found.body.replace(/\$ARGUMENTS/g, extraArgs) : found.body; + if (!extraArgs) { + return { info: `agent "${name}" runs as a subagent and requires a task argument.` }; + } + if (!ctx.runSlashSubagent) { + return { + info: `agent "${name}" runs as a subagent, but this session cannot launch subagents from slash commands.`, + }; + } + void ctx.runSlashSubagent(toSubagentSkill(found), extraArgs).then( + (text) => { + if (text) ctx.postInfo?.(text); + }, + (err) => { + const reason = err instanceof Error ? err.message : String(err); + ctx.postInfo?.(`▲ subagent "${name}" failed: ${reason}`); + }, + ); return { info: `▸ running agent "${name}"${extraArgs ? ` — ${extraArgs}` : ""}`, - resubmit: `# Agent: ${found.name}${found.description ? `\n> ${found.description}` : ""}\n\n${body}`, }; } diff --git a/src/cli/ui/slash/types.ts b/src/cli/ui/slash/types.ts index 47b7f8aa8..8e49c8e9e 100644 --- a/src/cli/ui/slash/types.ts +++ b/src/cli/ui/slash/types.ts @@ -1,6 +1,7 @@ import type { EngineeringLifecycleSnapshot } from "../../../code/lifecycle.js"; import type { EditMode } from "../../../config.js"; import type { McpServerSummary } from "../../../mcp/summary.js"; +import type { Skill } from "../../../skills.js"; import type { JobRegistry } from "../../../tools/jobs.js"; import type { PlanStep } from "../../../tools/plan.js"; import type { CodeUndoOutput } from "../undo-context.js"; @@ -70,6 +71,8 @@ export interface SlashContext { codeHistory?: () => string; codeShowEdit?: (args: readonly string[]) => string; codeRoot?: string; + /** Current workspace root even outside code mode — used by project-scoped slash extensions. */ + workspaceRoot?: string; getEngineeringLifecycleSnapshot?: () => EngineeringLifecycleSnapshot | null; pendingEditCount?: number; mcpServers?: McpServerSummary[]; @@ -167,6 +170,8 @@ export interface SlashContext { }; /** Current session id — included in `/feedback`'s diagnostic block when present. */ sessionId?: string; + /** Launch a runAs=subagent skill/agent from a slash command and return the final user-facing text. */ + runSlashSubagent?: (skill: Skill, task: string) => Promise; /** Extra slash-command handlers (skill auto-registration + custom commands from settings.json). * Checked after the static HANDLERS record; project/global scope merge handled upstream. */ extraHandlers?: Record; diff --git a/src/skills.ts b/src/skills.ts index 4beeac774..4a6018bdc 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -252,13 +252,14 @@ export class SkillStore { if (!isValidSkillName(name)) return null; for (const { dir, scope, status } of this.roots()) { if (status !== "ok") continue; + const isAgentDir = basename(dir) === "agents"; const dirCandidate = join(dir, name, SKILL_FILE); if (existsSync(dirCandidate) && statSync(dirCandidate).isFile()) { - return this.parse(dirCandidate, name, scope); + return this.parse(dirCandidate, name, scope, isAgentDir); } const flatCandidate = join(dir, `${name}.md`); if (existsSync(flatCandidate) && statSync(flatCandidate).isFile()) { - return this.parse(flatCandidate, name, scope); + return this.parse(flatCandidate, name, scope, isAgentDir); } } if (!this.disableBuiltins) { diff --git a/tests/slash-custom.test.ts b/tests/slash-custom.test.ts index 6c667a20a..c973f3e0c 100644 --- a/tests/slash-custom.test.ts +++ b/tests/slash-custom.test.ts @@ -4,7 +4,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import React from "react"; +// @ts-expect-error react-test-renderer is present at runtime but has no local typings here. +import TestRenderer, { act } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type ExtraSlashHandlers, + useExtraSlashHandlers, +} from "../src/cli/ui/hooks/useExtraSlashHandlers.js"; import { handleSlash, parseSlash } from "../src/cli/ui/slash.js"; import { setExtraSlashSpecs, suggestSlashCommands } from "../src/cli/ui/slash/commands.js"; import { CustomSlashRegistry } from "../src/cli/ui/slash/custom.js"; @@ -24,6 +31,15 @@ function makeLoop() { }); } +function HookProbe(props: { + projectRoot?: string; + homeDir?: string; + onUpdate: (value: ExtraSlashHandlers) => void; +}) { + props.onUpdate(useExtraSlashHandlers(props.projectRoot, props.homeDir)); + return null; +} + // CustomSlashRegistry describe("CustomSlashRegistry", () => { let home: string; @@ -120,6 +136,17 @@ describe("CustomSlashRegistry", () => { expect(result.info).toBe("--verbose foo"); }); + it("execute() quotes user args before appending them to the shell command", () => { + const reg = new CustomSlashRegistry({ homeDir: home }); + const dangerArg = process.platform === "win32" ? "one & echo injected" : "one; echo injected"; + const result = reg.execute( + "test", + [dangerArg], + 'node -e "process.stdout.write(process.argv[1])"', + ); + expect(result.info).toBe(dangerArg); + }); + it("execute() returns error info when command fails", () => { const reg = new CustomSlashRegistry({ homeDir: home }); const result = reg.execute("fail", [], "nonexistent_command_xyz 2>/dev/null; exit 1"); @@ -266,6 +293,118 @@ describe("Skill disableModelInvocation", () => { }); }); +describe("useExtraSlashHandlers", () => { + let home: string; + let project1: string; + let project2: string; + let renderer: TestRenderer.ReactTestRenderer | null; + let latest: ExtraSlashHandlers | null; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "reasonix-extra-home-")); + project1 = mkdtempSync(join(tmpdir(), "reasonix-extra-proj1-")); + project2 = mkdtempSync(join(tmpdir(), "reasonix-extra-proj2-")); + renderer = null; + latest = null; + }); + + afterEach(() => { + renderer?.unmount(); + setExtraSlashSpecs([]); + rmSync(home, { recursive: true, force: true }); + rmSync(project1, { recursive: true, force: true }); + rmSync(project2, { recursive: true, force: true }); + }); + + it("refreshes handlers when the project root changes", async () => { + const dir1 = join(project1, ".reasonix", "commands"); + const dir2 = join(project2, ".reasonix", "commands"); + mkdirSync(dir1, { recursive: true }); + mkdirSync(dir2, { recursive: true }); + writeFileSync(join(dir1, "one.md"), "---\ndescription: one\n---\n\none", "utf8"); + writeFileSync(join(dir2, "two.md"), "---\ndescription: two\n---\n\ntwo", "utf8"); + + await act(async () => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + projectRoot: project1, + homeDir: home, + onUpdate: (value) => { + latest = value; + }, + }), + ); + }); + + expect(Object.keys(latest!.handlers)).toContain("one"); + expect(Object.keys(latest!.handlers)).not.toContain("two"); + + await act(async () => { + renderer!.update( + React.createElement(HookProbe, { + projectRoot: project2, + homeDir: home, + onUpdate: (value) => { + latest = value; + }, + }), + ); + }); + + expect(Object.keys(latest!.handlers)).toContain("two"); + expect(Object.keys(latest!.handlers)).not.toContain("one"); + }); + + it("auto-registered subagent skills call runSlashSubagent and post their result", async () => { + const agentsDir = join(project1, ".reasonix", "agents"); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "planner.md"), + "---\nname: planner\ndescription: Plan work\ntools: Read, Write\nmodel: deepseek-v4-pro\n---\n\nReview: $ARGUMENTS", + "utf8", + ); + + await act(async () => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + projectRoot: project1, + homeDir: home, + onUpdate: (value) => { + latest = value; + }, + }), + ); + }); + + const runSlashSubagent = vi.fn().mockResolvedValue("subagent output"); + let posted = ""; + const result = latest!.handlers.planner?.(["audit"], makeLoop(), { + runSlashSubagent, + postInfo: (text) => { + posted = text; + }, + }); + + expect(result?.resubmit).toBeUndefined(); + expect(result?.info).toContain("planner"); + + await act(async () => { + await Promise.resolve(); + }); + + expect(runSlashSubagent).toHaveBeenCalledWith( + expect.objectContaining({ + name: "planner", + runAs: "subagent", + model: "deepseek-v4-pro", + allowedTools: ["Read", "Write"], + }), + "audit", + ); + expect(posted).toBe("subagent output"); + }); +}); + // /slash handler describe("/slash handler", () => { let loop: CacheFirstLoop; diff --git a/tests/slash-md-commands.test.ts b/tests/slash-md-commands.test.ts index 59ec39fd0..4faeec3da 100644 --- a/tests/slash-md-commands.test.ts +++ b/tests/slash-md-commands.test.ts @@ -294,7 +294,7 @@ describe("/agents handler", () => { writeAgent("planner", "name: planner\ndescription: Plans", "Plan things."); writeAgent("reviewer", "name: reviewer\ndescription: Reviews", "Review things."); - const result = handleSlash("agents", [], loop, { codeRoot: project }); + const result = handleSlash("agents", [], loop, { workspaceRoot: project }); expect(result.info).toContain("2 available"); expect(result.info).toContain("/planner"); expect(result.info).toContain("/reviewer"); @@ -335,13 +335,19 @@ describe("/agents handler", () => { }); it("/agents new creates a stub file", () => { - const result = handleSlash("agents", ["new", "myagent"], loop, { codeRoot: project }); + const result = handleSlash("agents", ["new", "myagent"], loop, { workspaceRoot: project }); expect(result.info).toContain("created"); // Verify file exists expect(existsSync(join(project, ".reasonix", "agents", "myagent.md"))).toBe(true); }); + it("/agents new rejects invalid names that would escape the agents directory", () => { + const result = handleSlash("agents", ["new", "../escape"], loop, { workspaceRoot: project }); + expect(result.info).toContain("invalid agent name"); + expect(existsSync(join(project, ".reasonix", "escape.md"))).toBe(false); + }); + it("/agents new without name shows usage", () => { const result = handleSlash("agents", ["new"], loop, { codeRoot: project }); expect(result.info).toContain("usage"); @@ -358,14 +364,38 @@ describe("/agents handler", () => { expect(result.info).toContain("requires a project root"); }); - it("/agents run invokes agent with resubmit", () => { - writeAgent("runner", "name: runner\ndescription: Runner", "Execute: $ARGUMENTS"); + it("/agents run invokes the agent as a subagent and posts its result", async () => { + writeAgent( + "runner", + "name: runner\ndescription: Runner\ntools: Read, Write\nmodel: deepseek-v4-pro", + "Execute: $ARGUMENTS", + ); - const result = handleSlash("agents", ["run", "runner", "task1"], loop, { codeRoot: project }); - expect(result.resubmit).toBeDefined(); - expect(result.resubmit).toContain("Execute: task1"); + const runSlashSubagent = vi.fn().mockResolvedValue("agent result"); + let posted = ""; + const result = handleSlash("agents", ["run", "runner", "task1"], loop, { + workspaceRoot: project, + runSlashSubagent, + postInfo: (text) => { + posted = text; + }, + }); + + await Promise.resolve(); + + expect(result.resubmit).toBeUndefined(); expect(result.info).toContain("runner"); expect(result.info).toContain("task1"); + expect(runSlashSubagent).toHaveBeenCalledWith( + expect.objectContaining({ + name: "runner", + runAs: "subagent", + model: "deepseek-v4-pro", + allowedTools: ["Read", "Write"], + }), + "task1", + ); + expect(posted).toBe("agent result"); }); it("/agents run without name shows usage", () => { @@ -378,11 +408,10 @@ describe("/agents handler", () => { expect(result.info).toContain("not found"); }); - it("/agents run without args keeps $ARGUMENTS placeholder", () => { + it("/agents run without args requires a task argument", () => { writeAgent("noargs", "name: noargs\ndescription: No args", "Do: $ARGUMENTS done"); const result = handleSlash("agents", ["run", "noargs"], loop, { codeRoot: project }); - // No extra args → $ARGUMENTS stays verbatim in the body - expect(result.resubmit).toContain("$ARGUMENTS"); + expect(result.info).toContain("requires a task argument"); }); });