Files
openclaude/src/commands.test.ts
214ee3dd2e feat(skills): add local skill CLI support (#1162)
* Add inspectable local skill CLI support

OpenClaude Skill Hub needs the runtime repo to treat project skills as first-class local assets before registry installation exists. This wires native .openclaude skill directories into discovery, preserves .claude compatibility, and adds list/show subcommands so users can inspect resolved local skills.

Constraint: Keep registry install, website catalog, and community governance out of this first runtime slice.

Rejected: Replace the existing skills loader wholesale | the repo already has working bundled, plugin, MCP, dynamic, and legacy command skill paths.

Confidence: medium

Scope-risk: moderate

Directive: Keep .claude skill loading compatible while .openclaude adoption rolls out.

Tested: bun test src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node dist/cli.mjs --bare skills list

Tested: node dist/cli.mjs --bare skills show debug

Tested: git diff --check

* Add local skill validation and removal

Skill Hub needs local package hygiene before registry install can be safe. This adds validation for SKILL.md directories and local removal for project or user skills without introducing remote registry behavior yet.

Constraint: Registry install and update flows are still out of scope for this slice.

Rejected: Implement install first | install needs the same validation and local removal semantics to avoid copying unsafe or unmanageable skill folders.

Confidence: medium

Scope-risk: moderate

Directive: Keep validation conservative; loosen individual checks only with explicit registry policy coverage.

Tested: bun test src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node dist/cli.mjs skills validate .openclaude/skills/demo-skill

Tested: node dist/cli.mjs skills list

Tested: node dist/cli.mjs skills show demo-skill

Tested: node dist/cli.mjs skills remove demo-skill

Tested: git diff --check

* Suppress startup banner for skills CLI

Skills management commands are meant to be script-friendly inspection operations. Printing the interactive startup screen before the list/show/validate output makes the command noisy and hard to read.

Constraint: Keep the interactive startup screen for normal OpenClaude sessions.

Confidence: high

Scope-risk: narrow

Tested: bun run build

Tested: node dist/cli.mjs skills list

Tested: git diff --check

* Make skills list readable for daily CLI use

The default skills list output was a metadata-heavy dump, which made bundled and local skills difficult to scan. This changes the human formatter to an aligned table with wrapped descriptions while keeping machine-readable metadata behind --json.

Constraint: Default list output must stay compact and human-readable while JSON remains script-friendly.

Rejected: Keep version and trust columns in the default table | those fields add noise and remain available through --json/show.

Confidence: high

Scope-risk: narrow

Directive: Keep the default list formatter focused on scanability; add metadata to --json or detail commands instead of widening the daily table.

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node dist/cli.mjs skills list

Tested: node dist/cli.mjs skills list --json

* Stabilize skills tests under CI

The PR introduced skills tests that passed in focused runs but failed under the full GitHub Actions Bun test job. The formatter test now uses bun:test consistently, and skill directory tests explicitly restore the setting-source state they rely on.

Constraint: CI runs the full Bun suite, so tests must avoid node:test interop and shared setting-source leakage.

Confidence: medium

Scope-risk: narrow

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: git diff --check

Not-tested: Full local bun test still has unrelated provider/OAuth failures on this machine.

* Isolate user skill precedence test state

The full CI suite can mutate process-wide config state while this test is running, so the user-vs-project precedence assertion now runs in a child Bun process with its own CLAUDE_CONFIG_DIR.

Constraint: getSkillDirCommands reads global config/env state, so this precedence test needs process isolation under the full suite.

Confidence: medium

Scope-risk: narrow

Tested: bun test src/skills/loadSkillsDir.test.ts src/cli/handlers/skills.test.ts src/commands.test.ts

Tested: git diff --check

* Stabilize conversation arc perf checks

The CI runner was failing the conversation arc benchmarks because they used shared persisted knowledge graph state and strict wall-clock thresholds. The tests now isolate graph storage in a temporary config directory and keep only coarse regression limits suitable for noisy shared runners.

Constraint: GitHub Actions shared runners can have variable storage/indexing latency.

Rejected: Remove the benchmark coverage entirely | the tests still provide useful regression signals when isolated and coarse-grained.

Confidence: medium

Scope-risk: narrow

Directive: Keep performance tests isolated from persisted user/project graph state.

Tested: bun test src/utils/conversationArc.perf.test.ts src/skills/loadSkillsDir.test.ts src/cli/handlers/skills.test.ts src/commands.test.ts

Tested: bun run smoke

* Let users install skills from registries and local sources

The skill hub CLI could list, inspect, validate, and remove local skills, but it had no supported install path. This adds a project/global install command that accepts local directories, raw SKILL.md files or URLs, and registry IDs with checksum validation when registry metadata provides one.

Constraint: The companion openclaude-skills repository currently publishes SKILL.md files without riskLevel metadata, so validation keeps riskLevel optional while preserving required identity/source fields.

Rejected: Require the external skills repository to be cloned into openclaude | install should work from registry metadata or explicit local paths without coupling the repos.

Confidence: high

Scope-risk: moderate

Directive: Keep --json/list behavior machine-compatible; install output should remain human-readable and validation should not reject normal security-review prose.

Tested: bun test src/cli/handlers/skillsInstall.test.ts src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: bun run smoke

* Stabilize skills install tests in the full suite

The install tests used shared console and cwd globals, which passed in isolation but raced with unrelated test files under Bun's full parallel suite. This makes the tests assert on installed files directly and injects the project directory into the handler for deterministic test isolation.

Constraint: The CLI still resolves project installs from the runtime cwd; projectDir is only used by direct handler tests.

Confidence: high

Scope-risk: narrow

Tested: bun test src/cli/handlers/skillsInstall.test.ts src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: bun run smoke

* Keep skills install coverage in the existing skills suite

The new standalone install test file changed Bun's parallel test scheduling and exposed unrelated global-state races in CI. Moving the coverage into the existing skills handler test file keeps the install behavior covered without adding another parallel test unit.

Constraint: Some existing tests mutate cwd/config globals under full-suite parallelism.

Confidence: medium

Scope-risk: narrow

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: git diff --check

* Harden skill install paths before validation

The install path used registry or SKILL.md names to create temporary and target directories before validation rejected unsafe names. This validates the install name before path construction, keeps the temp root as an explicit cleanup target, and resolves install targets under the selected skills root before copy or force removal.

Constraint: Registry and raw SKILL.md sources are untrusted until validation completes.

Rejected: Rely on validateSkillPath after temp construction | unsafe names can affect filesystem paths before validation runs.

Confidence: high

Scope-risk: narrow

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: bun run smoke

Tested: git diff --check

* Hide bundled skills from human skills list

The default skills list is meant for skills users can inspect and manage in the current environment. Bundled skills remain available internally and in JSON metadata, but the human table now omits bundled rows and removes the Source column.

Constraint: --json remains machine-readable with full source metadata.

Confidence: high

Scope-risk: narrow

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node dist/cli.mjs skills list

Tested: node dist/cli.mjs skills list --json

Tested: git diff --check

Tested: bun run smoke

* Include home dir in config cache key

Tests can mock homedir while leaving CLAUDE_CONFIG_DIR unset, so caching only by the env override can leak a temporary .openclaude root into later config/profile tests. Include homedir in the memoization key so config path resolution follows both inputs.

Constraint: Keep getClaudeConfigHomeDir memoized for hot callers.

Confidence: high

Scope-risk: narrow

Tested: bun test --max-concurrency=1 src/utils/openclaudePaths.test.ts src/utils/providerProfile.test.ts src/utils/knowledgeGraph.stress.test.ts tests/sdk/sdk-context-isolation.test.ts

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node dist/cli.mjs skills list

Tested: bun run smoke

Tested: git diff --check

* Hide bundled skills from public skills commands

Bundled skills are internal helpers, so the public skills CLI should only expose installed skills that users can inspect or manage. Filter bundled skills from JSON output and command lookups, and use a generic not-found response for hidden bundled names.

Constraint: Installed project and user skills remain listed, inspectable, removable, and available in JSON.

Confidence: high

Scope-risk: narrow

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node dist/cli.mjs skills list --json

Tested: node dist/cli.mjs skills show batch

Tested: node dist/cli.mjs skills remove batch

Tested: bun run smoke

Tested: git diff --check

* Stop config path leaks across tests

The full PR check can load config-path helpers after tests have mocked homedir or changed global session state. Explicit CLAUDE_CONFIG_DIR now bypasses the default-home memoization cache, and SDK contexts now treat sessionProjectDir: null as an intentional context value instead of falling back to stale global state.

Constraint: Keep default config-home resolution memoized for hot callers.

Confidence: high

Scope-risk: narrow

Tested: bun test --max-concurrency=1 src/utils/openclaudePaths.test.ts src/utils/providerProfile.test.ts src/utils/knowledgeGraph.stress.test.ts tests/sdk/sdk-context-isolation.test.ts

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: bun run smoke

Tested: git diff --check

* Stabilize config-sensitive tests in CI

The PR check showed provider profile tests sharing process.env/CWD-sensitive state and a knowledge graph stress test assuming a fixed config-root persistence path. Mark the profile tests that mutate global process state as non-concurrent and assert the corrupted Orama rename relative to the actual persistence path under test.

Constraint: Production behavior is unchanged; this only tightens test isolation.

Confidence: high

Scope-risk: narrow

Tested: bun test --max-concurrency=1 src/utils/openclaudePaths.test.ts src/utils/providerProfile.test.ts src/utils/knowledgeGraph.stress.test.ts tests/sdk/sdk-context-isolation.test.ts

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: git diff --check

* Explain skill remove scope mismatches

Removing a user-global skill without --global looked like a missing skill even though skills list showed it. Detect when the requested skill exists in the other local scope and print the exact removal command hint while keeping bundled/internal skills hidden as generic not found.

Constraint: Bundled skills remain hidden from public skills commands.

Confidence: high

Scope-risk: narrow

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node dist/cli.mjs skills remove pr-review

Tested: node dist/cli.mjs skills remove batch

Tested: bun run smoke

Tested: git diff --check

* Clarify empty skills list state

The public skills list now hides bundled/internal skills, so an empty result means there are no installed user or project skills. Use clearer copy to avoid implying internal skills do not exist.

Constraint: Bundled skills remain hidden from public skills commands.

Confidence: high

Scope-risk: narrow

Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: node /home/anaxy/Projects/openclaude/dist/cli.mjs skills list from empty temp project

Tested: bun run smoke

Tested: git diff --check

* fix(skills): preserve namespaced local installs

* Keep skills CLI independent of provider startup

Skills management commands need to work when provider configuration is broken, because they are local/script-friendly maintenance commands. Route skills subcommands before provider profile hydration and validation, including supported leading global flags such as --bare.

Constraint: Provider startup validation must still run for normal interactive and provider-backed commands.

Rejected: Import full main.tsx for the skills fast path | that loads optional bundled Chrome modules and re-couples the local skills path to interactive startup.

Confidence: high

Scope-risk: narrow

Tested: bun test src/entrypoints/cli.skills.test.ts src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts

Tested: bun run build

Tested: CLAUDE_CODE_USE_OPENAI=1 OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_API_KEY= node dist/cli.mjs skills list

Tested: CLAUDE_CODE_USE_OPENAI=1 OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_API_KEY= node dist/cli.mjs --bare skills list

Tested: bun run smoke

Tested: git diff --check

* Preserve reviewed skill install hardening after rebase

Rebasing PR #1162 onto current main flattened an earlier merge commit that carried reviewed Skill Hub hardening and regression coverage. This restores those final-tree changes as a normal linear commit so the rebased PR keeps the same behavior reviewers approved without retaining merge commits or mainline noise.

Constraint: Keep the PR branch linear for maintainer review while preserving the reviewed final tree from the conflict-resolved integration branch.

Rejected: Push the plain rebase result | it would drop registry sha256/version/trust metadata handling and associated tests from the reviewed PR state.

Confidence: high

Scope-risk: narrow

Directive: Do not remove the registry sha256 requirement or install-path regression tests without another security review.

Tested: final tree compared against fix-pr-1162-conflicts before verification

* Fix skills CLI review follow-ups

* Fix skills CLI review findings

* Address skills CLI review follow-ups

* Fix skills tests under bare-mode CI state

* Clear bare argv in skills tests

* Harden skills remove and loader tests

* Pin cwd state in skills remove test

* Use explicit project dir for skills removal

* Avoid skill remove test name collision

* Use fs abstraction for skills removal

* fix skills CLI review findings

* Fix skills CLI startup bypass and test isolation

* Fix skills CLI review findings

* Fix remaining skills CLI review findings

* Fix skills CLI review findings

---------

Co-authored-by: OpenClaude Worker 3 <worker-3@openclaude.local>
Co-authored-by: jatmn <the@jat.mn>
2026-07-06 11:08:17 +08:00

863 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chdir } from 'node:process'
import { afterEach, describe, expect, test } from 'bun:test'
import {
getAllowedSettingSources,
setAllowedSettingSources,
} from './bootstrap/state.js'
import type { CommandBase, PromptCommand } from './types/command.js'
import { runWithCwdOverride } from './utils/cwd.js'
import {
builtInCommandNames,
clearCommandMemoizationCaches,
formatDescriptionWithSource,
getCommands,
INTERNAL_ONLY_COMMANDS,
} from './commands.js'
import { registerBatchSkill } from './skills/bundled/batch.js'
import { registerDebugSkill } from './skills/bundled/debug.js'
import { registerLoopSkill } from './skills/bundled/loop.js'
import { registerSimplifySkill } from './skills/bundled/simplify.js'
import { registerUpdateConfigSkill } from './skills/bundled/updateConfig.js'
import {
clearBundledSkills,
getBundledSkills,
registerBundledSkill,
} from './skills/bundledSkills.js'
import { isCommand } from './types/command.js'
import {
resetSettingsCache,
setSessionSettingsCache,
} from './utils/settings/settingsCache.js'
function useLanguage(language?: string): void {
setSessionSettingsCache({
settings: language ? { language } : {},
errors: [],
})
}
afterEach(() => {
resetSettingsCache()
clearBundledSkills()
clearCommandMemoizationCaches()
})
// Narrows the Command union to the prompt variant so getPromptForCommand is
// callable; bughunter commands are always registered as prompt commands.
function findPromptCommand(cmds: ReturnType<typeof getCommands> extends Promise<infer T> ? T : never, name: string): CommandBase & PromptCommand {
const cmd = cmds.find(c => c.name === name)
if (!cmd || cmd.type !== 'prompt') {
throw new Error(`expected /${name} to be registered as a prompt command`)
}
return cmd
}
describe('builtInCommandNames', () => {
test('includes the LSP command', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-lsp-'))
try {
const cmds = await getCommands(cwd)
expect(cmds.map(c => c.name)).toContain('lsp')
} finally {
await rm(cwd, { recursive: true, force: true })
}
})
test('project skills take precedence over bundled skills with the same name', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-skill-precedence-'))
const originalSources = getAllowedSettingSources()
setAllowedSettingSources([
'userSettings',
'projectSettings',
'localSettings',
'flagSettings',
'policySettings',
])
registerBundledSkill({
name: 'debug',
description: 'Bundled debug skill',
async getPromptForCommand() {
return [{ type: 'text', text: 'bundled debug' }]
},
})
try {
const skillDir = join(cwd, '.openclaude', 'skills', 'debug')
await mkdir(skillDir, { recursive: true })
await writeFile(
join(skillDir, 'SKILL.md'),
`---\ndescription: Project debug skill\n---\n# Debug\n`,
'utf8',
)
clearCommandMemoizationCaches()
const cmds = await getCommands(cwd)
const debugCommands = cmds.filter(
(cmd): cmd is CommandBase & PromptCommand =>
cmd.type === 'prompt' && cmd.name === 'debug',
)
expect(debugCommands.length).toBeGreaterThanOrEqual(2)
expect(debugCommands[0].description).toBe('Project debug skill')
expect(debugCommands[0].source).toBe('projectSettings')
expect(debugCommands[1].description).toBe('Bundled debug skill')
expect(debugCommands[1].source).toBe('bundled')
} finally {
setAllowedSettingSources(originalSources)
await rm(cwd, { recursive: true, force: true })
clearCommandMemoizationCaches()
}
})
test('getCommands() includes bughunter for normal users (USER_TYPE unset)', async () => {
// Regression: bughunter previously lived in INTERNAL_ONLY_COMMANDS and was
// never available to non-ant users. Ensure it stays in the public COMMANDS list.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
// Clear ALL command caches — including the zero-arg COMMANDS() memoize that
// captures USER_TYPE at first call and never re-evaluates it. Without this,
// a prior test that ran with USER_TYPE=ant would pollute the COMMANDS cache
// and make bughunter appear gated even in a "normal user" run.
clearCommandMemoizationCaches()
// Use a unique tmp dir to avoid the loadAllCommands memoize cache
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-'))
try {
const cmds = await getCommands(cwd)
expect(cmds.map(c => c.name)).toContain('bughunter')
expect(INTERNAL_ONLY_COMMANDS.map(c => c.name)).not.toContain('bughunter')
} finally {
await rm(cwd, { recursive: true, force: true })
// Restore env vars to avoid test isolation issues
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
})
test('getCommands() includes bughunter-security and bughunter-perf for normal users', async () => {
// Sibling subcommands of /bughunter — must stay in the public COMMANDS list,
// not in INTERNAL_ONLY_COMMANDS, so normal users can invoke them.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-sibs-'))
try {
const cmds = await getCommands(cwd)
const names = cmds.map(c => c.name)
expect(names).toContain('bughunter-security')
expect(names).toContain('bughunter-perf')
const internalNames = INTERNAL_ONLY_COMMANDS.map(c => c.name)
expect(internalNames).not.toContain('bughunter-security')
expect(internalNames).not.toContain('bughunter-perf')
} finally {
await rm(cwd, { recursive: true, force: true })
// Restore env vars to avoid test isolation issues
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
})
const FULL_GIT_COMMANDS = [
'git status',
'git diff --name-only --diff-filter=AM',
'git diff --cached --name-only --diff-filter=AM',
'git log -10 --pretty=format: --name-only --diff-filter=AM',
'git ls-files',
'git diff HEAD -- .',
'git rev-parse --git-dir',
'git rev-parse --git-dir 2>&1',
'git diff HEAD -- . 2> /dev/null',
'git status 2>/dev/null',
'git diff --name-only --diff-filter=AM 2>/dev/null',
'git diff --cached --name-only --diff-filter=AM 2>/dev/null',
'git log -10 --pretty=format: --name-only --diff-filter=AM 2>/dev/null',
'git diff HEAD -- . 2>/dev/null',
]
const createMockToolContext = (cwd: string, commands: string[]) =>
({
getAppState: () => ({
toolPermissionContext: {
alwaysAllowRules: { command: commands },
alwaysDenyRules: {},
alwaysAskRules: {},
mode: 'default' as const,
additionalWorkingDirectories: new Map([[cwd, true]]),
isBypassPermissionsModeAvailable: false,
},
}),
abortController: new AbortController(),
options: {
debug: false,
mainLoopModel: '',
tools: {} as any,
verbose: false,
thinkingConfig: {} as any,
mcpClients: [] as any,
mcpResources: {} as any,
isNonInteractiveSession: false,
agentDefinitions: {} as any,
},
}) as any
/** Retry rm on EBUSY (Windows: dir held by spawned shell handle). */
async function rmRetry(dir: string, retries = 5, delay = 200): Promise<void> {
const originalCwd = process.cwd()
try {
// Move to a stable dir so the temp dir is no longer anyone's cwd.
chdir(tmpdir())
for (let i = 0; i < retries; i++) {
try {
await rm(dir, { recursive: true, force: true })
return
} catch (e: any) {
if ((e as NodeJS.ErrnoException).code === 'EBUSY' && i < retries - 1) {
await new Promise(r => setTimeout(r, delay))
continue
}
throw e
}
}
} finally {
chdir(originalCwd)
}
}
test('bughunter prompt generation works in non-git directory', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
// Create a temp dir WITHOUT .git
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-nogit-'))
try {
const cmds = await getCommands(cwd)
const bughunterCmd = findPromptCommand(cmds, 'bughunter')
// Generate the prompt - should not throw and should contain fallback text
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
// Run with cwd override so git commands execute in the temp dir (non-git)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return bughunterCmd.getPromptForCommand('', mockContext)
})
expect(promptBlocks).toBeDefined()
expect(promptBlocks.length).toBeGreaterThan(0)
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Verify git fallback text appears (not blank) - now in template as static text
// Shell tool outputs "(Bash completed with no output)" for empty results, which is valid
const checkEmptyIndicator = (text: string, expected: string) => {
expect(text).toMatch(new RegExp(`(${expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|Bash completed with no output)`))
}
checkEmptyIndicator(promptText, '(If empty: not a git repository or git unavailable)')
checkEmptyIndicator(promptText, '(If empty: no unstaged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no staged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no git history or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no diff available or not a git repo)')
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
// All three bughunter commands share the same git-context block template
// and the same lineLimits call, so we parameterize the regression tests
// over the sibling names. A regression in any one of them (e.g. losing
// the HEAD~10 → git log swap, or dropping the 400-line cap) would fail
// here even if a sibling's prompt was edited in isolation.
const BUGHUNTER_SIBLINGS = [
'bughunter',
'bughunter-security',
'bughunter-perf',
] as const
for (const cmdName of BUGHUNTER_SIBLINGS) {
test(`${cmdName} keeps git context populated in a fresh single-commit repo`, async () => {
// Regression: in a one-commit repo, the prior `git diff --name-only
// HEAD~10..HEAD` snippet exited 128, which the outer catch converted to
// stripping every snippet. Switched to `git log -10` so all the other
// git-context blocks (status, staged, diff) still populate even when
// the repo is too shallow for HEAD~10 to resolve.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(
join(tmpdir(), `oc-test-bughunter-fresh-${cmdName}-`),
)
try {
// Build a real one-commit repo with an unstaged change
const { spawnSync } = await import('node:child_process')
const run = (args: string[]) =>
spawnSync('git', args, { cwd, encoding: 'utf8' })
run(['init', '-q'])
run(['config', 'user.email', 't@t'])
run(['config', 'user.name', 't'])
run(['config', 'commit.gpgsign', 'false'])
await Bun.write(join(cwd, 'a.txt'), 'a\n')
run(['add', 'a.txt'])
run(['commit', '-q', '-m', 'init'])
await Bun.write(join(cwd, 'b.txt'), 'b\n') // untracked / unstaged
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, cmdName)
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText =
promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// git status should mention the new untracked b.txt (not "(Bash completed
// with no output)" — that would mean the catch-all stripped it).
expect(promptText).toContain('b.txt')
// The diff block should at least mention the file or the diff marker —
// and crucially must not be the "head -10 revision" error.
expect(promptText).not.toMatch(/unknown revision|HEAD~10/)
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
}
for (const cmdName of BUGHUNTER_SIBLINGS) {
test(`${cmdName} diff block is bounded to 400 lines`, async () => {
// Regression: the prompt label advertises "first 400 lines" but commit
// 73d0bcb dropped the `| head -400` cap. Reproduce with a 1000-line diff
// and assert the diff code block in the rendered prompt has ≤ 400 lines.
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(
join(tmpdir(), `oc-test-bughunter-cap-${cmdName}-`),
)
try {
const { spawnSync } = await import('node:child_process')
const run = (args: string[]) =>
spawnSync('git', args, { cwd, encoding: 'utf8' })
run(['init', '-q'])
run(['config', 'user.email', 't@t'])
run(['config', 'user.name', 't'])
run(['config', 'commit.gpgsign', 'false'])
// Commit a baseline file with 1000 lines
const baseline =
Array.from({ length: 1000 }, (_, i) => `line${i + 1}`).join('\n') +
'\n'
await Bun.write(join(cwd, 'big.txt'), baseline)
run(['add', 'big.txt'])
run(['commit', '-q', '-m', 'baseline'])
// Modify every line to force a 1000+-line diff
const modified =
Array.from({ length: 1000 }, (_, i) => `+line${i + 1}`).join('\n') +
'\n'
await Bun.write(join(cwd, 'big.txt'), modified)
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, cmdName)
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText =
promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Locate the DIFF code block and count its lines
const diffMatch = promptText.match(
/DIFF OF UNSTAGED \+ STAGED CHANGES[\s\S]*?```\n([\s\S]*?)\n```/,
)
expect(diffMatch).not.toBeNull()
const diffBody = diffMatch![1]
const diffLineCount = diffBody.split('\n').length
expect(diffLineCount).toBeLessThanOrEqual(400)
// And the line cap is actually being applied, not just truncating by
// chance: the diff body should have hundreds of lines truncated.
expect(diffLineCount).toBeGreaterThan(100)
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
}
test('bughunter does not execute shell snippets in user-provided args', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-inject-'))
try {
const cmds = await getCommands(cwd)
const bughunterCmd = findPromptCommand(cmds, 'bughunter')
const mockContext = createMockToolContext(cwd, [
'git status', 'git diff --name-only --diff-filter=AM',
'git diff --cached --name-only --diff-filter=AM',
'git log -10 --pretty=format: --name-only --diff-filter=AM',
'git ls-files', 'git diff HEAD -- .', 'head -400', 'head -50',
])
// Pass args containing shell-like syntax - it must appear verbatim, not executed
const maliciousScope = 'src/auth !`echo pwned`'
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return bughunterCmd.getPromptForCommand(maliciousScope, mockContext)
})
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// The shell snippet should appear VERBATIM (injection prevented)
expect(promptText).toContain(maliciousScope)
// The git context shell blocks should have been executed (showing fallback text)
// Accept either bash output or the static echo fallback text (for Windows/no-bash)
expect(promptText).toMatch(/(If empty:|bash completed with no output)/i)
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
test('bughunter-security prompt generation works in non-git directory', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-sec-nogit-'))
try {
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, 'bughunter-security')
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
// Run with cwd override so git commands execute in the temp dir (non-git)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Shell tool outputs "(Bash completed with no output)" for empty results, which is valid
const checkEmptyIndicator = (text: string, expected: string) => {
expect(text).toMatch(new RegExp(`(${expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|Bash completed with no output)`))
}
checkEmptyIndicator(promptText, '(If empty: not a git repository or git unavailable)')
checkEmptyIndicator(promptText, '(If empty: no unstaged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no staged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no git history or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no diff available or not a git repo)')
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
test('bughunter-perf prompt generation works in non-git directory', async () => {
const originalUserType = process.env['USER_TYPE']
const originalIsDemo = process.env['IS_DEMO']
delete process.env['USER_TYPE']
delete process.env['IS_DEMO']
clearCommandMemoizationCaches()
const cwd = await mkdtemp(join(tmpdir(), 'oc-test-bughunter-perf-nogit-'))
try {
const cmds = await getCommands(cwd)
const cmd = findPromptCommand(cmds, 'bughunter-perf')
const mockContext = createMockToolContext(cwd, FULL_GIT_COMMANDS)
// Run with cwd override so git commands execute in the temp dir (non-git)
const promptBlocks = await runWithCwdOverride(cwd, async () => {
return cmd.getPromptForCommand('', mockContext)
})
const promptText = promptBlocks[0].type === 'text' ? promptBlocks[0].text : ''
// Shell tool outputs "(Bash completed with no output)" for empty results, which is valid
const checkEmptyIndicator = (text: string, expected: string) => {
expect(text).toMatch(new RegExp(`(${expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|Bash completed with no output)`))
}
checkEmptyIndicator(promptText, '(If empty: not a git repository or git unavailable)')
checkEmptyIndicator(promptText, '(If empty: no unstaged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no staged changes or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no git history or not a git repo)')
checkEmptyIndicator(promptText, '(If empty: no diff available or not a git repo)')
} finally {
try {
await rmRetry(cwd)
} finally {
if (originalUserType !== undefined) {
process.env['USER_TYPE'] = originalUserType
} else {
delete process.env['USER_TYPE']
}
if (originalIsDemo !== undefined) {
process.env['IS_DEMO'] = originalIsDemo
} else {
delete process.env['IS_DEMO']
}
clearCommandMemoizationCaches()
}
}
})
test('includes the request-size diagnostic command', () => {
expect(builtInCommandNames()).toContain('request-size')
})
test('includes the /dream command', () => {
expect(builtInCommandNames()).toContain('dream')
})
})
describe('isCommand', () => {
test('rejects generated missing-module noop stubs', () => {
function noop19() {
return null
}
expect(isCommand(noop19)).toBe(false)
expect(isCommand({ isHidden: true, name: 'stub' })).toBe(false)
})
test('accepts real command objects', () => {
expect(
isCommand({
type: 'local',
name: 'example',
description: 'example command',
supportsNonInteractive: false,
load: async () => ({
call: async () => ({ type: 'skip' }),
}),
}),
).toBe(true)
})
})
describe('formatDescriptionWithSource', () => {
test('returns empty text for prompt commands missing a description', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'builtin',
description: undefined,
} as any
expect(formatDescriptionWithSource(command)).toBe('')
})
test('formats plugin commands with missing description safely', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'plugin',
description: undefined,
pluginInfo: {
pluginManifest: {
name: 'MyPlugin',
},
},
} as any
expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ')
})
test('translates prompt built-in descriptions using the current language', () => {
const command = {
name: 'review',
type: 'prompt',
source: 'builtin',
description: 'Review a pull request',
localizationKey: 'commands.review.description',
} as any
useLanguage('english')
expect(formatDescriptionWithSource(command)).toBe('Review a pull request')
useLanguage('vietnamese')
expect(formatDescriptionWithSource(command)).toBe('Đánh giá pull request')
})
test('falls back to English when an OpenClaude localization key is missing', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'builtin',
description: 'English fallback description',
localizationKey: 'commands.example.missing.description',
} as any
useLanguage('vietnamese')
expect(formatDescriptionWithSource(command)).toBe(
'English fallback description',
)
})
test('does not translate project, policy, workflow, or user-authored descriptions', () => {
const description = 'Review a pull request'
const promptCommand = (source: string) =>
({
name: 'external-review',
type: 'prompt',
source,
description,
}) as any
useLanguage('vietnamese')
expect(formatDescriptionWithSource(promptCommand('projectSettings'))).toBe(
'Review a pull request (project)',
)
expect(formatDescriptionWithSource(promptCommand('userSettings'))).toBe(
'Review a pull request (user)',
)
expect(formatDescriptionWithSource(promptCommand('policySettings'))).toBe(
'Review a pull request (managed)',
)
expect(formatDescriptionWithSource(promptCommand('localSettings'))).toBe(
'Review a pull request (project, gitignored)',
)
expect(formatDescriptionWithSource(promptCommand('flagSettings'))).toBe(
'Review a pull request (cli flag)',
)
expect(
formatDescriptionWithSource({
...promptCommand('projectSettings'),
kind: 'workflow',
}),
).toBe('Review a pull request (workflow)')
})
test('does not translate plugin descriptions that match built-in English text', () => {
const command = {
name: 'external-review',
type: 'prompt',
source: 'plugin',
description: 'Review a pull request',
pluginInfo: {
pluginManifest: {
name: 'MyPlugin',
},
},
} as any
useLanguage('vietnamese')
expect(formatDescriptionWithSource(command)).toBe(
'(MyPlugin) Review a pull request',
)
})
test('does not translate non-prompt local descriptions without a localization key', () => {
const command = {
name: 'external-review',
type: 'local',
description: 'Review a pull request',
} as any
useLanguage('vietnamese')
expect(formatDescriptionWithSource(command)).toBe('Review a pull request')
})
test('translates non-prompt local descriptions only with an explicit localization key', () => {
const command = {
name: 'copy',
type: 'local',
description:
"Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
localizationKey: 'commands.copy.description',
} as any
useLanguage('vietnamese')
expect(formatDescriptionWithSource(command)).toBe(
'Sao chép phản hồi gần nhất của Claude vào clipboard (hoặc /copy N cho phản hồi thứ N gần nhất)',
)
useLanguage('english')
expect(formatDescriptionWithSource(command)).toBe(
"Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
)
})
})
describe('bundled skill localization', () => {
test('resolves descriptions from the current language at read time', () => {
resetSettingsCache()
clearBundledSkills()
registerBatchSkill()
registerDebugSkill()
registerLoopSkill()
registerSimplifySkill()
registerUpdateConfigSkill()
const batch = getBundledSkills().find(command => command.name === 'batch')
const debug = getBundledSkills().find(command => command.name === 'debug')
const loop = getBundledSkills().find(command => command.name === 'loop')
const simplify = getBundledSkills().find(
command => command.name === 'simplify',
)
const updateConfig = getBundledSkills().find(
command => command.name === 'update-config',
)
const expectedDebugEnglish =
process.env.USER_TYPE === 'ant'
? 'Debug your current Claude Code session by reading the session debug log. Includes all event logging'
: 'Enable debug logging for this session and help diagnose issues'
const expectedDebugVietnamese =
process.env.USER_TYPE === 'ant'
? 'Debug phiên Claude Code hiện tại bằng cách đọc debug log của phiên. Bao gồm toàn bộ event logging'
: 'Bật debug logging cho phiên này và hỗ trợ chẩn đoán sự cố'
expect(batch).toBeDefined()
expect(debug).toBeDefined()
expect(loop).toBeDefined()
expect(simplify).toBeDefined()
expect(updateConfig).toBeDefined()
expect(batch!.localizationKey).toBe('skills.batch.description')
expect(loop!.localizationKey).toBe('skills.loop.description')
expect(loop!.whenToUseLocalizationKey).toBe('skills.loop.whenToUse')
useLanguage('english')
expect(batch!.description).toBe(
'Research and plan a large-scale change, then execute it in parallel across 530 isolated worktree agents that each open a PR.',
)
expect(debug!.description).toBe(expectedDebugEnglish)
expect(loop!.description).toBe(
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
)
expect(loop!.whenToUse).toBe(
'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.',
)
expect(simplify!.description).toBe(
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
)
expect(updateConfig!.description).toStartWith(
'Use this skill to configure the Claude Code harness via settings.json.',
)
useLanguage('vietnamese')
expect(batch!.description).toBe(
'Nghiên cứu và lập kế hoạch cho một thay đổi quy mô lớn, rồi thực thi song song trên 530 agent worktree cô lập, mỗi agent mở một PR.',
)
expect(debug!.description).toBe(expectedDebugVietnamese)
expect(loop!.description).toBe(
'Chạy một prompt theo khoảng thời gian cố định hoặc lên lịch lại động, bao gồm cả chế độ bảo trì lặp lại.',
)
expect(loop!.whenToUse).toBe(
'Khi người dùng muốn kiểm tra trạng thái, giám sát quy trình, chạy bảo trì định kỳ, hoặc chạy lại một prompt trong phiên hiện tại.',
)
expect(simplify!.description).toBe(
'Đánh giá code đã thay đổi về mặt tái sử dụng, chất lượng và hiệu suất, sau đó sửa các vấn đề tìm được.',
)
expect(updateConfig!.description).toStartWith(
'Sử dụng skill này để cấu hình Claude Code qua settings.json.',
)
useLanguage('english')
expect(loop!.description).toBe(
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
)
expect(updateConfig!.description).toStartWith(
'Use this skill to configure the Claude Code harness via settings.json.',
)
})
test('falls back to bundled skill English text when a localization key is missing', () => {
registerBundledSkill({
name: 'fallback-skill',
description: 'English-only bundled skill description',
descriptionKey: 'skills.fallback-skill.missing.description',
getPromptForCommand: async () => [],
})
const skill = getBundledSkills().find(
command => command.name === 'fallback-skill',
)
expect(skill).toBeDefined()
useLanguage('vietnamese')
expect(skill!.description).toBe('English-only bundled skill description')
expect(formatDescriptionWithSource(skill!)).toBe(
'English-only bundled skill description (bundled)',
)
})
})