mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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>
This commit is contained in:
co-authored by
OpenClaude Worker 3
jatmn
parent
6038681fc8
commit
214ee3dd2e
@@ -1734,4 +1734,3 @@ export function resetAllReplayIndexBuilders(): Array<{
|
||||
STATE.replayIndexBuilders.clear()
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'bun:test'
|
||||
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../test/sharedMutationLock.js'
|
||||
import {
|
||||
enableUserAndProjectSettingSources,
|
||||
restoreSettingState,
|
||||
} from '../../test/settingSourceState.js'
|
||||
import { setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js'
|
||||
import { clearCommandsCache } from '../../commands.js'
|
||||
import type { Command } from '../../types/command.js'
|
||||
import {
|
||||
getFsImplementation,
|
||||
setFsImplementation,
|
||||
} from '../../utils/fsOperations.js'
|
||||
import { skillsInstallHandler } from './skillsInstall.ts'
|
||||
import { skillsRemoveHandler } from './skills.ts'
|
||||
import {
|
||||
formatSkillsListForDisplay,
|
||||
formatSkillsListJson,
|
||||
trustLabel,
|
||||
} from './skillsListFormat.ts'
|
||||
import { getSkillRemoveNotFoundMessage } from './skillsRemoveMessage.ts'
|
||||
import { validateSkillPath } from './skillsValidation.ts'
|
||||
|
||||
type SkillCommand = Command & { type: 'prompt' }
|
||||
|
||||
const VALID_SKILL = `---
|
||||
name: sample-skill
|
||||
title: Sample Skill
|
||||
description: Sample skill used by install tests.
|
||||
version: 0.1.0
|
||||
category: test
|
||||
author: OpenClaude Tests
|
||||
license: MIT
|
||||
trust: local
|
||||
---
|
||||
|
||||
# Sample Skill
|
||||
|
||||
Use this skill for install tests.
|
||||
Document token scopes without storing secret values.
|
||||
`
|
||||
|
||||
const NAMESPACED_SKILL = `---
|
||||
name: git:commit
|
||||
title: Git Commit
|
||||
description: Nested git commit skill used by install tests.
|
||||
version: 0.1.0
|
||||
category: test
|
||||
author: OpenClaude Tests
|
||||
license: MIT
|
||||
trust: local
|
||||
---
|
||||
|
||||
# Git Commit
|
||||
|
||||
Use this skill for commit workflows.
|
||||
`
|
||||
|
||||
const MINIMAL_EXISTING_FORMAT_SKILL = `---
|
||||
description: Minimal existing-format skill.
|
||||
---
|
||||
|
||||
# Minimal Existing Format
|
||||
|
||||
Use this skill for compatibility tests.
|
||||
`
|
||||
|
||||
const PATH_TRAVERSAL_SKILL = `---
|
||||
name: ../escape
|
||||
title: Unsafe Skill
|
||||
description: Invalid skill used by install tests.
|
||||
version: 0.1.0
|
||||
category: test
|
||||
author: OpenClaude Tests
|
||||
license: MIT
|
||||
trust: local
|
||||
---
|
||||
|
||||
# Unsafe Skill
|
||||
`
|
||||
|
||||
function skill(
|
||||
name: string,
|
||||
description: string | undefined,
|
||||
source: SkillCommand['source'] = 'bundled',
|
||||
skillTrust?: string,
|
||||
): SkillCommand {
|
||||
return {
|
||||
type: 'prompt',
|
||||
name,
|
||||
description: description ?? '',
|
||||
hasUserSpecifiedDescription: description !== undefined,
|
||||
progressMessage: 'running',
|
||||
contentLength: description?.length ?? 0,
|
||||
source,
|
||||
skillTrust,
|
||||
loadedFrom: source === 'bundled' ? 'bundled' : 'skills',
|
||||
userInvocable: true,
|
||||
async getPromptForCommand() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function writeSkillDir(root: string): string {
|
||||
const skillDir = join(root, 'sample-skill')
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), VALID_SKILL, 'utf8')
|
||||
return skillDir
|
||||
}
|
||||
|
||||
function sha256OfSkillSource(text: string): string {
|
||||
return createHash('sha256')
|
||||
.update(text.replace(/\r\n/g, '\n'), 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function buildRegistryEntry(
|
||||
sourceDir: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: 'gitlawb/sample-skill',
|
||||
name: 'sample-skill',
|
||||
title: 'Sample Skill',
|
||||
description: 'Sample skill used by install tests.',
|
||||
trust: 'official',
|
||||
version: '0.1.0',
|
||||
license: 'MIT',
|
||||
author: 'OpenClaude Tests',
|
||||
source: join(sourceDir, 'SKILL.md'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function stagedInstallTempDirs(): string[] {
|
||||
return readdirSync(tmpdir()).filter(entry =>
|
||||
entry.startsWith('openclaude-skill-install-'),
|
||||
)
|
||||
}
|
||||
|
||||
function assertNoNewStagedInstallDirs(before: string[]): void {
|
||||
assert.deepEqual(stagedInstallTempDirs().sort(), before.sort())
|
||||
}
|
||||
|
||||
async function withTempDir<T>(fn: (tempDir: string) => Promise<T>): Promise<T> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-skill-install-test-'))
|
||||
try {
|
||||
return await fn(tempDir)
|
||||
} finally {
|
||||
process.exitCode = 0
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('formats skills list as an aligned human table', () => {
|
||||
const output = formatSkillsListForDisplay(
|
||||
[
|
||||
skill(
|
||||
'batch',
|
||||
'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.',
|
||||
'projectSettings',
|
||||
),
|
||||
skill(
|
||||
'debug',
|
||||
'Enable debug logging for this session and help diagnose issues.',
|
||||
'userSettings',
|
||||
),
|
||||
skill(
|
||||
'loop',
|
||||
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
|
||||
'projectSettings',
|
||||
),
|
||||
skill(
|
||||
'simplify',
|
||||
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
|
||||
'projectSettings',
|
||||
),
|
||||
skill(
|
||||
'update-config',
|
||||
'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors require hooks.',
|
||||
'projectSettings',
|
||||
),
|
||||
],
|
||||
80,
|
||||
)
|
||||
|
||||
assert.match(output, /^Skills: 5 enabled/)
|
||||
assert.match(output, /Name\s+Status\s+Description/)
|
||||
assert.doesNotMatch(output, /\bSource\b/)
|
||||
assert.doesNotMatch(output, /source: bundled \| trust:/)
|
||||
assert.doesNotMatch(output, /\bbundled\b/)
|
||||
assert.match(output, /batch\s+enabled\s+Research and plan/)
|
||||
assert.match(output, /update-config\s+enabled\s+Configure the Claude Code harness via/)
|
||||
})
|
||||
|
||||
test('omits source column while preserving installed rows', () => {
|
||||
const output = formatSkillsListForDisplay(
|
||||
[
|
||||
skill('docs-writer', 'Writes project documentation.', 'projectSettings'),
|
||||
skill('pr-review', 'Reviews pull requests.', 'userSettings'),
|
||||
skill('debug', 'Enable debug logging.', 'bundled'),
|
||||
],
|
||||
100,
|
||||
)
|
||||
|
||||
assert.doesNotMatch(output, /\bSource\b/)
|
||||
assert.doesNotMatch(output, /docs-writer\s+enabled\s+project\s+/)
|
||||
assert.doesNotMatch(output, /pr-review\s+enabled\s+user\s+/)
|
||||
assert.match(output, /docs-writer\s+enabled\s+Writes project documentation\./)
|
||||
assert.match(output, /pr-review\s+enabled\s+Reviews pull requests\./)
|
||||
assert.doesNotMatch(output, /\bdebug\b/)
|
||||
assert.doesNotMatch(output, /Enable debug logging/)
|
||||
})
|
||||
|
||||
test('omits bundled skills from the human table', () => {
|
||||
const output = formatSkillsListForDisplay(
|
||||
[
|
||||
skill('debug', 'Enable debug logging.', 'bundled'),
|
||||
skill('docs-writer', 'Writes project documentation.', 'projectSettings'),
|
||||
],
|
||||
100,
|
||||
)
|
||||
|
||||
assert.match(output, /^Skills: 1 enabled/)
|
||||
assert.doesNotMatch(output, /\bdebug\b/)
|
||||
assert.doesNotMatch(output, /Enable debug logging/)
|
||||
assert.match(output, /docs-writer\s+enabled\s+Writes project documentation\./)
|
||||
})
|
||||
|
||||
test('wraps description continuations under the Description column', () => {
|
||||
const output = formatSkillsListForDisplay(
|
||||
[
|
||||
skill(
|
||||
'batch',
|
||||
'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.',
|
||||
'projectSettings',
|
||||
),
|
||||
],
|
||||
45,
|
||||
)
|
||||
const lines = output.split('\n')
|
||||
const header = lines.find(line => line.includes('Description'))
|
||||
assert.ok(header)
|
||||
const descriptionColumn = header.indexOf('Description')
|
||||
const continuation = lines.find(line =>
|
||||
line.trim().startsWith('large-scale change'),
|
||||
)
|
||||
assert.ok(continuation)
|
||||
assert.equal(continuation.search(/\S/), descriptionColumn)
|
||||
})
|
||||
|
||||
test('formats empty skills list cleanly', () => {
|
||||
assert.equal(
|
||||
formatSkillsListForDisplay([], 100),
|
||||
'Skills: 0 enabled\n\nNo installed skills found.',
|
||||
)
|
||||
})
|
||||
|
||||
test('formats all-bundled skills as empty in the human table', () => {
|
||||
assert.equal(
|
||||
formatSkillsListForDisplay(
|
||||
[skill('debug', 'Enable debug logging.', 'bundled')],
|
||||
100,
|
||||
),
|
||||
'Skills: 0 enabled\n\nNo installed skills found.',
|
||||
)
|
||||
})
|
||||
|
||||
test('formats skills list json as machine-readable metadata', () => {
|
||||
const description = 'Full description should remain in JSON. Extra sentence stays.'
|
||||
const parsed = JSON.parse(
|
||||
formatSkillsListJson([
|
||||
skill('debug', description, 'projectSettings'),
|
||||
skill('batch', 'Bundled skill should stay hidden.', 'bundled'),
|
||||
]),
|
||||
) as {
|
||||
enabledCount: number
|
||||
skills: Array<{ name: string; source: string; description: string }>
|
||||
}
|
||||
|
||||
assert.equal(parsed.enabledCount, 1)
|
||||
assert.equal(parsed.skills[0]?.name, 'debug')
|
||||
assert.equal(parsed.skills[0]?.source, 'project')
|
||||
assert.equal(parsed.skills[0]?.description, description)
|
||||
assert.equal(parsed.skills.length, 1)
|
||||
assert.equal(
|
||||
parsed.skills.some(item => item.name === 'batch'),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('formats persisted registry trust metadata for installed skills', () => {
|
||||
const installed = skill(
|
||||
'official-skill',
|
||||
'Installed from the registry.',
|
||||
'projectSettings',
|
||||
'official',
|
||||
)
|
||||
const parsed = JSON.parse(formatSkillsListJson([installed])) as {
|
||||
skills: Array<{ trust: string }>
|
||||
}
|
||||
|
||||
assert.equal(trustLabel(installed), 'official')
|
||||
assert.equal(parsed.skills[0]?.trust, 'official')
|
||||
})
|
||||
|
||||
test('formats all-bundled skills as empty json', () => {
|
||||
const parsed = JSON.parse(
|
||||
formatSkillsListJson([
|
||||
skill('batch', 'Research and plan large-scale changes.', 'bundled'),
|
||||
skill('debug', 'Enable debug logging.', 'bundled'),
|
||||
]),
|
||||
) as {
|
||||
enabledCount: number
|
||||
skills: Array<{ name: string }>
|
||||
}
|
||||
|
||||
assert.equal(parsed.enabledCount, 0)
|
||||
assert.deepEqual(parsed.skills, [])
|
||||
})
|
||||
|
||||
test('explains remove scope mismatch for globally installed skills', () => {
|
||||
assert.equal(
|
||||
getSkillRemoveNotFoundMessage(
|
||||
[skill('pr-review', 'Reviews pull requests.', 'userSettings')],
|
||||
'pr-review',
|
||||
{},
|
||||
),
|
||||
'Skill "pr-review" is installed globally. Use --global to remove it.',
|
||||
)
|
||||
})
|
||||
|
||||
test('explains remove scope mismatch for project installed skills', () => {
|
||||
assert.equal(
|
||||
getSkillRemoveNotFoundMessage(
|
||||
[skill('docs-writer', 'Writes documentation.', 'projectSettings')],
|
||||
'docs-writer',
|
||||
{ global: true },
|
||||
),
|
||||
'Skill "docs-writer" is installed in this project. Remove it without --global.',
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps remove not-found generic for hidden bundled skills', () => {
|
||||
assert.equal(
|
||||
getSkillRemoveNotFoundMessage(
|
||||
[skill('batch', 'Bundled skill.', 'bundled')],
|
||||
'batch',
|
||||
{},
|
||||
),
|
||||
'Skill "batch" not found.',
|
||||
)
|
||||
})
|
||||
|
||||
test.serial('installs a local skill directory into project skills by default', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const source = writeSkillDir(join(tempDir, 'source'))
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
|
||||
await skillsInstallHandler(source, { projectDir: cwd })
|
||||
|
||||
const installed = readFileSync(
|
||||
join(cwd, '.openclaude', 'skills', 'sample-skill', 'SKILL.md'),
|
||||
'utf8',
|
||||
)
|
||||
assert.equal(installed, VALID_SKILL)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('validates existing minimal local skill metadata format', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const skillDir = join(tempDir, 'minimal-skill')
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), MINIMAL_EXISTING_FORMAT_SKILL, 'utf8')
|
||||
|
||||
assert.deepEqual(await validateSkillPath(skillDir), [])
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('allows benign security guidance about credentials', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const skillDir = join(tempDir, 'security-guidance')
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
`---\ndescription: Security guidance.\n---\n# Security Guidance\n\nDo not paste your token into chat.\n`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.deepEqual(await validateSkillPath(skillDir), [])
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('rejects oversized local text files without reading them fully', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const skillDir = join(tempDir, 'oversized-skill')
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
MINIMAL_EXISTING_FORMAT_SKILL,
|
||||
'utf8',
|
||||
)
|
||||
writeFileSync(join(skillDir, 'notes.txt'), 'a'.repeat(1024 * 1024 + 1), 'utf8')
|
||||
|
||||
assert.deepEqual(await validateSkillPath(skillDir), [
|
||||
'notes.txt is too large. Skill text files must be at most 1048576 bytes.',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('installs existing minimal local skill metadata format', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const source = join(tempDir, 'source', 'minimal-skill')
|
||||
mkdirSync(source, { recursive: true })
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(join(source, 'SKILL.md'), MINIMAL_EXISTING_FORMAT_SKILL, 'utf8')
|
||||
|
||||
await skillsInstallHandler(source, { projectDir: cwd })
|
||||
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(cwd, '.openclaude', 'skills', 'minimal-skill', 'SKILL.md'),
|
||||
'utf8',
|
||||
),
|
||||
MINIMAL_EXISTING_FORMAT_SKILL,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('preserves namespaced names when installing local skill directories', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const source = join(tempDir, 'source', 'git', 'commit')
|
||||
mkdirSync(source, { recursive: true })
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(join(source, 'SKILL.md'), NAMESPACED_SKILL, 'utf8')
|
||||
|
||||
await skillsInstallHandler(source, { projectDir: cwd })
|
||||
|
||||
const nestedPath = join(
|
||||
cwd,
|
||||
'.openclaude',
|
||||
'skills',
|
||||
'git',
|
||||
'commit',
|
||||
'SKILL.md',
|
||||
)
|
||||
const flatPath = join(cwd, '.openclaude', 'skills', 'commit', 'SKILL.md')
|
||||
assert.equal(existsSync(flatPath), false)
|
||||
assert.equal(readFileSync(nestedPath, 'utf8'), NAMESPACED_SKILL)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('refuses to overwrite installed skills without --force', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const source = writeSkillDir(join(tempDir, 'source'))
|
||||
mkdirSync(join(cwd, '.openclaude', 'skills', 'sample-skill'), {
|
||||
recursive: true,
|
||||
})
|
||||
writeFileSync(
|
||||
join(cwd, '.openclaude', 'skills', 'sample-skill', 'SKILL.md'),
|
||||
'existing skill content',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
await skillsInstallHandler(source, { projectDir: cwd })
|
||||
|
||||
assert.equal(process.exitCode, 1)
|
||||
const installed = readFileSync(
|
||||
join(cwd, '.openclaude', 'skills', 'sample-skill', 'SKILL.md'),
|
||||
'utf8',
|
||||
)
|
||||
assert.equal(installed, 'existing skill content')
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('installs a registry skill by id from a local registry file', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const sourceDir = writeSkillDir(join(tempDir, 'registry-source'))
|
||||
const registryPath = join(tempDir, 'registry.json')
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
buildRegistryEntry(sourceDir, {
|
||||
repo: 'https://github.com/Gitlawb/openclaude-skills',
|
||||
path: 'skills/sample-skill/SKILL.md',
|
||||
homepage: 'https://github.com/Gitlawb/openclaude-skills/tree/main/skills/sample-skill',
|
||||
sha256: sha256OfSkillSource(VALID_SKILL),
|
||||
min_openclaude_version: '0.1.0',
|
||||
tools_required: ['Read', 'Bash'],
|
||||
}),
|
||||
]),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
await skillsInstallHandler('sample-skill', {
|
||||
projectDir: cwd,
|
||||
registry: registryPath,
|
||||
})
|
||||
|
||||
const installedMetadata = JSON.parse(
|
||||
readFileSync(
|
||||
join(cwd, '.openclaude', 'skills', 'sample-skill', 'skill.json'),
|
||||
'utf8',
|
||||
),
|
||||
) as {
|
||||
trust: string
|
||||
sha256: string
|
||||
min_openclaude_version: string
|
||||
tools_required: string[]
|
||||
}
|
||||
assert.equal(installedMetadata.trust, 'official')
|
||||
assert.equal(installedMetadata.sha256, sha256OfSkillSource(VALID_SKILL))
|
||||
assert.equal(installedMetadata.min_openclaude_version, '0.1.0')
|
||||
assert.deepEqual(installedMetadata.tools_required, ['Read', 'Bash'])
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('resolves relative registry skill sources from the registry file', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const registryDir = join(tempDir, 'registry')
|
||||
const sourceDir = writeSkillDir(join(registryDir, 'skills'))
|
||||
const registryPath = join(registryDir, 'registry.json')
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
buildRegistryEntry(sourceDir, {
|
||||
source: 'skills/sample-skill/SKILL.md',
|
||||
sha256: sha256OfSkillSource(VALID_SKILL),
|
||||
}),
|
||||
]),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
await skillsInstallHandler('sample-skill', {
|
||||
projectDir: cwd,
|
||||
registry: registryPath,
|
||||
})
|
||||
|
||||
assert.equal(process.exitCode, 0)
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(cwd, '.openclaude', 'skills', 'sample-skill', 'SKILL.md'),
|
||||
'utf8',
|
||||
),
|
||||
VALID_SKILL,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('rejects registry skills without a sha256 pin', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const sourceDir = writeSkillDir(join(tempDir, 'registry-source'))
|
||||
const registryPath = join(tempDir, 'registry.json')
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
buildRegistryEntry(sourceDir),
|
||||
]),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const stagedBefore = stagedInstallTempDirs()
|
||||
await skillsInstallHandler('sample-skill', {
|
||||
projectDir: cwd,
|
||||
registry: registryPath,
|
||||
})
|
||||
|
||||
assert.equal(process.exitCode, 1)
|
||||
assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false)
|
||||
assertNoNewStagedInstallDirs(stagedBefore)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('rejects registry skills that require a newer OpenClaude version', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const sourceDir = writeSkillDir(join(tempDir, 'registry-source'))
|
||||
const registryPath = join(tempDir, 'registry.json')
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
buildRegistryEntry(sourceDir, {
|
||||
sha256: sha256OfSkillSource(VALID_SKILL),
|
||||
min_openclaude_version: '999.0.0',
|
||||
}),
|
||||
]),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const stagedBefore = stagedInstallTempDirs()
|
||||
await skillsInstallHandler('sample-skill', {
|
||||
projectDir: cwd,
|
||||
registry: registryPath,
|
||||
})
|
||||
|
||||
assert.equal(process.exitCode, 1)
|
||||
assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false)
|
||||
assertNoNewStagedInstallDirs(stagedBefore)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('rejects path-like skill names before installing raw markdown', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
const sourceFile = join(sourceDir, 'SKILL.md')
|
||||
mkdirSync(sourceDir, { recursive: true })
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(sourceFile, PATH_TRAVERSAL_SKILL, 'utf8')
|
||||
|
||||
await skillsInstallHandler(sourceFile, { projectDir: cwd })
|
||||
|
||||
assert.equal(process.exitCode, 1)
|
||||
assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('rejects registry names that would escape the install root', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const sourceDir = writeSkillDir(join(tempDir, 'registry-source'))
|
||||
const registryPath = join(tempDir, 'registry.json')
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
buildRegistryEntry(sourceDir, {
|
||||
name: '../escape',
|
||||
sha256: sha256OfSkillSource(VALID_SKILL),
|
||||
}),
|
||||
]),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
await skillsInstallHandler('sample-skill', {
|
||||
projectDir: cwd,
|
||||
registry: registryPath,
|
||||
})
|
||||
|
||||
assert.equal(process.exitCode, 1)
|
||||
assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('rejects direct HTTP URL installs without a sha256 pin', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
|
||||
const stagedBefore = stagedInstallTempDirs()
|
||||
await skillsInstallHandler('https://example.com/sample-skill.md', {
|
||||
projectDir: cwd,
|
||||
})
|
||||
|
||||
assert.equal(process.exitCode, 1)
|
||||
assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false)
|
||||
assertNoNewStagedInstallDirs(stagedBefore)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('cleans staged temp roots when markdown staging fails', async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const source = join(tempDir, 'source', 'SKILL.md')
|
||||
const oversizedName = 'a'.repeat(300)
|
||||
mkdirSync(join(tempDir, 'source'), { recursive: true })
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeFileSync(
|
||||
source,
|
||||
`---\nname: ${oversizedName}\ndescription: oversized name\n---\n# Oversized\n`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const stagedBefore = stagedInstallTempDirs()
|
||||
await skillsInstallHandler(source, { projectDir: cwd })
|
||||
|
||||
assert.equal(process.exitCode, 1)
|
||||
assertNoNewStagedInstallDirs(stagedBefore)
|
||||
})
|
||||
})
|
||||
|
||||
test.serial('removes only the targeted project skill directory', async () => {
|
||||
await acquireSharedMutationLock('skillsRemoveHandler')
|
||||
const originalFs = getFsImplementation()
|
||||
try {
|
||||
setFsImplementation({
|
||||
...originalFs,
|
||||
existsSync,
|
||||
stat: async path => statSync(path),
|
||||
readdir: async path => readdirSync(path, { withFileTypes: true }),
|
||||
readFile: async (path, options) => readFileSync(path, options),
|
||||
rm: async (path, options) => {
|
||||
rmSync(path, options)
|
||||
},
|
||||
})
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const skillsRoot = join(cwd, '.openclaude', 'skills')
|
||||
const targetName = 'remove-target-skill'
|
||||
const target = join(skillsRoot, targetName)
|
||||
const sibling = join(skillsRoot, 'sibling-skill')
|
||||
const originalSettingsState = enableUserAndProjectSettingSources()
|
||||
mkdirSync(target, { recursive: true })
|
||||
mkdirSync(sibling, { recursive: true })
|
||||
writeFileSync(
|
||||
join(target, 'SKILL.md'),
|
||||
VALID_SKILL.replace('sample-skill', targetName),
|
||||
'utf8',
|
||||
)
|
||||
writeFileSync(
|
||||
join(sibling, 'SKILL.md'),
|
||||
VALID_SKILL.replace('sample-skill', 'sibling-skill'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
clearCommandsCache()
|
||||
try {
|
||||
process.exitCode = 0
|
||||
await skillsRemoveHandler(targetName, { projectDir: cwd })
|
||||
assert.equal(process.exitCode, 0)
|
||||
} finally {
|
||||
restoreSettingState(originalSettingsState)
|
||||
clearCommandsCache()
|
||||
}
|
||||
|
||||
assert.equal(existsSync(target), false)
|
||||
assert.equal(existsSync(join(sibling, 'SKILL.md')), true)
|
||||
})
|
||||
} finally {
|
||||
setFsImplementation(originalFs)
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test.serial('removes legacy project skills from .claude directories', async () => {
|
||||
await acquireSharedMutationLock('skillsRemoveHandler')
|
||||
const originalFs = getFsImplementation()
|
||||
try {
|
||||
setFsImplementation({
|
||||
...originalFs,
|
||||
existsSync,
|
||||
stat: async path => statSync(path),
|
||||
readdir: async path => readdirSync(path, { withFileTypes: true }),
|
||||
readFile: async (path, options) => readFileSync(path, options),
|
||||
rm: async (path, options) => {
|
||||
rmSync(path, options)
|
||||
},
|
||||
})
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const targetName = 'legacy-remove-skill'
|
||||
const target = join(cwd, '.claude', 'skills', targetName)
|
||||
const originalSettingsState = enableUserAndProjectSettingSources()
|
||||
mkdirSync(target, { recursive: true })
|
||||
writeFileSync(
|
||||
join(target, 'SKILL.md'),
|
||||
VALID_SKILL.replace('sample-skill', targetName),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
clearCommandsCache()
|
||||
try {
|
||||
process.exitCode = 0
|
||||
await skillsRemoveHandler(targetName, { projectDir: cwd })
|
||||
assert.equal(process.exitCode, 0)
|
||||
} finally {
|
||||
restoreSettingState(originalSettingsState)
|
||||
clearCommandsCache()
|
||||
}
|
||||
|
||||
assert.equal(existsSync(target), false)
|
||||
})
|
||||
} finally {
|
||||
setFsImplementation(originalFs)
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test.serial('routes install and validation file access through the fs abstraction', async () => {
|
||||
await acquireSharedMutationLock('skillsInstallHandler')
|
||||
const originalFs = getFsImplementation()
|
||||
let statCalls = 0
|
||||
let readFileCalls = 0
|
||||
let writeFileCalls = 0
|
||||
try {
|
||||
setFsImplementation({
|
||||
...originalFs,
|
||||
stat: async path => {
|
||||
statCalls += 1
|
||||
return originalFs.stat(path)
|
||||
},
|
||||
readFile: async (path, options) => {
|
||||
readFileCalls += 1
|
||||
return originalFs.readFile(path, options)
|
||||
},
|
||||
writeFile: async (path, data, options) => {
|
||||
writeFileCalls += 1
|
||||
return originalFs.writeFile(path, data, options)
|
||||
},
|
||||
})
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const source = writeSkillDir(join(tempDir, 'source'))
|
||||
const sourceFile = join(source, 'SKILL.md')
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
|
||||
await skillsInstallHandler(sourceFile, { projectDir: cwd })
|
||||
assert.equal(process.exitCode, 0)
|
||||
assert.deepEqual(await validateSkillPath(source), [])
|
||||
})
|
||||
|
||||
assert.ok(statCalls > 0)
|
||||
assert.ok(readFileCalls > 0)
|
||||
assert.ok(writeFileCalls > 0)
|
||||
} finally {
|
||||
setFsImplementation(originalFs)
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test.serial('does not remove skills from --add-dir directories', async () => {
|
||||
await acquireSharedMutationLock('skillsRemoveHandler')
|
||||
const originalFs = getFsImplementation()
|
||||
try {
|
||||
setFsImplementation({
|
||||
...originalFs,
|
||||
existsSync,
|
||||
stat: async path => statSync(path),
|
||||
readdir: async path => readdirSync(path, { withFileTypes: true }),
|
||||
readFile: async (path, options) => readFileSync(path, options),
|
||||
rm: async (path, options) => {
|
||||
rmSync(path, options)
|
||||
},
|
||||
})
|
||||
await withTempDir(async tempDir => {
|
||||
const cwd = join(tempDir, 'project')
|
||||
const addDir = join(tempDir, 'additional-project')
|
||||
const targetName = 'add-dir-skill'
|
||||
const target = join(addDir, '.openclaude', 'skills', targetName)
|
||||
const originalSettingsState = enableUserAndProjectSettingSources()
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
mkdirSync(target, { recursive: true })
|
||||
writeFileSync(
|
||||
join(target, 'SKILL.md'),
|
||||
VALID_SKILL.replace('sample-skill', targetName),
|
||||
'utf8',
|
||||
)
|
||||
setAdditionalDirectoriesForClaudeMd([addDir])
|
||||
|
||||
clearCommandsCache()
|
||||
try {
|
||||
process.exitCode = 0
|
||||
await skillsRemoveHandler(targetName, { projectDir: cwd })
|
||||
assert.equal(process.exitCode, 1)
|
||||
} finally {
|
||||
setAdditionalDirectoriesForClaudeMd([])
|
||||
restoreSettingState(originalSettingsState)
|
||||
clearCommandsCache()
|
||||
}
|
||||
|
||||
assert.equal(existsSync(join(target, 'SKILL.md')), true)
|
||||
})
|
||||
} finally {
|
||||
setFsImplementation(originalFs)
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Skills subcommand handler — lists and inspects configured skills.
|
||||
*/
|
||||
|
||||
import { isAbsolute, join, relative, resolve } from 'path'
|
||||
import {
|
||||
findCommand,
|
||||
getCommandName,
|
||||
getCommands,
|
||||
type Command,
|
||||
} from '../../commands.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { getDisplayPath } from '../../utils/file.js'
|
||||
import { getFsImplementation } from '../../utils/fsOperations.js'
|
||||
import { PROJECT_CONFIG_DIR_NAMES } from '../../utils/markdownConfigLoader.js'
|
||||
import {
|
||||
formatSkillsListForDisplay,
|
||||
formatSkillsListJson,
|
||||
isPublicSkill,
|
||||
locationLabel,
|
||||
sourceLabel,
|
||||
trustLabel,
|
||||
type SkillListCommand,
|
||||
} from './skillsListFormat.js'
|
||||
import {
|
||||
findLocalSkillForRemoval,
|
||||
getSkillRemoveNotFoundMessage,
|
||||
} from './skillsRemoveMessage.js'
|
||||
import { validateSkillPath } from './skillsValidation.js'
|
||||
|
||||
export { skillsInstallHandler } from './skillsInstall.js'
|
||||
|
||||
type SkillCommand = SkillListCommand
|
||||
type ListOptions = { json?: boolean }
|
||||
type RemoveOptions = { global?: boolean; projectDir?: string }
|
||||
const VALID_REMOVE_SKILL_NAME = /^[a-z0-9][a-z0-9-]*(?::[a-z0-9][a-z0-9-]*)*$/
|
||||
|
||||
function isSkillCommand(cmd: Command): cmd is SkillCommand {
|
||||
return (
|
||||
cmd.type === 'prompt' &&
|
||||
(cmd.loadedFrom === 'skills' ||
|
||||
cmd.loadedFrom === 'commands_DEPRECATED' ||
|
||||
cmd.loadedFrom === 'plugin' ||
|
||||
cmd.loadedFrom === 'bundled' ||
|
||||
cmd.loadedFrom === 'mcp')
|
||||
)
|
||||
}
|
||||
|
||||
function loadSkills(cwd = getCwd()): Promise<SkillCommand[]> {
|
||||
return getCommands(cwd).then(commands => commands.filter(isSkillCommand))
|
||||
}
|
||||
|
||||
function resolveContainedPath(root: string, child: string): string {
|
||||
const resolvedRoot = resolve(root)
|
||||
const resolvedChild = resolve(resolvedRoot, child)
|
||||
const relativePath = relative(resolvedRoot, resolvedChild)
|
||||
|
||||
if (
|
||||
relativePath === '' ||
|
||||
relativePath.startsWith('..') ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid skill remove path "${child}". Skill paths must stay inside ${getDisplayPath(resolvedRoot)}.`,
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedChild
|
||||
}
|
||||
|
||||
function isContainedInRoot(root: string, child: string): boolean {
|
||||
const resolvedRoot = resolve(root)
|
||||
const resolvedChild = resolve(child)
|
||||
const relativePath = relative(resolvedRoot, resolvedChild)
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(!relativePath.startsWith('..') && !isAbsolute(relativePath))
|
||||
)
|
||||
}
|
||||
|
||||
function localSkillRoots(options: RemoveOptions): string[] {
|
||||
return options.global
|
||||
? [join(getClaudeConfigHomeDir(), 'skills')]
|
||||
: PROJECT_CONFIG_DIR_NAMES.map(configDirName =>
|
||||
join(options.projectDir ?? getCwd(), configDirName, 'skills'),
|
||||
)
|
||||
}
|
||||
|
||||
function localSkillRootsForRemoval(
|
||||
name: string,
|
||||
options: RemoveOptions,
|
||||
): string[] {
|
||||
const skillName = name.trim()
|
||||
if (!VALID_REMOVE_SKILL_NAME.test(skillName)) return []
|
||||
return localSkillRoots(options).map(root =>
|
||||
resolveContainedPath(root, join(...skillName.split(':'))),
|
||||
)
|
||||
}
|
||||
|
||||
function isSkillInRemovalRoot(
|
||||
skill: SkillCommand,
|
||||
options: RemoveOptions,
|
||||
): boolean {
|
||||
return (
|
||||
skill.loadedFrom === 'skills' &&
|
||||
typeof skill.skillRoot === 'string' &&
|
||||
localSkillRoots(options).some(root => isContainedInRoot(root, skill.skillRoot!))
|
||||
)
|
||||
}
|
||||
|
||||
async function existingLocalSkillRootForRemoval(
|
||||
name: string,
|
||||
options: RemoveOptions,
|
||||
): Promise<string | undefined> {
|
||||
for (const skillRoot of localSkillRootsForRemoval(name, options)) {
|
||||
try {
|
||||
await getFsImplementation().stat(join(skillRoot, 'SKILL.md'))
|
||||
return skillRoot
|
||||
} catch {
|
||||
// Keep checking other supported roots.
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function skillsListHandler(options: ListOptions = {}): Promise<void> {
|
||||
const skills = await loadSkills()
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatSkillsListJson(skills))
|
||||
return
|
||||
}
|
||||
|
||||
console.log(formatSkillsListForDisplay(skills))
|
||||
}
|
||||
|
||||
export async function skillsShowHandler(name: string): Promise<void> {
|
||||
const skills = await loadSkills()
|
||||
const skill = findCommand(name, skills.filter(isPublicSkill))
|
||||
if (!skill || !isSkillCommand(skill)) {
|
||||
console.error(`Skill "${name}" not found.`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Name: ${getCommandName(skill)}`,
|
||||
`Source: ${sourceLabel(skill)}`,
|
||||
`Trust: ${trustLabel(skill)}`,
|
||||
`Version: ${skill.version ?? '-'}`,
|
||||
`Location: ${locationLabel(skill)}`,
|
||||
`Description: ${skill.description}`,
|
||||
]
|
||||
|
||||
if (skill.whenToUse) {
|
||||
lines.push(`When to use: ${skill.whenToUse}`)
|
||||
}
|
||||
|
||||
if (skill.allowedTools && skill.allowedTools.length > 0) {
|
||||
lines.push(`Allowed tools: ${skill.allowedTools.join(', ')}`)
|
||||
}
|
||||
|
||||
if (skill.skillFilePath) {
|
||||
try {
|
||||
const content = await getFsImplementation().readFile(skill.skillFilePath, {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
lines.push('', '--- SKILL.md ---', content.trimEnd())
|
||||
} catch {
|
||||
lines.push('', 'SKILL.md could not be read.')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(lines.join('\n'))
|
||||
}
|
||||
|
||||
export async function skillsValidateHandler(path: string): Promise<void> {
|
||||
const errors = await validateSkillPath(path)
|
||||
if (errors.length > 0) {
|
||||
console.error(`Skill validation failed for ${getDisplayPath(resolve(path))}:`)
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Skill validation passed for ${getDisplayPath(resolve(path))}.`)
|
||||
}
|
||||
|
||||
export async function skillsRemoveHandler(
|
||||
name: string,
|
||||
options: RemoveOptions,
|
||||
): Promise<void> {
|
||||
const directSkillRoot = await existingLocalSkillRootForRemoval(name, options)
|
||||
if (directSkillRoot) {
|
||||
await getFsImplementation().rm(directSkillRoot, { recursive: true, force: false })
|
||||
console.log(
|
||||
`Removed skill "${name}" from ${options.global ? 'user' : 'project'}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const skills = (await loadSkills(options.projectDir))
|
||||
.filter(isPublicSkill)
|
||||
.filter(skill =>
|
||||
isSkillInRemovalRoot(skill, options) ||
|
||||
isSkillInRemovalRoot(skill, { ...options, global: !options.global }),
|
||||
)
|
||||
const targetSource = options.global ? 'userSettings' : 'projectSettings'
|
||||
const skill = findLocalSkillForRemoval(skills, name, targetSource)
|
||||
|
||||
if (!skill) {
|
||||
console.error(getSkillRemoveNotFoundMessage(skills, name, options))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!skill.skillRoot) {
|
||||
console.error(`Skill "${name}" does not have a removable local directory.`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
await getFsImplementation().rm(skill.skillRoot, { recursive: true, force: false })
|
||||
console.log(`Removed skill "${getCommandName(skill)}" from ${sourceLabel(skill)}.`)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js'
|
||||
|
||||
type SkillsCliOptions = {
|
||||
additionalDirectories: string[]
|
||||
force?: boolean
|
||||
global?: boolean
|
||||
help?: boolean
|
||||
json?: boolean
|
||||
registry?: string
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
const TRAILING_GLOBAL_BOOLEAN_FLAGS = new Set([
|
||||
'--bare',
|
||||
'--debug',
|
||||
'--debug-to-stderr',
|
||||
'--dangerously-skip-permissions',
|
||||
'--allow-dangerously-skip-permissions',
|
||||
'--disable-slash-commands',
|
||||
'--enable-auth-status',
|
||||
'--fork-session',
|
||||
'--ide',
|
||||
'--include-hook-events',
|
||||
'--include-partial-messages',
|
||||
'--init',
|
||||
'--init-only',
|
||||
'--maintenance',
|
||||
'--mcp-debug',
|
||||
'--no-chrome',
|
||||
'--no-session-persistence',
|
||||
'--replay-user-messages',
|
||||
'--strict-mcp-config',
|
||||
'--verbose',
|
||||
])
|
||||
|
||||
const TRAILING_GLOBAL_VALUE_FLAGS = new Set([
|
||||
'--agent',
|
||||
'--append-system-prompt',
|
||||
'--append-system-prompt-file',
|
||||
'--debug-file',
|
||||
'--effort',
|
||||
'--fallback-model',
|
||||
'--heartbeat',
|
||||
'--input-format',
|
||||
'--json-schema',
|
||||
'--max-budget-usd',
|
||||
'--max-thinking-tokens',
|
||||
'--max-turns',
|
||||
'--model',
|
||||
'--output-format',
|
||||
'--permission-mode',
|
||||
'--permission-prompt-tool',
|
||||
'--provider',
|
||||
'--resume-session-at',
|
||||
'--session-id',
|
||||
'--settings',
|
||||
'--setting-sources',
|
||||
'--system-prompt',
|
||||
'--system-prompt-file',
|
||||
'--thinking',
|
||||
'--workload',
|
||||
'-n',
|
||||
'--name',
|
||||
])
|
||||
|
||||
const TRAILING_GLOBAL_MULTI_VALUE_FLAGS = new Set([
|
||||
'--add-dir',
|
||||
'--allowedTools',
|
||||
'--allowed-tools',
|
||||
'--betas',
|
||||
'--disallowedTools',
|
||||
'--disallowed-tools',
|
||||
'--file',
|
||||
'--mcp-config',
|
||||
'--plugin-dir',
|
||||
'--provider-env-file',
|
||||
'--tools',
|
||||
])
|
||||
|
||||
const SKILLS_HELP = `Usage: openclaude skills <command> [options]
|
||||
|
||||
Commands:
|
||||
list [--json] List installed skills
|
||||
show <name> Show details for an installed skill
|
||||
validate <path> Validate a local skill directory
|
||||
install <idOrUrlOrPath> [options] Install a skill (--sha256 required for HTTP(S) URLs)
|
||||
remove <name> [--global] Remove an installed skill`
|
||||
|
||||
function parseSkillsCliArgs(args: string[]): {
|
||||
options: SkillsCliOptions
|
||||
positionals: string[]
|
||||
error?: string
|
||||
} {
|
||||
const options: SkillsCliOptions = { additionalDirectories: [] }
|
||||
const positionals: string[] = []
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === '--json') {
|
||||
options.json = true
|
||||
} else if (arg === '--global') {
|
||||
options.global = true
|
||||
} else if (arg === '--force') {
|
||||
options.force = true
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
options.help = true
|
||||
} else if (arg === '--registry') {
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
return { options, positionals, error: '--registry requires a value.' }
|
||||
}
|
||||
options.registry = value
|
||||
index += 1
|
||||
} else if (arg === '--sha256') {
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
return { options, positionals, error: '--sha256 requires a value.' }
|
||||
}
|
||||
options.sha256 = value
|
||||
index += 1
|
||||
} else if (arg?.startsWith('--registry=')) {
|
||||
const value = arg.slice('--registry='.length)
|
||||
if (!value) {
|
||||
return { options, positionals, error: '--registry requires a value.' }
|
||||
}
|
||||
options.registry = value
|
||||
} else if (arg?.startsWith('--sha256=')) {
|
||||
const value = arg.slice('--sha256='.length)
|
||||
if (!value) {
|
||||
return { options, positionals, error: '--sha256 requires a value.' }
|
||||
}
|
||||
options.sha256 = value
|
||||
} else if (TRAILING_GLOBAL_BOOLEAN_FLAGS.has(arg)) {
|
||||
continue
|
||||
} else if (TRAILING_GLOBAL_VALUE_FLAGS.has(arg)) {
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
return { options, positionals, error: `${arg} requires a value.` }
|
||||
}
|
||||
index += 1
|
||||
} else if (
|
||||
Array.from(TRAILING_GLOBAL_VALUE_FLAGS).some(flag =>
|
||||
arg?.startsWith(`${flag}=`),
|
||||
)
|
||||
) {
|
||||
continue
|
||||
} else if (TRAILING_GLOBAL_MULTI_VALUE_FLAGS.has(arg)) {
|
||||
let consumed = false
|
||||
while (args[index + 1] && !args[index + 1]!.startsWith('-')) {
|
||||
index += 1
|
||||
consumed = true
|
||||
if (arg === '--add-dir') {
|
||||
options.additionalDirectories.push(args[index]!)
|
||||
}
|
||||
}
|
||||
if (!consumed) {
|
||||
return { options, positionals, error: `${arg} requires a value.` }
|
||||
}
|
||||
} else {
|
||||
const multiValueEqualsFlag = Array.from(TRAILING_GLOBAL_MULTI_VALUE_FLAGS)
|
||||
.find(flag => arg?.startsWith(`${flag}=`))
|
||||
if (multiValueEqualsFlag) {
|
||||
const value = arg.slice(`${multiValueEqualsFlag}=`.length)
|
||||
if (!value) {
|
||||
return {
|
||||
options,
|
||||
positionals,
|
||||
error: `${multiValueEqualsFlag} requires a value.`,
|
||||
}
|
||||
}
|
||||
if (multiValueEqualsFlag === '--add-dir') {
|
||||
options.additionalDirectories.push(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!arg?.startsWith('--')) {
|
||||
positionals.push(arg)
|
||||
continue
|
||||
}
|
||||
return { options, positionals, error: `Unknown skills option: ${arg}` }
|
||||
}
|
||||
}
|
||||
|
||||
return { options, positionals }
|
||||
}
|
||||
|
||||
export async function runSkillsCliAction(
|
||||
action: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSkillsCli(args: string[]): Promise<void> {
|
||||
const subcommand = args[1] ?? 'list'
|
||||
const { options, positionals, error } = parseSkillsCliArgs(args.slice(2))
|
||||
if (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
if (subcommand === '--help' || subcommand === '-h' || options.help) {
|
||||
console.log(SKILLS_HELP)
|
||||
process.exit(0)
|
||||
}
|
||||
if (options.additionalDirectories.length > 0) {
|
||||
setAdditionalDirectoriesForClaudeMd(options.additionalDirectories)
|
||||
}
|
||||
|
||||
const {
|
||||
skillsInstallHandler,
|
||||
skillsListHandler,
|
||||
skillsRemoveHandler,
|
||||
skillsShowHandler,
|
||||
skillsValidateHandler,
|
||||
} = await import('./skills.js')
|
||||
|
||||
await runSkillsCliAction(async () => {
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
await skillsListHandler({ json: options.json })
|
||||
break
|
||||
case 'show': {
|
||||
const name = positionals[0]
|
||||
if (!name) {
|
||||
console.error('Skill name is required.')
|
||||
process.exit(1)
|
||||
}
|
||||
await skillsShowHandler(name)
|
||||
break
|
||||
}
|
||||
case 'validate': {
|
||||
const path = positionals[0]
|
||||
if (!path) {
|
||||
console.error('Skill path is required.')
|
||||
process.exit(1)
|
||||
}
|
||||
await skillsValidateHandler(path)
|
||||
break
|
||||
}
|
||||
case 'install': {
|
||||
const idOrUrlOrPath = positionals[0]
|
||||
if (!idOrUrlOrPath) {
|
||||
console.error('Skill ID, URL, or path is required.')
|
||||
process.exit(1)
|
||||
}
|
||||
await skillsInstallHandler(idOrUrlOrPath, options)
|
||||
break
|
||||
}
|
||||
case 'remove': {
|
||||
const name = positionals[0]
|
||||
if (!name) {
|
||||
console.error('Skill name is required.')
|
||||
process.exit(1)
|
||||
}
|
||||
await skillsRemoveHandler(name, { global: options.global })
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown skills command: ${subcommand}`)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
process.exit(process.exitCode ?? 0)
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { tmpdir } from 'os'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { coerce, lt } from 'semver'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { getDisplayPath } from '../../utils/file.js'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { getFsImplementation } from '../../utils/fsOperations.js'
|
||||
import { publicBuildVersion } from '../../utils/version.js'
|
||||
import { validateSkillPath } from './skillsValidation.js'
|
||||
|
||||
export type InstallOptions = {
|
||||
global?: boolean
|
||||
force?: boolean
|
||||
registry?: string
|
||||
projectDir?: string
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
type SkillRegistryEntry = {
|
||||
id?: unknown
|
||||
name?: unknown
|
||||
title?: unknown
|
||||
description?: unknown
|
||||
trust?: unknown
|
||||
version?: unknown
|
||||
license?: unknown
|
||||
source?: unknown
|
||||
repo?: unknown
|
||||
path?: unknown
|
||||
homepage?: unknown
|
||||
sha256?: unknown
|
||||
min_openclaude_version?: unknown
|
||||
tools_required?: unknown
|
||||
category?: unknown
|
||||
tags?: unknown
|
||||
author?: unknown
|
||||
}
|
||||
|
||||
type RegistryEntriesResult = {
|
||||
entries: SkillRegistryEntry[]
|
||||
registrySource: string
|
||||
}
|
||||
|
||||
const DEFAULT_SKILLS_REGISTRY_URL =
|
||||
'https://raw.githubusercontent.com/Gitlawb/openclaude-skills/main/registry.json'
|
||||
const VALID_INSTALL_SKILL_NAME = /^[a-z0-9][a-z0-9-]*(?::[a-z0-9][a-z0-9-]*)*$/
|
||||
const MAX_INSTALL_SKILL_NAME_LENGTH = 120
|
||||
const REMOTE_SOURCE_TIMEOUT_MS = 30_000
|
||||
const MAX_REMOTE_SOURCE_BYTES = 1024 * 1024
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'file:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await getFsImplementation().stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function installRoot(options: InstallOptions): string {
|
||||
return options.global
|
||||
? join(getClaudeConfigHomeDir(), 'skills')
|
||||
: join(options.projectDir ?? getCwd(), '.openclaude', 'skills')
|
||||
}
|
||||
|
||||
function normalizeRegistryEntries(parsed: unknown): SkillRegistryEntry[] {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter(isPlainObject)
|
||||
}
|
||||
if (isPlainObject(parsed) && Array.isArray(parsed.skills)) {
|
||||
return parsed.skills.filter(isPlainObject)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async function readSourceText(source: string): Promise<string> {
|
||||
if (isUrl(source)) {
|
||||
const url = new URL(source)
|
||||
if (url.protocol === 'file:') {
|
||||
return getFsImplementation().readFile(fileURLToPath(url), {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
const { signal, cleanup } = createCombinedAbortSignal(undefined, {
|
||||
timeoutMs: REMOTE_SOURCE_TIMEOUT_MS,
|
||||
})
|
||||
try {
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${source}: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length')
|
||||
if (
|
||||
contentLength &&
|
||||
Number.parseInt(contentLength, 10) > MAX_REMOTE_SOURCE_BYTES
|
||||
) {
|
||||
throw new Error(`Remote source ${source} is too large to install.`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let bytesRead = 0
|
||||
let text = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytesRead += value.byteLength
|
||||
if (bytesRead > MAX_REMOTE_SOURCE_BYTES) {
|
||||
await reader.cancel()
|
||||
throw new Error(`Remote source ${source} is too large to install.`)
|
||||
}
|
||||
text += decoder.decode(value, { stream: true })
|
||||
}
|
||||
|
||||
return text + decoder.decode()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
return getFsImplementation().readFile(resolve(source), { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
async function readRegistryEntries(source: string): Promise<RegistryEntriesResult> {
|
||||
let registrySource = source
|
||||
if (!isUrl(source)) {
|
||||
const resolved = resolve(source)
|
||||
try {
|
||||
const sourceStats = await getFsImplementation().stat(resolved)
|
||||
registrySource = sourceStats.isDirectory()
|
||||
? join(resolved, 'registry.json')
|
||||
: resolved
|
||||
} catch {
|
||||
registrySource = resolved
|
||||
}
|
||||
}
|
||||
|
||||
const raw = await readSourceText(registrySource)
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return {
|
||||
entries: normalizeRegistryEntries(parsed),
|
||||
registrySource,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRegistryEntrySource(
|
||||
entrySource: string,
|
||||
registrySource: string,
|
||||
): string {
|
||||
if (isUrl(entrySource) || isAbsolute(entrySource)) {
|
||||
return entrySource
|
||||
}
|
||||
|
||||
if (isUrl(registrySource)) {
|
||||
return new URL(entrySource, registrySource).toString()
|
||||
}
|
||||
|
||||
return resolve(dirname(registrySource), entrySource)
|
||||
}
|
||||
|
||||
async function resolveRegistryEntry(
|
||||
idOrName: string,
|
||||
options: InstallOptions,
|
||||
): Promise<{ entry: SkillRegistryEntry; registrySource: string } | null> {
|
||||
const registrySource =
|
||||
options.registry ??
|
||||
process.env.OPENCLAUDE_SKILLS_REGISTRY_URL ??
|
||||
DEFAULT_SKILLS_REGISTRY_URL
|
||||
const registry = await readRegistryEntries(registrySource)
|
||||
const entry = registry.entries.find(
|
||||
candidate =>
|
||||
candidate.id === idOrName ||
|
||||
candidate.name === idOrName ||
|
||||
(typeof candidate.id === 'string' &&
|
||||
candidate.id.endsWith(`/${idOrName}`)),
|
||||
)
|
||||
|
||||
return entry ? { entry, registrySource: registry.registrySource } : null
|
||||
}
|
||||
|
||||
function registryMetadata(entry: SkillRegistryEntry): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {}
|
||||
for (const key of [
|
||||
'id',
|
||||
'name',
|
||||
'title',
|
||||
'description',
|
||||
'category',
|
||||
'tags',
|
||||
'trust',
|
||||
'version',
|
||||
'license',
|
||||
'author',
|
||||
'repo',
|
||||
'path',
|
||||
'homepage',
|
||||
'sha256',
|
||||
'min_openclaude_version',
|
||||
'tools_required',
|
||||
] as const) {
|
||||
const value = entry[key]
|
||||
if (value !== undefined) metadata[key] = value
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
function sha256OfSkillSource(text: string): string {
|
||||
const normalized = text.replace(/\r\n/g, '\n')
|
||||
return createHash('sha256').update(normalized, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
|
||||
}
|
||||
|
||||
function requireRegistrySha256(entry: SkillRegistryEntry, spec: string): string {
|
||||
if (typeof entry.sha256 !== 'string' || entry.sha256.trim() === '') {
|
||||
throw new Error(
|
||||
`Registry entry "${spec}" is missing sha256. Refusing to install an unpinned skill.`,
|
||||
)
|
||||
}
|
||||
return normalizeExpectedSha256(entry.sha256) ?? ''
|
||||
}
|
||||
|
||||
function normalizeExpectedSha256(value: string | undefined): string | null {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (!normalized) return null
|
||||
if (!/^[a-f0-9]{64}$/.test(normalized)) {
|
||||
throw new Error('--sha256 must be a 64-character lowercase or uppercase hex digest.')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function assertSha256Matches(text: string, expectedSha256: string, spec: string): void {
|
||||
const actual = sha256OfSkillSource(text)
|
||||
if (actual !== expectedSha256) {
|
||||
throw new Error(
|
||||
`Checksum mismatch for "${spec}". Expected ${expectedSha256}, got ${actual}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertCompatibleOpenClaudeVersion(entry: SkillRegistryEntry, spec: string): string | undefined {
|
||||
if (
|
||||
typeof entry.min_openclaude_version !== 'string' ||
|
||||
entry.min_openclaude_version.trim() === ''
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const minimum = entry.min_openclaude_version.trim()
|
||||
const current = coerce(publicBuildVersion)
|
||||
const required = coerce(minimum)
|
||||
|
||||
if (!current || !required) {
|
||||
throw new Error(
|
||||
`Registry entry "${spec}" has an invalid min_openclaude_version value: ${minimum}.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (lt(current, required)) {
|
||||
throw new Error(
|
||||
`Skill "${spec}" requires OpenClaude ${required.version} or newer. Current version is ${current.version}.`,
|
||||
)
|
||||
}
|
||||
|
||||
return minimum
|
||||
}
|
||||
|
||||
function trustInstallWarning(trust: string): string | null {
|
||||
if (trust === 'official') {
|
||||
return null
|
||||
}
|
||||
if (trust === 'verified') {
|
||||
return 'Warning: this verified community skill was reviewed, but is not maintained as an official OpenClaude skill.'
|
||||
}
|
||||
if (trust === 'community') {
|
||||
return 'Warning: this community skill passed registry validation, but may not be deeply reviewed or maintained by OpenClaude maintainers.'
|
||||
}
|
||||
if (trust === 'deprecated') {
|
||||
return 'Warning: this skill is marked deprecated. Install only if you intentionally need this older workflow.'
|
||||
}
|
||||
return `Warning: this skill has trust tier "${trust}". Review SKILL.md before using it.`
|
||||
}
|
||||
|
||||
function getSkillNameFromMarkdown(markdown: string, fallback: string): string {
|
||||
try {
|
||||
const { frontmatter } = parseFrontmatter(markdown, 'SKILL.md')
|
||||
const name = frontmatter.name
|
||||
if (typeof name === 'string' && name.trim() !== '') {
|
||||
return name.trim()
|
||||
}
|
||||
} catch {
|
||||
// Validation reports malformed frontmatter later.
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function skillNameFromSource(source: string): string {
|
||||
const withoutTrailingSlash = source.replace(/\/+$/, '')
|
||||
const leaf = basename(withoutTrailingSlash)
|
||||
if (/^skill\.md$/i.test(leaf)) {
|
||||
return basename(dirname(withoutTrailingSlash))
|
||||
}
|
||||
return leaf.replace(/\.md$/i, '') || 'skill'
|
||||
}
|
||||
|
||||
function normalizeInstallSkillName(value: string): string {
|
||||
const skillName = value.trim()
|
||||
if (
|
||||
!VALID_INSTALL_SKILL_NAME.test(skillName) ||
|
||||
skillName.length > MAX_INSTALL_SKILL_NAME_LENGTH
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid skill name "${value}". Use lowercase letters, numbers, dashes, optional colon namespaces, and at most ${MAX_INSTALL_SKILL_NAME_LENGTH} characters.`,
|
||||
)
|
||||
}
|
||||
return skillName
|
||||
}
|
||||
|
||||
function resolveContainedPath(root: string, child: string): string {
|
||||
const resolvedRoot = resolve(root)
|
||||
const resolvedChild = resolve(resolvedRoot, child)
|
||||
const relativePath = relative(resolvedRoot, resolvedChild)
|
||||
|
||||
if (
|
||||
relativePath === '' ||
|
||||
relativePath.startsWith('..') ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid skill install path "${child}". Skill paths must stay inside ${getDisplayPath(resolvedRoot)}.`,
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedChild
|
||||
}
|
||||
|
||||
function skillNameToInstallPath(skillName: string): string {
|
||||
return join(...skillName.split(':'))
|
||||
}
|
||||
|
||||
function resolveSkillInstallPath(root: string, skillName: string): string {
|
||||
return resolveContainedPath(root, skillNameToInstallPath(skillName))
|
||||
}
|
||||
|
||||
async function getSkillNameFromDirectory(sourcePath: string): Promise<string> {
|
||||
const fallbackName = basename(sourcePath)
|
||||
try {
|
||||
const markdown = await getFsImplementation().readFile(
|
||||
join(sourcePath, 'SKILL.md'),
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
return getSkillNameFromMarkdown(markdown, fallbackName)
|
||||
} catch {
|
||||
// Validation reports missing or malformed SKILL.md after the directory is staged.
|
||||
return fallbackName
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareSkillFromMarkdown({
|
||||
markdown,
|
||||
fallbackName,
|
||||
registryEntry,
|
||||
}: {
|
||||
markdown: string
|
||||
fallbackName: string
|
||||
registryEntry?: SkillRegistryEntry
|
||||
}): Promise<{ tempRoot: string; tempDir: string; skillName: string }> {
|
||||
const skillName = normalizeInstallSkillName(
|
||||
typeof registryEntry?.name === 'string'
|
||||
? registryEntry.name
|
||||
: getSkillNameFromMarkdown(markdown, fallbackName),
|
||||
)
|
||||
const fs = getFsImplementation()
|
||||
const tempRoot = await fs.mkdtemp(join(tmpdir(), 'openclaude-skill-install-'))
|
||||
try {
|
||||
const tempDir = resolveSkillInstallPath(tempRoot, skillName)
|
||||
await fs.mkdir(tempDir)
|
||||
await fs.writeFile(join(tempDir, 'SKILL.md'), markdown, {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (registryEntry) {
|
||||
await fs.writeFile(
|
||||
join(tempDir, 'skill.json'),
|
||||
`${JSON.stringify(registryMetadata(registryEntry), null, 2)}\n`,
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
}
|
||||
return { tempRoot, tempDir, skillName }
|
||||
} catch (error) {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareInstallCandidate(
|
||||
spec: string,
|
||||
options: InstallOptions,
|
||||
): Promise<{
|
||||
tempDir: string
|
||||
tempRoot: string
|
||||
skillName: string
|
||||
sourceDescription: string
|
||||
trust: string
|
||||
toolsRequired: string[]
|
||||
minOpenClaudeVersion?: string
|
||||
registryBacked: boolean
|
||||
}> {
|
||||
if (!isUrl(spec) && (await pathExists(resolve(spec)))) {
|
||||
const fs = getFsImplementation()
|
||||
const sourcePath = resolve(spec)
|
||||
const sourceStats = await fs.stat(sourcePath)
|
||||
if (sourceStats.isDirectory()) {
|
||||
const skillName = normalizeInstallSkillName(
|
||||
await getSkillNameFromDirectory(sourcePath),
|
||||
)
|
||||
const tempRoot = await fs.mkdtemp(join(tmpdir(), 'openclaude-skill-install-'))
|
||||
try {
|
||||
const tempDir = resolveSkillInstallPath(tempRoot, skillName)
|
||||
await fs.cp(sourcePath, tempDir, {
|
||||
recursive: true,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: false,
|
||||
})
|
||||
return {
|
||||
tempRoot,
|
||||
tempDir,
|
||||
skillName,
|
||||
sourceDescription: getDisplayPath(sourcePath),
|
||||
trust: 'local',
|
||||
toolsRequired: [],
|
||||
registryBacked: false,
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = await fs.readFile(sourcePath, { encoding: 'utf8' })
|
||||
const fallbackName = skillNameFromSource(sourcePath)
|
||||
const prepared = await prepareSkillFromMarkdown({ markdown, fallbackName })
|
||||
return {
|
||||
...prepared,
|
||||
sourceDescription: getDisplayPath(sourcePath),
|
||||
trust: 'local',
|
||||
toolsRequired: [],
|
||||
registryBacked: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (isUrl(spec)) {
|
||||
const url = new URL(spec)
|
||||
const expectedSha256 =
|
||||
url.protocol === 'http:' || url.protocol === 'https:'
|
||||
? normalizeExpectedSha256(options.sha256)
|
||||
: null
|
||||
if (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
!expectedSha256
|
||||
) {
|
||||
throw new Error(
|
||||
'Direct HTTP(S) skill installs require --sha256 to pin the expected SKILL.md digest.',
|
||||
)
|
||||
}
|
||||
const markdown = await readSourceText(spec)
|
||||
if (expectedSha256) {
|
||||
assertSha256Matches(markdown, expectedSha256, spec)
|
||||
}
|
||||
const fallbackName = skillNameFromSource(url.pathname)
|
||||
const prepared = await prepareSkillFromMarkdown({ markdown, fallbackName })
|
||||
return {
|
||||
...prepared,
|
||||
sourceDescription: spec,
|
||||
trust: 'url',
|
||||
toolsRequired: [],
|
||||
registryBacked: false,
|
||||
}
|
||||
}
|
||||
|
||||
const registryMatch = await resolveRegistryEntry(spec, options)
|
||||
const entry = registryMatch?.entry
|
||||
if (!entry || typeof entry.source !== 'string') {
|
||||
throw new Error(`Skill "${spec}" was not found in the registry.`)
|
||||
}
|
||||
|
||||
const expectedSha256 = requireRegistrySha256(entry, spec)
|
||||
const minOpenClaudeVersion = assertCompatibleOpenClaudeVersion(entry, spec)
|
||||
const entrySource = resolveRegistryEntrySource(
|
||||
entry.source,
|
||||
registryMatch.registrySource,
|
||||
)
|
||||
const markdown = await readSourceText(entrySource)
|
||||
assertSha256Matches(markdown, expectedSha256, spec)
|
||||
|
||||
const fallbackName =
|
||||
typeof entry.name === 'string' ? entry.name : skillNameFromSource(entrySource)
|
||||
const prepared = await prepareSkillFromMarkdown({
|
||||
markdown,
|
||||
fallbackName,
|
||||
registryEntry: entry,
|
||||
})
|
||||
return {
|
||||
...prepared,
|
||||
sourceDescription: entrySource,
|
||||
trust: typeof entry.trust === 'string' ? entry.trust : 'registry',
|
||||
toolsRequired: stringArray(entry.tools_required),
|
||||
minOpenClaudeVersion,
|
||||
registryBacked: true,
|
||||
}
|
||||
}
|
||||
|
||||
export async function skillsInstallHandler(
|
||||
spec: string,
|
||||
options: InstallOptions = {},
|
||||
): Promise<void> {
|
||||
let candidate:
|
||||
| Awaited<ReturnType<typeof prepareInstallCandidate>>
|
||||
| undefined
|
||||
|
||||
try {
|
||||
candidate = await prepareInstallCandidate(spec, options)
|
||||
const installErrors = await validateSkillPath(candidate.tempDir, {
|
||||
requireRegistryMetadata: candidate.registryBacked,
|
||||
})
|
||||
if (installErrors.length > 0) {
|
||||
console.error(`Skill install failed validation for "${candidate.skillName}":`)
|
||||
for (const error of installErrors) {
|
||||
console.error(`- ${error}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const root = installRoot(options)
|
||||
const targetDir = resolveSkillInstallPath(root, candidate.skillName)
|
||||
if ((await pathExists(targetDir)) && !options.force) {
|
||||
console.error(
|
||||
`Skill "${candidate.skillName}" already exists at ${getDisplayPath(targetDir)}. Use --force to overwrite.`,
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Installing skill "${candidate.skillName}"`)
|
||||
console.log(`Source: ${candidate.sourceDescription}`)
|
||||
console.log(`Trust: ${candidate.trust}`)
|
||||
const trustWarning = trustInstallWarning(candidate.trust)
|
||||
if (trustWarning) {
|
||||
console.warn(trustWarning)
|
||||
}
|
||||
if (candidate.toolsRequired.length > 0) {
|
||||
console.log(`Tools required: ${candidate.toolsRequired.join(', ')}`)
|
||||
}
|
||||
if (candidate.minOpenClaudeVersion) {
|
||||
console.log(`Requires OpenClaude: >= ${candidate.minOpenClaudeVersion}`)
|
||||
}
|
||||
console.log(`Target: ${getDisplayPath(targetDir)}`)
|
||||
|
||||
const fs = getFsImplementation()
|
||||
await fs.mkdir(root)
|
||||
if (options.force) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true })
|
||||
}
|
||||
await fs.cp(candidate.tempDir, targetDir, {
|
||||
recursive: true,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: false,
|
||||
})
|
||||
console.log(`Installed skill "${candidate.skillName}".`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Skill install failed: ${message}`)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (candidate) {
|
||||
await getFsImplementation().rm(candidate.tempRoot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { getCommandName, type Command } from '../../types/command.js'
|
||||
import { getDisplayPath } from '../../utils/file.js'
|
||||
|
||||
export type SkillListCommand = Command & { type: 'prompt' }
|
||||
|
||||
export function sourceLabel(skill: SkillListCommand): string {
|
||||
if (!skill.source) return '-'
|
||||
if (skill.source === 'projectSettings') return 'project'
|
||||
if (skill.source === 'userSettings') return 'user'
|
||||
if (skill.source === 'policySettings') return 'managed'
|
||||
return skill.source
|
||||
}
|
||||
|
||||
export function trustLabel(skill: SkillListCommand): string {
|
||||
if (skill.source === 'bundled') return 'bundled'
|
||||
if (skill.source === 'plugin') return 'plugin'
|
||||
if (skill.source === 'mcp') return 'mcp'
|
||||
if (skill.source === 'policySettings') return 'managed'
|
||||
if (skill.skillTrust) return skill.skillTrust
|
||||
return 'local'
|
||||
}
|
||||
|
||||
export function locationLabel(skill: SkillListCommand): string {
|
||||
if (skill.skillFilePath) return getDisplayPath(skill.skillFilePath)
|
||||
if (skill.skillRoot) return getDisplayPath(skill.skillRoot)
|
||||
return '-'
|
||||
}
|
||||
|
||||
export function isPublicSkill(skill: SkillListCommand): boolean {
|
||||
return skill.source !== 'bundled'
|
||||
}
|
||||
|
||||
function publicSkills(skills: SkillListCommand[]): SkillListCommand[] {
|
||||
return skills.filter(isPublicSkill)
|
||||
}
|
||||
|
||||
function getResolutionState(
|
||||
skills: SkillListCommand[],
|
||||
): Map<SkillListCommand, string> {
|
||||
const winners = new Map<string, SkillListCommand>()
|
||||
const states = new Map<SkillListCommand, string>()
|
||||
|
||||
for (const skill of skills) {
|
||||
const key = getCommandName(skill)
|
||||
const winner = winners.get(key)
|
||||
if (winner) {
|
||||
states.set(skill, `shadowed by ${sourceLabel(winner)}`)
|
||||
} else {
|
||||
winners.set(key, skill)
|
||||
states.set(skill, 'enabled')
|
||||
}
|
||||
}
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
function normalizeDescription(text: string | undefined): string {
|
||||
const normalized = text?.trim().replace(/\s+/g, ' ') ?? ''
|
||||
return normalized || 'No description provided.'
|
||||
}
|
||||
|
||||
function descriptionSummary(text: string | undefined): string {
|
||||
const normalized = normalizeDescription(text)
|
||||
if (normalized === 'No description provided.') return normalized
|
||||
const firstSentence = normalized.match(/^.*?(?:\.(?:\s|$)|$)/)?.[0] ?? normalized
|
||||
const withoutSkillPrefix = firstSentence.replace(/^Use this skill to\s+/i, '')
|
||||
const summary = (
|
||||
withoutSkillPrefix
|
||||
? withoutSkillPrefix[0]!.toUpperCase() + withoutSkillPrefix.slice(1)
|
||||
: withoutSkillPrefix
|
||||
).trim()
|
||||
return /[.!?]$/.test(summary) ? summary : `${summary}.`
|
||||
}
|
||||
|
||||
export function wrapSkillDescription(text: string, width: number): string[] {
|
||||
const words = text.split(/\s+/).filter(Boolean)
|
||||
const lines: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const word of words) {
|
||||
const next = current ? `${current} ${word}` : word
|
||||
if (next.length > width && current) {
|
||||
lines.push(current)
|
||||
current = word
|
||||
} else {
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
lines.push(current)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function terminalWidth(): number {
|
||||
return process.stdout.columns && process.stdout.columns > 0
|
||||
? process.stdout.columns
|
||||
: 100
|
||||
}
|
||||
|
||||
function separator(width: number): string {
|
||||
return '─'.repeat(width)
|
||||
}
|
||||
|
||||
function formatSkillListRow({
|
||||
skill,
|
||||
state,
|
||||
nameWidth,
|
||||
statusWidth,
|
||||
descriptionWidth,
|
||||
descriptionIndent,
|
||||
}: {
|
||||
skill: SkillListCommand
|
||||
state: string | undefined
|
||||
nameWidth: number
|
||||
statusWidth: number
|
||||
descriptionWidth: number
|
||||
descriptionIndent: string
|
||||
}): string {
|
||||
const status = state ?? 'enabled'
|
||||
const prefix = `${getCommandName(skill).padEnd(nameWidth)} ${status.padEnd(statusWidth)} `
|
||||
const descriptionLines = wrapSkillDescription(
|
||||
descriptionSummary(skill.description),
|
||||
descriptionWidth,
|
||||
)
|
||||
const lines = [`${prefix}${descriptionLines[0] ?? ''}`]
|
||||
lines.push(
|
||||
...descriptionLines.slice(1).map(line => `${descriptionIndent}${line}`),
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function skillListJson(
|
||||
skill: SkillListCommand,
|
||||
state: string | undefined,
|
||||
): object {
|
||||
return {
|
||||
name: getCommandName(skill),
|
||||
status: state ?? 'enabled',
|
||||
source: sourceLabel(skill),
|
||||
trust: trustLabel(skill),
|
||||
version: skill.version ?? null,
|
||||
description: skill.description ?? null,
|
||||
location: locationLabel(skill),
|
||||
loadedFrom: skill.loadedFrom ?? null,
|
||||
userInvocable: skill.userInvocable ?? null,
|
||||
whenToUse: skill.whenToUse ?? null,
|
||||
allowedTools: skill.allowedTools ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSkillsListJson(skills: SkillListCommand[]): string {
|
||||
const visibleSkills = publicSkills(skills)
|
||||
const states = getResolutionState(visibleSkills)
|
||||
return JSON.stringify(
|
||||
{
|
||||
enabledCount: [...states.values()].filter(s => s === 'enabled').length,
|
||||
skills: visibleSkills
|
||||
.slice()
|
||||
.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b)))
|
||||
.map(skill => skillListJson(skill, states.get(skill))),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
|
||||
export function formatSkillsListForDisplay(
|
||||
skills: SkillListCommand[],
|
||||
columns = terminalWidth(),
|
||||
): string {
|
||||
const visibleSkills = publicSkills(skills)
|
||||
const states = getResolutionState(visibleSkills)
|
||||
const sortedSkills = visibleSkills
|
||||
.slice()
|
||||
.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b)))
|
||||
const enabledCount = sortedSkills.filter(
|
||||
skill => states.get(skill) === 'enabled',
|
||||
).length
|
||||
|
||||
if (sortedSkills.length === 0) {
|
||||
return ['Skills: 0 enabled', '', 'No installed skills found.'].join('\n')
|
||||
}
|
||||
|
||||
const nameWidth = Math.max(
|
||||
'Name'.length,
|
||||
...sortedSkills.map(skill => getCommandName(skill).length),
|
||||
)
|
||||
const statusWidth = Math.max(
|
||||
'Status'.length,
|
||||
...sortedSkills.map(skill => (states.get(skill) ?? 'enabled').length),
|
||||
)
|
||||
const descriptionStart = nameWidth + 2 + statusWidth + 3
|
||||
const descriptionWidth = Math.max(20, columns - descriptionStart)
|
||||
const descriptionIndent = ' '.repeat(descriptionStart)
|
||||
const header = `${'Name'.padEnd(nameWidth)} ${'Status'.padEnd(statusWidth)} Description`
|
||||
const rule = `${separator(nameWidth)} ${separator(statusWidth)} ${separator(descriptionWidth)}`
|
||||
const rows = sortedSkills.map(skill =>
|
||||
formatSkillListRow({
|
||||
skill,
|
||||
state: states.get(skill),
|
||||
nameWidth,
|
||||
statusWidth,
|
||||
descriptionWidth,
|
||||
descriptionIndent,
|
||||
}),
|
||||
)
|
||||
|
||||
return [
|
||||
`Skills: ${enabledCount} enabled`,
|
||||
'',
|
||||
header,
|
||||
rule,
|
||||
...rows,
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getCommandName } from '../../types/command.js'
|
||||
import type { SkillListCommand } from './skillsListFormat.js'
|
||||
|
||||
type RemoveOptions = { global?: boolean }
|
||||
|
||||
function isMatchingLocalSkill(
|
||||
skill: SkillListCommand,
|
||||
name: string,
|
||||
source: SkillListCommand['source'],
|
||||
): boolean {
|
||||
return (
|
||||
skill.source === source &&
|
||||
skill.loadedFrom === 'skills' &&
|
||||
(skill.name === name || getCommandName(skill) === name)
|
||||
)
|
||||
}
|
||||
|
||||
export function findLocalSkillForRemoval(
|
||||
skills: SkillListCommand[],
|
||||
name: string,
|
||||
source: SkillListCommand['source'],
|
||||
): SkillListCommand | undefined {
|
||||
return skills.find(skill => isMatchingLocalSkill(skill, name, source))
|
||||
}
|
||||
|
||||
export function getSkillRemoveNotFoundMessage(
|
||||
skills: SkillListCommand[],
|
||||
name: string,
|
||||
options: RemoveOptions,
|
||||
): string {
|
||||
const alternateSource = options.global ? 'projectSettings' : 'userSettings'
|
||||
const alternateSkill = findLocalSkillForRemoval(
|
||||
skills,
|
||||
name,
|
||||
alternateSource,
|
||||
)
|
||||
|
||||
if (alternateSkill) {
|
||||
return options.global
|
||||
? `Skill "${name}" is installed in this project. Remove it without --global.`
|
||||
: `Skill "${name}" is installed globally. Use --global to remove it.`
|
||||
}
|
||||
|
||||
return `Skill "${name}" not found.`
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { basename, join, resolve, sep } from 'path'
|
||||
import { getDisplayPath } from '../../utils/file.js'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { getFsImplementation } from '../../utils/fsOperations.js'
|
||||
|
||||
const REQUIRED_METADATA = [
|
||||
'name',
|
||||
'title',
|
||||
'description',
|
||||
'version',
|
||||
'category',
|
||||
'author',
|
||||
'license',
|
||||
'trust',
|
||||
] as const
|
||||
|
||||
type ValidationOptions = {
|
||||
requireRegistryMetadata?: boolean
|
||||
}
|
||||
|
||||
const VALID_SKILL_NAME = /^[a-z0-9][a-z0-9-]*(?::[a-z0-9][a-z0-9-]*)*$/
|
||||
const UNSAFE_FILE_NAMES = new Set([
|
||||
'package.json',
|
||||
'bun.lock',
|
||||
'package-lock.json',
|
||||
'pnpm-lock.yaml',
|
||||
'yarn.lock',
|
||||
])
|
||||
const UNSAFE_TEXT_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\bcurl\b[^|\n]*\|\s*(?:sh|bash)\b/i, 'curl pipe-to-shell install command'],
|
||||
[/\bbase64\b[^|\n]*\|\s*(?:sh|bash|node|python|python3)\b/i, 'base64 decode-and-execute command'],
|
||||
[/\brm\s+-rf\s+(?:\/|\$HOME|~|\*)/i, 'destructive rm command'],
|
||||
[
|
||||
/\b(?:api[_-]?key|token|secret|password)\b\s*[:=]\s*['"]?[A-Za-z0-9_./+=-]{16,}/i,
|
||||
'embedded credential-like value',
|
||||
],
|
||||
[
|
||||
/(?:^|[.!?\n]\s*)(?:please\s+)?(?:send|paste|provide|enter)\s+(?:your\s+)?(?:api[_-]?key|token|secret|password)\b/i,
|
||||
'credential collection instruction',
|
||||
],
|
||||
]
|
||||
const MAX_SKILL_TEXT_FILE_BYTES = 1024 * 1024
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function metadataValue(
|
||||
frontmatter: Record<string, unknown>,
|
||||
jsonMetadata: Record<string, unknown>,
|
||||
field: string,
|
||||
): unknown {
|
||||
return jsonMetadata[field] ?? frontmatter[field]
|
||||
}
|
||||
|
||||
async function readOptionalSkillJson(
|
||||
skillDir: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const skillJsonPath = join(skillDir, 'skill.json')
|
||||
const fs = getFsImplementation()
|
||||
try {
|
||||
if ((await fs.stat(skillJsonPath)).size > MAX_SKILL_TEXT_FILE_BYTES) {
|
||||
return {}
|
||||
}
|
||||
const raw = await fs.readFile(skillJsonPath, { encoding: 'utf8' })
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return isPlainObject(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSkillFiles(skillDir: string): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
const fs = getFsImplementation()
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir)
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
const relativePath = fullPath.slice(skillDir.length + 1)
|
||||
|
||||
if (relativePath.split(sep).includes('..')) {
|
||||
files.push(relativePath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
files.push(relativePath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(skillDir)
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
async function fileLooksBinary(path: string): Promise<boolean> {
|
||||
return (await getFsImplementation().readFileBytes(path, 4096)).includes(0)
|
||||
}
|
||||
|
||||
export async function validateSkillPath(
|
||||
path: string,
|
||||
options: ValidationOptions = {},
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
const skillDir = resolve(path)
|
||||
const skillFilePath = join(skillDir, 'SKILL.md')
|
||||
const fs = getFsImplementation()
|
||||
|
||||
try {
|
||||
const dirStats = await fs.stat(skillDir)
|
||||
if (!dirStats.isDirectory()) {
|
||||
return [`${getDisplayPath(skillDir)} is not a directory.`]
|
||||
}
|
||||
} catch {
|
||||
return [`${getDisplayPath(skillDir)} does not exist.`]
|
||||
}
|
||||
|
||||
try {
|
||||
const skillFileStats = await fs.stat(skillFilePath)
|
||||
if (!skillFileStats.isFile()) {
|
||||
errors.push('SKILL.md is not a file.')
|
||||
}
|
||||
} catch {
|
||||
errors.push('Missing SKILL.md.')
|
||||
return errors
|
||||
}
|
||||
|
||||
let skillMarkdown = ''
|
||||
let frontmatter: Record<string, unknown> = {}
|
||||
const skillFileStats = await fs.stat(skillFilePath)
|
||||
if (skillFileStats.size > MAX_SKILL_TEXT_FILE_BYTES) {
|
||||
errors.push(`SKILL.md is too large. Skill text files must be at most ${MAX_SKILL_TEXT_FILE_BYTES} bytes.`)
|
||||
} else {
|
||||
try {
|
||||
skillMarkdown = await fs.readFile(skillFilePath, { encoding: 'utf8' })
|
||||
frontmatter = parseFrontmatter(skillMarkdown, skillFilePath).frontmatter
|
||||
} catch {
|
||||
errors.push('SKILL.md could not be read as UTF-8 markdown.')
|
||||
}
|
||||
}
|
||||
|
||||
const jsonMetadata = await readOptionalSkillJson(skillDir)
|
||||
const name = metadataValue(frontmatter, jsonMetadata, 'name')
|
||||
if (typeof name === 'string' && !VALID_SKILL_NAME.test(name)) {
|
||||
errors.push(`Invalid skill name "${name}". Use lowercase letters, numbers, dashes, and optional colon namespaces.`)
|
||||
}
|
||||
|
||||
if (options.requireRegistryMetadata) {
|
||||
for (const field of REQUIRED_METADATA) {
|
||||
const value = metadataValue(frontmatter, jsonMetadata, field)
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
errors.push(`Missing required metadata: ${field}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let files: string[] = []
|
||||
try {
|
||||
files = await collectSkillFiles(skillDir)
|
||||
} catch {
|
||||
errors.push('Skill files could not be read.')
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = join(skillDir, file)
|
||||
const fileName = basename(file)
|
||||
const fileStats = await fs.lstat(fullPath)
|
||||
|
||||
if (fileStats.isSymbolicLink()) {
|
||||
errors.push(`Symlinks are not allowed: ${file}.`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (UNSAFE_FILE_NAMES.has(fileName)) {
|
||||
errors.push(`Executable/dependency metadata is not allowed in skills: ${file}.`)
|
||||
}
|
||||
|
||||
if (fileStats.isFile() && (await fileLooksBinary(fullPath))) {
|
||||
errors.push(`Binary files are not allowed in skills: ${file}.`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (fileStats.isFile() && /\.(?:md|json|txt|ya?ml|sh|js|ts)$/i.test(file)) {
|
||||
if (fileStats.size > MAX_SKILL_TEXT_FILE_BYTES) {
|
||||
errors.push(`${file} is too large. Skill text files must be at most ${MAX_SKILL_TEXT_FILE_BYTES} bytes.`)
|
||||
continue
|
||||
}
|
||||
const text = await fs.readFile(fullPath, { encoding: 'utf8' })
|
||||
for (const [pattern, label] of UNSAFE_TEXT_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
errors.push(`Unsafe pattern detected in ${file}: ${label}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(errors)]
|
||||
}
|
||||
+51
-1
@@ -1,9 +1,13 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
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 {
|
||||
@@ -39,6 +43,7 @@ function useLanguage(language?: string): void {
|
||||
afterEach(() => {
|
||||
resetSettingsCache()
|
||||
clearBundledSkills()
|
||||
clearCommandMemoizationCaches()
|
||||
})
|
||||
|
||||
// Narrows the Command union to the prompt variant so getPromptForCommand is
|
||||
@@ -62,6 +67,51 @@ describe('builtInCommandNames', () => {
|
||||
}
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
+2
-2
@@ -506,12 +506,12 @@ const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
|
||||
])
|
||||
|
||||
return [
|
||||
...bundledSkills,
|
||||
...builtinPluginSkills,
|
||||
...skillDirCommands,
|
||||
...workflowCommands,
|
||||
...pluginCommands,
|
||||
...pluginSkills,
|
||||
...bundledSkills,
|
||||
...builtinPluginSkills,
|
||||
...COMMANDS(),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
|
||||
const repoRoot = resolve(import.meta.dir, '..', '..')
|
||||
const cliEntrypoint = join(repoRoot, 'src', 'entrypoints', 'cli.tsx')
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
return new Response(stream).text()
|
||||
}
|
||||
|
||||
async function runSkillsList(args: string[]): Promise<{
|
||||
exitCode: number
|
||||
stderr: string
|
||||
stdout: string
|
||||
}> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'openclaude-skills-cli-'))
|
||||
const projectDir = join(root, 'project')
|
||||
const homeDir = join(root, 'home')
|
||||
const configDir = join(root, 'config')
|
||||
mkdirSync(projectDir)
|
||||
mkdirSync(homeDir)
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: [process.execPath, cliEntrypoint, ...args],
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_API_KEY: '',
|
||||
CLAUDE_CONFIG_DIR: configDir,
|
||||
HOME: homeDir,
|
||||
OPENCLAUDE_DISABLE_EARLY_INPUT: '1',
|
||||
},
|
||||
stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
})
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readStream(proc.stdout),
|
||||
readStream(proc.stderr),
|
||||
proc.exited,
|
||||
])
|
||||
|
||||
return { exitCode, stderr, stdout }
|
||||
}
|
||||
|
||||
test('skills list bypasses provider startup validation', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList(['skills', 'list'])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('Skills: 0 enabled')
|
||||
expect(stdout).toContain('No installed skills found.')
|
||||
expect(stderr).not.toContain('OPENAI_API_KEY is required')
|
||||
}, 15_000)
|
||||
|
||||
test('skills list bypasses provider startup validation after --bare', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'--bare',
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('Skills: 0 enabled')
|
||||
expect(stdout).toContain('No installed skills found.')
|
||||
expect(stderr).not.toContain('OPENAI_API_KEY is required')
|
||||
}, 15_000)
|
||||
|
||||
test('skills list bypasses provider startup validation after --settings', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'--settings',
|
||||
'{}',
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('Skills: 0 enabled')
|
||||
expect(stderr).not.toContain('OPENAI_API_KEY is required')
|
||||
}, 15_000)
|
||||
|
||||
test('skills list bypasses provider startup validation after --setting-sources', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'--setting-sources',
|
||||
'user,project',
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('Skills: 0 enabled')
|
||||
expect(stderr).not.toContain('OPENAI_API_KEY is required')
|
||||
}, 15_000)
|
||||
|
||||
test('skills list honors --add-dir before provider startup validation', async () => {
|
||||
const addDirRoot = mkdtempSync(join(tmpdir(), 'openclaude-skills-add-dir-'))
|
||||
const skillDir = join(addDirRoot, '.openclaude', 'skills', 'addon')
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
`---\ndescription: Skill loaded from add-dir.\n---\n# Addon\n`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'--add-dir',
|
||||
addDirRoot,
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('addon')
|
||||
expect(stderr).not.toContain('OPENAI_API_KEY is required')
|
||||
}, 15_000)
|
||||
|
||||
test('skills list accepts equals-form global flags before provider startup validation', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'openclaude-skills-cli-flags-'))
|
||||
const providerEnvFile = join(root, 'provider.env')
|
||||
const pluginDir = join(root, 'plugin')
|
||||
try {
|
||||
writeFileSync(providerEnvFile, '', 'utf8')
|
||||
mkdirSync(pluginDir, { recursive: true })
|
||||
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
`--provider-env-file=${providerEnvFile}`,
|
||||
`--plugin-dir=${pluginDir}`,
|
||||
'--mcp-config={}',
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('Skills: 0 enabled')
|
||||
expect(stderr).not.toContain('OPENAI_API_KEY is required')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test('--print keeps skills as a prompt instead of the management subcommand', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'--print',
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).not.toContain('Skills: 0 enabled')
|
||||
expect(stderr).toContain('OPENAI_API_KEY')
|
||||
}, 15_000)
|
||||
|
||||
test('--print with intervening global flags keeps skills as prompt text', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'--print',
|
||||
'--model',
|
||||
'gpt-4',
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).not.toContain('Skills: 0 enabled')
|
||||
expect(stderr).toContain('OPENAI_API_KEY')
|
||||
}, 15_000)
|
||||
|
||||
test('--continue keeps skills as prompt text instead of the management subcommand', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'--continue',
|
||||
'skills',
|
||||
'list',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).not.toContain('Skills: 0 enabled')
|
||||
expect(stderr).toContain('OPENAI_API_KEY')
|
||||
}, 15_000)
|
||||
|
||||
test('skills list accepts trailing global flags', async () => {
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'skills',
|
||||
'list',
|
||||
'--bare',
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('Skills: 0 enabled')
|
||||
expect(stderr).not.toContain('Unknown skills option')
|
||||
}, 15_000)
|
||||
|
||||
test('skills list honors trailing --add-dir', async () => {
|
||||
const addDirRoot = mkdtempSync(join(tmpdir(), 'openclaude-skills-add-dir-'))
|
||||
const skillDir = join(addDirRoot, '.openclaude', 'skills', 'addon')
|
||||
try {
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
`---\ndescription: Skill loaded from trailing add-dir.\n---\n# Addon\n`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const { exitCode, stderr, stdout } = await runSkillsList([
|
||||
'skills',
|
||||
'list',
|
||||
'--add-dir',
|
||||
addDirRoot,
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('addon')
|
||||
expect(stderr).not.toContain('Unknown skills option')
|
||||
} finally {
|
||||
rmSync(addDirRoot, { recursive: true, force: true })
|
||||
}
|
||||
}, 15_000)
|
||||
+193
-3
@@ -38,6 +38,179 @@ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true'
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
process.env.COREPACK_ENABLE_AUTO_PIN = '0';
|
||||
|
||||
const SKILLS_LEADING_BOOLEAN_FLAGS = new Set([
|
||||
'--bare',
|
||||
'--debug',
|
||||
'--debug-to-stderr',
|
||||
'--dangerously-skip-permissions',
|
||||
'--allow-dangerously-skip-permissions',
|
||||
'--disable-slash-commands',
|
||||
'--enable-auth-status',
|
||||
'--fork-session',
|
||||
'--ide',
|
||||
'--include-hook-events',
|
||||
'--include-partial-messages',
|
||||
'--init',
|
||||
'--init-only',
|
||||
'--maintenance',
|
||||
'--mcp-debug',
|
||||
'--no-chrome',
|
||||
'--no-session-persistence',
|
||||
'--replay-user-messages',
|
||||
'--strict-mcp-config',
|
||||
'--verbose',
|
||||
])
|
||||
|
||||
const SKILLS_LEADING_VALUE_FLAGS = new Set([
|
||||
'--agent',
|
||||
'--append-system-prompt',
|
||||
'--append-system-prompt-file',
|
||||
'--debug-file',
|
||||
'--effort',
|
||||
'--fallback-model',
|
||||
'--heartbeat',
|
||||
'--input-format',
|
||||
'--json-schema',
|
||||
'--max-budget-usd',
|
||||
'--max-thinking-tokens',
|
||||
'--max-turns',
|
||||
'--model',
|
||||
'--output-format',
|
||||
'--permission-mode',
|
||||
'--permission-prompt-tool',
|
||||
'--provider',
|
||||
'--resume-session-at',
|
||||
'--session-id',
|
||||
'--settings',
|
||||
'--setting-sources',
|
||||
'--system-prompt',
|
||||
'--system-prompt-file',
|
||||
'--thinking',
|
||||
'--workload',
|
||||
'-n',
|
||||
'--name',
|
||||
])
|
||||
|
||||
const SKILLS_LEADING_OPTIONAL_VALUE_FLAGS = new Set([
|
||||
'--continue',
|
||||
'--from-pr',
|
||||
'--print',
|
||||
'-c',
|
||||
'-p',
|
||||
'-r',
|
||||
'--resume',
|
||||
])
|
||||
|
||||
const SKILLS_LEADING_MULTI_VALUE_FLAGS = new Set([
|
||||
'--add-dir',
|
||||
'--allowedTools',
|
||||
'--allowed-tools',
|
||||
'--betas',
|
||||
'--disallowedTools',
|
||||
'--disallowed-tools',
|
||||
'--file',
|
||||
'--mcp-config',
|
||||
'--plugin-dir',
|
||||
'--provider-env-file',
|
||||
'--tools',
|
||||
])
|
||||
|
||||
type SkillsCliParseResult = {
|
||||
additionalDirectories: string[]
|
||||
args: string[]
|
||||
}
|
||||
|
||||
function getSkillsCliArgs(args: string[]): SkillsCliParseResult | undefined {
|
||||
const additionalDirectories: string[] = []
|
||||
let sawPromptModeFlag = false
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === 'skills') {
|
||||
if (sawPromptModeFlag) {
|
||||
return undefined
|
||||
}
|
||||
return { additionalDirectories, args: args.slice(index) }
|
||||
}
|
||||
if (SKILLS_LEADING_BOOLEAN_FLAGS.has(arg)) {
|
||||
continue
|
||||
}
|
||||
if (SKILLS_LEADING_MULTI_VALUE_FLAGS.has(arg)) {
|
||||
let consumed = false
|
||||
while (args[index + 1] && !args[index + 1]!.startsWith('-')) {
|
||||
index += 1
|
||||
const value = args[index]
|
||||
if (value === 'skills') {
|
||||
if (sawPromptModeFlag) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
additionalDirectories,
|
||||
args: args.slice(index),
|
||||
}
|
||||
}
|
||||
if (value && arg === '--add-dir') {
|
||||
additionalDirectories.push(value)
|
||||
}
|
||||
consumed = true
|
||||
}
|
||||
if (!consumed) {
|
||||
return undefined
|
||||
}
|
||||
continue
|
||||
}
|
||||
const multiValueEqualsFlag = Array.from(SKILLS_LEADING_MULTI_VALUE_FLAGS)
|
||||
.find(flag => arg?.startsWith(`${flag}=`))
|
||||
if (multiValueEqualsFlag) {
|
||||
const value = arg.slice(`${multiValueEqualsFlag}=`.length)
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
if (multiValueEqualsFlag === '--add-dir') {
|
||||
additionalDirectories.push(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
SKILLS_LEADING_VALUE_FLAGS.has(arg) &&
|
||||
args[index + 1] &&
|
||||
!args[index + 1]!.startsWith('-')
|
||||
) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (
|
||||
Array.from(SKILLS_LEADING_VALUE_FLAGS).some(flag =>
|
||||
arg?.startsWith(`${flag}=`),
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (SKILLS_LEADING_OPTIONAL_VALUE_FLAGS.has(arg)) {
|
||||
sawPromptModeFlag = true
|
||||
if (
|
||||
args[index + 1] &&
|
||||
args[index + 1] !== 'skills' &&
|
||||
!args[index + 1]!.startsWith('-')
|
||||
) {
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
Array.from(SKILLS_LEADING_OPTIONAL_VALUE_FLAGS).some(flag =>
|
||||
arg?.startsWith(`${flag}=`),
|
||||
)
|
||||
) {
|
||||
sawPromptModeFlag = true
|
||||
continue
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Set max heap size for child processes. The current CLI process is already
|
||||
// running by this point; the package launcher raises its heap before importing
|
||||
// dist/cli.mjs. Keeping NODE_OPTIONS here preserves the larger cap for tools or
|
||||
@@ -239,6 +412,20 @@ export async function main(
|
||||
}
|
||||
reapplyExplicitProviderInputs()
|
||||
|
||||
// Local skills management must stay available even when provider startup
|
||||
// configuration is broken, so users can inspect/fix skills from scripts.
|
||||
const skillsCliArgs = getSkillsCliArgs(args)
|
||||
if (skillsCliArgs) {
|
||||
const { setAdditionalDirectoriesForClaudeMd } = await import(
|
||||
'../bootstrap/state.js'
|
||||
)
|
||||
setAdditionalDirectoriesForClaudeMd(skillsCliArgs.additionalDirectories)
|
||||
const { runSkillsCli } = await import('../cli/handlers/skillsCli.js')
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, ...skillsCliArgs.args]
|
||||
await runSkillsCli(skillsCliArgs.args)
|
||||
return
|
||||
}
|
||||
|
||||
const { applyStartupEnvFromProfile } = await importers.providerProfile()
|
||||
await applyStartupEnvFromProfile({
|
||||
processEnv: process.env,
|
||||
@@ -319,9 +506,12 @@ export async function main(
|
||||
const { eagerParseCliFlag } = await importers.cliArgs()
|
||||
const earlyModelFlag = eagerParseCliFlag('--model')
|
||||
|
||||
// Print the gradient startup screen before the Ink UI loads
|
||||
const { printStartupScreen } = await importers.startupScreen()
|
||||
printStartupScreen(earlyModelFlag)
|
||||
// Print the gradient startup screen before the Ink UI loads. Plain CLI
|
||||
// management subcommands should stay script-friendly and avoid the banner.
|
||||
if (args[0] !== 'skills') {
|
||||
const { printStartupScreen } = await importers.startupScreen()
|
||||
printStartupScreen(earlyModelFlag)
|
||||
}
|
||||
|
||||
// For all other paths, load the startup profiler
|
||||
const {
|
||||
|
||||
@@ -4156,6 +4156,37 @@ async function run(): Promise<CommanderCommand> {
|
||||
await agentsHandler();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const runSkillsCommanderAction = async (action: (handlers: typeof import('./cli/handlers/skills.js')) => Promise<void>) => {
|
||||
const [skillsHandlers, {
|
||||
runSkillsCliAction
|
||||
}] = await Promise.all([import('./cli/handlers/skills.js'), import('./cli/handlers/skillsCli.js')]);
|
||||
await runSkillsCliAction(() => action(skillsHandlers));
|
||||
process.exit(process.exitCode ?? 0);
|
||||
};
|
||||
const skillsCmd = program.command('skills').description('List, inspect, validate, and manage OpenClaude skills').configureHelp(createSortedHelpConfig());
|
||||
skillsCmd.command('list').description('List configured skills').option('--json', 'Output as JSON').action(async (options: {
|
||||
json?: boolean;
|
||||
}) => runSkillsCommanderAction(({ skillsListHandler }) => skillsListHandler(options)));
|
||||
skillsCmd.command('show <name>').description('Show details for a configured skill').action(async (name: string) => {
|
||||
await runSkillsCommanderAction(({ skillsShowHandler }) => skillsShowHandler(name));
|
||||
});
|
||||
skillsCmd.command('validate <path>').description('Validate a local skill directory').action(async (path: string) => {
|
||||
await runSkillsCommanderAction(({ skillsValidateHandler }) => skillsValidateHandler(path));
|
||||
});
|
||||
skillsCmd.command('install <idOrUrlOrPath>').description('Install a skill from the registry, URL, or local path').option('--registry <urlOrPath>', 'Registry JSON URL/path for registry ID installs').option('--sha256 <hash>', 'Expected SHA-256 digest for direct HTTP(S) URL installs').option('--global', 'Install to the user-global skills directory').option('--force', 'Overwrite an existing installed skill').action(async (idOrUrlOrPath: string, options: {
|
||||
registry?: string;
|
||||
sha256?: string;
|
||||
global?: boolean;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
await runSkillsCommanderAction(({ skillsInstallHandler }) => skillsInstallHandler(idOrUrlOrPath, options));
|
||||
});
|
||||
skillsCmd.command('remove <name>').description('Remove a local project skill').option('--global', 'Remove from the user-global skills directory').action(async (name: string, options: {
|
||||
global?: boolean;
|
||||
}) => {
|
||||
await runSkillsCommanderAction(({ skillsRemoveHandler }) => skillsRemoveHandler(name, options));
|
||||
});
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
// Skip when tengu_auto_mode_config.enabled === 'disabled' (circuit breaker).
|
||||
// Reads from disk cache — GrowthBook isn't initialized at registration time.
|
||||
|
||||
@@ -1,30 +1,141 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { test } from 'bun:test'
|
||||
|
||||
import { getSkillDirCommands, clearSkillCaches } from './loadSkillsDir.ts'
|
||||
import {
|
||||
enableUserAndProjectSettingSources,
|
||||
restoreSettingState,
|
||||
} from '../test/settingSourceState.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
import type { Command } from '../types/command.ts'
|
||||
import {
|
||||
getClaudeConfigHomeDir,
|
||||
getClaudeConfigHomeDirOverrideForTesting,
|
||||
setClaudeConfigHomeDirForTesting,
|
||||
} from '../utils/envUtils.ts'
|
||||
import {
|
||||
getFsImplementation,
|
||||
setFsImplementation,
|
||||
} from '../utils/fsOperations.ts'
|
||||
import { resetSettingsCache } from '../utils/settings/settingsCache.ts'
|
||||
import {
|
||||
clearDynamicSkills,
|
||||
clearSkillCaches,
|
||||
getSkillDirCommands,
|
||||
getProjectSkillsPaths,
|
||||
} from './loadSkillsDir.ts'
|
||||
|
||||
function writeSkill(rootDir: string, skillPath: string): void {
|
||||
const skillDir = join(rootDir, '.claude', 'skills', ...skillPath.split('/'))
|
||||
function writeSkill(
|
||||
rootDir: string,
|
||||
skillPath: string,
|
||||
options?: { configDirName?: '.claude' | '.openclaude'; description?: string },
|
||||
): void {
|
||||
const skillDir = join(
|
||||
rootDir,
|
||||
options?.configDirName ?? '.claude',
|
||||
'skills',
|
||||
...skillPath.split('/'),
|
||||
)
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
`---\ndescription: ${skillPath}\n---\n# ${skillPath}\n`,
|
||||
`---\ndescription: ${options?.description ?? skillPath}\n---\n# ${skillPath}\n`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
test('loads flat and nested skills with colon namespaces', async () => {
|
||||
function isPromptSkillNamed(
|
||||
skill: Command,
|
||||
name: string,
|
||||
): skill is Extract<Command, { type: 'prompt' }> {
|
||||
return (
|
||||
skill.type === 'prompt' &&
|
||||
skill.name === name
|
||||
)
|
||||
}
|
||||
|
||||
function writeUserSkill(
|
||||
configDir: string,
|
||||
skillPath: string,
|
||||
description = skillPath,
|
||||
): void {
|
||||
const skillDir = join(configDir, 'skills', ...skillPath.split('/'))
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
`---\ndescription: ${description}\n---\n# ${skillPath}\n`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
function clearSkillAndConfigCaches(): void {
|
||||
clearSkillCaches()
|
||||
getClaudeConfigHomeDir.cache?.clear?.()
|
||||
resetSettingsCache()
|
||||
}
|
||||
|
||||
function setRealFilesystemForTest(): ReturnType<typeof getFsImplementation> {
|
||||
const originalFs = getFsImplementation()
|
||||
setFsImplementation({
|
||||
...originalFs,
|
||||
stat: async path => statSync(path),
|
||||
readdir: async path => readdirSync(path, { withFileTypes: true }),
|
||||
readFile: async (path, options) => readFileSync(path, options),
|
||||
})
|
||||
return originalFs
|
||||
}
|
||||
|
||||
function setConfigDirEnv(configDir: string): void {
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
process.env.OPENCLAUDE_CONFIG_DIR = configDir
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
}
|
||||
|
||||
function restoreConfigDirEnv(original: {
|
||||
openClaudeConfigDir: string | undefined
|
||||
claudeConfigDir: string | undefined
|
||||
configHomeOverride: string | undefined
|
||||
}): void {
|
||||
setClaudeConfigHomeDirForTesting(original.configHomeOverride)
|
||||
|
||||
if (original.openClaudeConfigDir === undefined) {
|
||||
delete process.env.OPENCLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCLAUDE_CONFIG_DIR = original.openClaudeConfigDir
|
||||
}
|
||||
|
||||
if (original.claudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = original.claudeConfigDir
|
||||
}
|
||||
}
|
||||
|
||||
test.serial('loads flat and nested skills with colon namespaces', async () => {
|
||||
await acquireSharedMutationLock('loadSkillsDir.test.ts')
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
|
||||
const cwd = join(configDir, 'workspace')
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalConfigDir = {
|
||||
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR,
|
||||
configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(),
|
||||
}
|
||||
const originalSettingsState = enableUserAndProjectSettingSources()
|
||||
const originalFs = setRealFilesystemForTest()
|
||||
|
||||
try {
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
@@ -32,8 +143,8 @@ test('loads flat and nested skills with colon namespaces', async () => {
|
||||
writeSkill(configDir, 'git/commit')
|
||||
writeSkill(configDir, 'frontend/react/form')
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
clearSkillCaches()
|
||||
setConfigDirEnv(configDir)
|
||||
clearSkillAndConfigCaches()
|
||||
|
||||
const skills = await getSkillDirCommands(cwd)
|
||||
const fixtureSkillsRoot = join(configDir, '.claude', 'skills')
|
||||
@@ -68,15 +179,205 @@ test('loads flat and nested skills with colon namespaces', async () => {
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
clearSkillCaches()
|
||||
restoreConfigDirEnv(originalConfigDir)
|
||||
setFsImplementation(originalFs)
|
||||
restoreSettingState(originalSettingsState)
|
||||
clearSkillAndConfigCaches()
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.serial('prefers .openclaude project skills over legacy .claude skills with the same name', async () => {
|
||||
await acquireSharedMutationLock('loadSkillsDir.test.ts')
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
|
||||
const cwd = join(configDir, 'workspace')
|
||||
const originalConfigDir = {
|
||||
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR,
|
||||
configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(),
|
||||
}
|
||||
const originalSettingsState = enableUserAndProjectSettingSources()
|
||||
const originalFs = setRealFilesystemForTest()
|
||||
|
||||
try {
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
writeSkill(cwd, 'shared', {
|
||||
configDirName: '.claude',
|
||||
description: 'legacy project skill',
|
||||
})
|
||||
writeSkill(cwd, 'shared', {
|
||||
configDirName: '.openclaude',
|
||||
description: 'native project skill',
|
||||
})
|
||||
|
||||
setConfigDirEnv(configDir)
|
||||
clearSkillAndConfigCaches()
|
||||
|
||||
const skills = await getSkillDirCommands(cwd)
|
||||
const sharedSkills = skills.filter(
|
||||
skill => skill.type === 'prompt' && skill.name === 'shared',
|
||||
)
|
||||
|
||||
assert.equal(sharedSkills.length, 2)
|
||||
assert.equal(sharedSkills[0]?.type, 'prompt')
|
||||
assert.equal(sharedSkills[0]?.description, 'native project skill')
|
||||
assert.match(sharedSkills[0]?.skillRoot ?? '', /\.openclaude/)
|
||||
} finally {
|
||||
restoreConfigDirEnv(originalConfigDir)
|
||||
setFsImplementation(originalFs)
|
||||
try {
|
||||
clearSkillAndConfigCaches()
|
||||
restoreSettingState(originalSettingsState)
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.serial('loads persisted registry trust metadata from skill.json', async () => {
|
||||
await acquireSharedMutationLock('loadSkillsDir.test.ts')
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
|
||||
const cwd = join(configDir, 'workspace')
|
||||
const originalConfigDir = {
|
||||
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR,
|
||||
configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(),
|
||||
}
|
||||
const originalSettingsState = enableUserAndProjectSettingSources()
|
||||
const originalFs = setRealFilesystemForTest()
|
||||
|
||||
try {
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
const skillDir = join(cwd, '.openclaude', 'skills', 'registry-skill')
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
`---\ndescription: Registry skill\n---\n# Registry Skill\n`,
|
||||
'utf8',
|
||||
)
|
||||
writeFileSync(
|
||||
join(skillDir, 'skill.json'),
|
||||
JSON.stringify({ trust: 'official' }),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
setConfigDirEnv(configDir)
|
||||
clearSkillAndConfigCaches()
|
||||
|
||||
const registrySkill = (await getSkillDirCommands(cwd)).find(skill =>
|
||||
isPromptSkillNamed(skill, 'registry-skill'),
|
||||
)
|
||||
|
||||
assert.equal(registrySkill?.skillTrust, 'official')
|
||||
} finally {
|
||||
try {
|
||||
restoreConfigDirEnv(originalConfigDir)
|
||||
setFsImplementation(originalFs)
|
||||
restoreSettingState(originalSettingsState)
|
||||
clearSkillAndConfigCaches()
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.serial('project skills are ordered before user skills with the same name', async () => {
|
||||
await acquireSharedMutationLock('loadSkillsDir.test.ts')
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
|
||||
const cwd = join(configDir, 'workspace')
|
||||
const originalConfigDir = {
|
||||
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR,
|
||||
configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(),
|
||||
}
|
||||
const originalSettingsState = enableUserAndProjectSettingSources()
|
||||
const originalFs = setRealFilesystemForTest()
|
||||
|
||||
try {
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
setConfigDirEnv(configDir)
|
||||
const userConfigDir = getClaudeConfigHomeDir()
|
||||
writeUserSkill(userConfigDir, 'shared', 'user skill')
|
||||
writeSkill(cwd, 'shared', {
|
||||
configDirName: '.openclaude',
|
||||
description: 'project skill',
|
||||
})
|
||||
|
||||
clearSkillAndConfigCaches()
|
||||
|
||||
const sharedSkills = (await getSkillDirCommands(cwd))
|
||||
.filter(skill => isPromptSkillNamed(skill, 'shared'))
|
||||
.map(skill => ({
|
||||
description: skill.description,
|
||||
source: skill.source,
|
||||
skillRoot: skill.skillRoot,
|
||||
}))
|
||||
|
||||
assert.deepEqual(sharedSkills, [
|
||||
{
|
||||
description: 'project skill',
|
||||
source: 'projectSettings',
|
||||
skillRoot: join(cwd, '.openclaude', 'skills', 'shared'),
|
||||
},
|
||||
{
|
||||
description: 'user skill',
|
||||
source: 'userSettings',
|
||||
skillRoot: join(userConfigDir, 'skills', 'shared'),
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
try {
|
||||
restoreConfigDirEnv(originalConfigDir)
|
||||
setFsImplementation(originalFs)
|
||||
restoreSettingState(originalSettingsState)
|
||||
clearSkillAndConfigCaches()
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.serial('dynamic discovery checks .openclaude skill directories', async () => {
|
||||
await acquireSharedMutationLock('loadSkillsDir.test.ts')
|
||||
const originalFs = setRealFilesystemForTest()
|
||||
const originalArgv = [...process.argv]
|
||||
const originalClaudeCodeSimple = process.env.CLAUDE_CODE_SIMPLE
|
||||
const rootDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
|
||||
const cwd = join(rootDir, 'workspace')
|
||||
const featureDir = join(cwd, 'src', 'feature')
|
||||
|
||||
try {
|
||||
process.argv = process.argv.filter(arg => arg !== '--bare')
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
mkdirSync(featureDir, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd, stdio: 'ignore' })
|
||||
writeSkill(featureDir, 'feature-skill', {
|
||||
configDirName: '.openclaude',
|
||||
})
|
||||
|
||||
assert.deepEqual(getProjectSkillsPaths(featureDir), [
|
||||
join(featureDir, '.claude', 'skills'),
|
||||
join(featureDir, '.openclaude', 'skills'),
|
||||
])
|
||||
} finally {
|
||||
try {
|
||||
process.argv = originalArgv
|
||||
if (originalClaudeCodeSimple === undefined) {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_SIMPLE = originalClaudeCodeSimple
|
||||
}
|
||||
setFsImplementation(originalFs)
|
||||
clearDynamicSkills()
|
||||
rmSync(rootDir, { recursive: true, force: true })
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+90
-42
@@ -52,6 +52,7 @@ import {
|
||||
getProjectDirsUpToHome,
|
||||
loadMarkdownFilesForSubdir,
|
||||
type MarkdownFile,
|
||||
PROJECT_CONFIG_DIR_NAMES,
|
||||
parseSlashCommandToolsFromFrontmatter,
|
||||
} from '../utils/markdownConfigLoader.js'
|
||||
import { parseUserSpecifiedModel } from '../utils/model/model.js'
|
||||
@@ -85,7 +86,7 @@ export function getSkillsPath(
|
||||
case 'userSettings':
|
||||
return join(getClaudeConfigHomeDir(), dir)
|
||||
case 'projectSettings':
|
||||
return `.claude/${dir}`
|
||||
return `.openclaude/${dir}`
|
||||
case 'plugin':
|
||||
return 'plugin'
|
||||
default:
|
||||
@@ -93,6 +94,24 @@ export function getSkillsPath(
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectSkillsPaths(dir: string): string[] {
|
||||
return PROJECT_CONFIG_DIR_NAMES.map(configDirName =>
|
||||
join(dir, configDirName, 'skills'),
|
||||
)
|
||||
}
|
||||
|
||||
function prefersOpenClaudeConfigDir(path: string): number {
|
||||
return path.split(pathSep).includes('.openclaude') ? 0 : 1
|
||||
}
|
||||
|
||||
function compareSkillDirPrecedence(a: string, b: string): number {
|
||||
const depthDelta = b.split(pathSep).length - a.split(pathSep).length
|
||||
if (depthDelta !== 0) {
|
||||
return depthDelta
|
||||
}
|
||||
return prefersOpenClaudeConfigDir(a) - prefersOpenClaudeConfigDir(b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count for a skill based on frontmatter only
|
||||
* (name, description, whenToUse) since full content is only loaded on invocation.
|
||||
@@ -129,6 +148,24 @@ type SkillWithPath = {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
async function readSkillJsonMetadata(
|
||||
skillDirPath: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await getFsImplementation().readFile(join(skillDirPath, 'skill.json'), {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return isPlainObject(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate hooks from frontmatter.
|
||||
* Returns undefined if hooks are not defined or invalid.
|
||||
@@ -283,6 +320,7 @@ export function createSkillCommand({
|
||||
userInvocable,
|
||||
source,
|
||||
baseDir,
|
||||
skillFilePath,
|
||||
loadedFrom,
|
||||
hooks,
|
||||
executionContext,
|
||||
@@ -290,6 +328,7 @@ export function createSkillCommand({
|
||||
paths,
|
||||
effort,
|
||||
shell,
|
||||
skillTrust,
|
||||
}: {
|
||||
skillName: string
|
||||
displayName: string | undefined
|
||||
@@ -306,6 +345,7 @@ export function createSkillCommand({
|
||||
userInvocable: boolean
|
||||
source: PromptCommand['source']
|
||||
baseDir: string | undefined
|
||||
skillFilePath: string | undefined
|
||||
loadedFrom: LoadedFrom
|
||||
hooks: HooksSettings | undefined
|
||||
executionContext: 'inline' | 'fork' | undefined
|
||||
@@ -313,6 +353,7 @@ export function createSkillCommand({
|
||||
paths: string[] | undefined
|
||||
effort: EffortValue | undefined
|
||||
shell: FrontmatterShell | undefined
|
||||
skillTrust: string | undefined
|
||||
}): Command {
|
||||
return {
|
||||
type: 'prompt',
|
||||
@@ -341,6 +382,8 @@ export function createSkillCommand({
|
||||
loadedFrom,
|
||||
hooks,
|
||||
skillRoot: baseDir,
|
||||
skillFilePath,
|
||||
skillTrust,
|
||||
async getPromptForCommand(args, toolUseContext) {
|
||||
let finalContent = baseDir
|
||||
? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
|
||||
@@ -503,7 +546,7 @@ async function findSkillMarkdownFiles(basePath: string): Promise<string[]> {
|
||||
* Loads skills from a /skills/ directory path.
|
||||
* Supports nested directory format: category/skill/SKILL.md
|
||||
*/
|
||||
async function loadSkillsFromSkillsDir(
|
||||
export async function loadSkillsFromSkillsDir(
|
||||
basePath: string,
|
||||
source: SettingSource,
|
||||
): Promise<SkillWithPath[]> {
|
||||
@@ -533,6 +576,7 @@ async function loadSkillsFromSkillsDir(
|
||||
content,
|
||||
skillFilePath,
|
||||
)
|
||||
const skillJsonMetadata = await readSkillJsonMetadata(skillDirPath)
|
||||
|
||||
const skillName = getSkillCommandName(skillFilePath, basePath)
|
||||
const parsed = parseSkillFrontmatterFields(
|
||||
@@ -549,8 +593,13 @@ async function loadSkillsFromSkillsDir(
|
||||
markdownContent,
|
||||
source,
|
||||
baseDir: skillDirPath,
|
||||
skillFilePath,
|
||||
loadedFrom: 'skills',
|
||||
paths,
|
||||
skillTrust:
|
||||
typeof skillJsonMetadata.trust === 'string'
|
||||
? skillJsonMetadata.trust
|
||||
: undefined,
|
||||
}),
|
||||
filePath: skillFilePath,
|
||||
}
|
||||
@@ -690,8 +739,10 @@ async function loadSkillsFromCommandsDir(
|
||||
markdownContent: content,
|
||||
source,
|
||||
baseDir: skillDirectory,
|
||||
skillFilePath: filePath,
|
||||
loadedFrom: 'commands_DEPRECATED',
|
||||
paths: undefined,
|
||||
skillTrust: undefined,
|
||||
}),
|
||||
filePath,
|
||||
})
|
||||
@@ -724,7 +775,10 @@ export const getSkillDirCommands = memoize(
|
||||
async (cwd: string): Promise<Command[]> => {
|
||||
const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills')
|
||||
const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills')
|
||||
const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd)
|
||||
const projectSkillsDirs = getProjectDirsUpToHome(
|
||||
'skills',
|
||||
cwd,
|
||||
).sort(compareSkillDirPrecedence)
|
||||
|
||||
logForDebugging(
|
||||
`Loading skills from: managed=${managedSkillsDir}, user=${userSkillsDir}, project=[${projectSkillsDirs.join(', ')}]`,
|
||||
@@ -748,12 +802,10 @@ export const getSkillDirCommands = memoize(
|
||||
return []
|
||||
}
|
||||
const additionalSkillsNested = await Promise.all(
|
||||
additionalDirs.map(dir =>
|
||||
loadSkillsFromSkillsDir(
|
||||
join(dir, '.claude', 'skills'),
|
||||
'projectSettings',
|
||||
),
|
||||
),
|
||||
additionalDirs
|
||||
.flatMap(getProjectSkillsPaths)
|
||||
.sort(compareSkillDirPrecedence)
|
||||
.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')),
|
||||
)
|
||||
// No dedup needed — explicit dirs, user controls uniqueness.
|
||||
return additionalSkillsNested.flat().map(s => s.skill)
|
||||
@@ -783,12 +835,10 @@ export const getSkillDirCommands = memoize(
|
||||
: Promise.resolve([]),
|
||||
projectSettingsEnabled
|
||||
? Promise.all(
|
||||
additionalDirs.map(dir =>
|
||||
loadSkillsFromSkillsDir(
|
||||
join(dir, '.claude', 'skills'),
|
||||
'projectSettings',
|
||||
),
|
||||
),
|
||||
additionalDirs
|
||||
.flatMap(getProjectSkillsPaths)
|
||||
.sort(compareSkillDirPrecedence)
|
||||
.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
// Legacy commands-as-skills goes through markdownConfigLoader with
|
||||
@@ -801,9 +851,9 @@ export const getSkillDirCommands = memoize(
|
||||
// Flatten and combine all skills
|
||||
const allSkillsWithPaths = [
|
||||
...managedSkills,
|
||||
...userSkills,
|
||||
...projectSkillsNested.flat(),
|
||||
...additionalSkillsNested.flat(),
|
||||
...userSkills,
|
||||
...legacyCommands,
|
||||
]
|
||||
|
||||
@@ -959,30 +1009,30 @@ export async function discoverSkillDirsForPaths(
|
||||
// CWD-level skills are already loaded at startup, so we only discover nested ones
|
||||
// Use prefix+separator check to avoid matching /project-backup when cwd is /project
|
||||
while (currentDir.startsWith(resolvedCwd + pathSep)) {
|
||||
const skillDir = join(currentDir, '.claude', 'skills')
|
||||
|
||||
// Skip if we've already checked this path (hit or miss) — avoids
|
||||
// repeating the same failed stat on every Read/Write/Edit call when
|
||||
// the directory doesn't exist (the common case).
|
||||
if (!dynamicSkillDirs.has(skillDir)) {
|
||||
dynamicSkillDirs.add(skillDir)
|
||||
try {
|
||||
await fs.stat(skillDir)
|
||||
// Skills dir exists. Before loading, check if the containing dir
|
||||
// is gitignored — blocks e.g. node_modules/pkg/.claude/skills from
|
||||
// loading silently. `git check-ignore` handles nested .gitignore,
|
||||
// .git/info/exclude, and global gitignore. Fails open outside a
|
||||
// git repo (exit 128 → false); the invocation-time trust dialog
|
||||
// is the actual security boundary.
|
||||
if (await isPathGitignored(currentDir, resolvedCwd)) {
|
||||
logForDebugging(
|
||||
`[skills] Skipped gitignored skills dir: ${skillDir}`,
|
||||
)
|
||||
continue
|
||||
for (const skillDir of getProjectSkillsPaths(currentDir)) {
|
||||
// Skip if we've already checked this path (hit or miss) — avoids
|
||||
// repeating the same failed stat on every Read/Write/Edit call when
|
||||
// the directory doesn't exist (the common case).
|
||||
if (!dynamicSkillDirs.has(skillDir)) {
|
||||
dynamicSkillDirs.add(skillDir)
|
||||
try {
|
||||
await fs.stat(skillDir)
|
||||
// Skills dir exists. Before loading, check if the containing dir
|
||||
// is gitignored — blocks e.g. node_modules/pkg/.openclaude/skills from
|
||||
// loading silently. `git check-ignore` handles nested .gitignore,
|
||||
// .git/info/exclude, and global gitignore. Fails open outside a
|
||||
// git repo (exit 128 → false); the invocation-time trust dialog
|
||||
// is the actual security boundary.
|
||||
if (await isPathGitignored(currentDir, resolvedCwd)) {
|
||||
logForDebugging(
|
||||
`[skills] Skipped gitignored skills dir: ${skillDir}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
newDirs.push(skillDir)
|
||||
} catch {
|
||||
// Directory doesn't exist — already recorded above, continue
|
||||
}
|
||||
newDirs.push(skillDir)
|
||||
} catch {
|
||||
// Directory doesn't exist — already recorded above, continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,9 +1044,7 @@ export async function discoverSkillDirsForPaths(
|
||||
}
|
||||
|
||||
// Sort by path depth (deepest first) so skills closer to the file take precedence
|
||||
return newDirs.sort(
|
||||
(a, b) => b.split(pathSep).length - a.split(pathSep).length,
|
||||
)
|
||||
return newDirs.sort(compareSkillDirPrecedence)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,8 +61,10 @@ async function readSkillResource(
|
||||
markdownContent,
|
||||
source: 'mcp',
|
||||
baseDir: undefined,
|
||||
skillFilePath: undefined,
|
||||
loadedFrom: 'mcp',
|
||||
paths: undefined,
|
||||
skillTrust: undefined,
|
||||
executionContext: parsed.executionContext,
|
||||
// Security: MCP skills are remote and untrusted. Discard any `hooks`
|
||||
// frontmatter — otherwise the slash-command path would register them as
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
getAdditionalDirectoriesForClaudeMd,
|
||||
getAllowedSettingSources,
|
||||
setAdditionalDirectoriesForClaudeMd,
|
||||
setAllowedSettingSources,
|
||||
} from '../bootstrap/state.js'
|
||||
import type { SettingSource } from '../utils/settings/constants.js'
|
||||
import { resetSettingsCache } from '../utils/settings/settingsCache.js'
|
||||
|
||||
export type SettingSourceState = {
|
||||
additionalDirectories: string[]
|
||||
argv: string[]
|
||||
claudeCodeSimple: string | undefined
|
||||
sources: SettingSource[]
|
||||
}
|
||||
|
||||
export function enableUserAndProjectSettingSources(): SettingSourceState {
|
||||
const originalSources = getAllowedSettingSources()
|
||||
const originalAdditionalDirectories = getAdditionalDirectoriesForClaudeMd()
|
||||
const originalArgv = [...process.argv]
|
||||
const originalClaudeCodeSimple = process.env.CLAUDE_CODE_SIMPLE
|
||||
process.argv = process.argv.filter(arg => arg !== '--bare')
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
setAdditionalDirectoriesForClaudeMd([])
|
||||
setAllowedSettingSources([
|
||||
'userSettings',
|
||||
'projectSettings',
|
||||
'localSettings',
|
||||
'flagSettings',
|
||||
'policySettings',
|
||||
])
|
||||
resetSettingsCache()
|
||||
return {
|
||||
additionalDirectories: originalAdditionalDirectories,
|
||||
argv: originalArgv,
|
||||
claudeCodeSimple: originalClaudeCodeSimple,
|
||||
sources: originalSources,
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreSettingState(original: SettingSourceState): void {
|
||||
process.argv = original.argv
|
||||
if (original.claudeCodeSimple === undefined) {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_SIMPLE = original.claudeCodeSimple
|
||||
}
|
||||
setAdditionalDirectoriesForClaudeMd(original.additionalDirectories)
|
||||
setAllowedSettingSources(original.sources)
|
||||
resetSettingsCache()
|
||||
}
|
||||
@@ -55,6 +55,11 @@ export type PromptCommand = {
|
||||
hooks?: HooksSettings
|
||||
// Base directory for skill resources (used to set CLAUDE_PLUGIN_ROOT environment variable for skill hooks)
|
||||
skillRoot?: string
|
||||
// Source markdown file for disk-backed skills. Used by CLI inspection
|
||||
// commands so showing a skill never has to invoke it.
|
||||
skillFilePath?: string
|
||||
// Trust metadata loaded from installed skill.json files, when present.
|
||||
skillTrust?: string
|
||||
// Execution context: 'inline' (default) or 'fork' (run as sub-agent)
|
||||
// 'inline' = skill content expands into the current conversation
|
||||
// 'fork' = skill runs in a sub-agent with separate context and token budget
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
initializeArc,
|
||||
updateArcPhase,
|
||||
getArcSummary,
|
||||
resetArc
|
||||
resetArc,
|
||||
} from './conversationArc.js'
|
||||
import { setClaudeConfigHomeDirForTesting } from './envUtils.js'
|
||||
import { getGlobalGraph, clearMemoryOnly, resetGlobalGraph } from './knowledgeGraph.js'
|
||||
|
||||
+32
-25
@@ -202,10 +202,34 @@ export function getClaudeConfigHomeDirOverrideForTesting(): string | undefined {
|
||||
return claudeConfigHomeDirOverride
|
||||
}
|
||||
|
||||
// Memoized: 150+ callers, many on hot paths. Keyed off both override env
|
||||
// vars so tests that change either get a fresh value without explicit
|
||||
// cache.clear.
|
||||
export const getClaudeConfigHomeDir = memoize(
|
||||
// Memoized for the default home-dir path: 150+ callers, many on hot paths.
|
||||
// Explicit env overrides and test overrides bypass this cache so runtime
|
||||
// overrides cannot be masked by a previously memoized default path.
|
||||
const getDefaultClaudeConfigHomeDir = memoize(
|
||||
(): string => {
|
||||
const homeDir = homedir()
|
||||
const migrationSucceeded = migrateLegacyClaudeConfigHome({
|
||||
homeDir,
|
||||
})
|
||||
const openClaudeDir = join(homeDir, '.openclaude')
|
||||
const legacyClaudeDir = join(homeDir, '.claude')
|
||||
|
||||
if (
|
||||
!migrationSucceeded &&
|
||||
!pathIsDirectory(openClaudeDir) &&
|
||||
pathExists(legacyClaudeDir)
|
||||
) {
|
||||
return legacyClaudeDir.normalize('NFC')
|
||||
}
|
||||
|
||||
return resolveClaudeConfigHomeDir({
|
||||
homeDir,
|
||||
})
|
||||
},
|
||||
() => homedir(),
|
||||
)
|
||||
|
||||
export const getClaudeConfigHomeDir = Object.assign(
|
||||
(): string => {
|
||||
if (claudeConfigHomeDirOverride) {
|
||||
return claudeConfigHomeDirOverride
|
||||
@@ -219,30 +243,13 @@ export const getClaudeConfigHomeDir = memoize(
|
||||
console.warn(`[openclaude] ${message}`)
|
||||
},
|
||||
})
|
||||
const homeDir = homedir()
|
||||
const migrationSucceeded = migrateLegacyClaudeConfigHome({
|
||||
configDirEnv,
|
||||
homeDir,
|
||||
})
|
||||
const openClaudeDir = join(homeDir, '.openclaude')
|
||||
const legacyClaudeDir = join(homeDir, '.claude')
|
||||
|
||||
if (
|
||||
!configDirEnv &&
|
||||
!migrationSucceeded &&
|
||||
!pathIsDirectory(openClaudeDir) &&
|
||||
pathExists(legacyClaudeDir)
|
||||
) {
|
||||
return legacyClaudeDir.normalize('NFC')
|
||||
if (configDirEnv) {
|
||||
return resolveClaudeConfigHomeDir({ configDirEnv })
|
||||
}
|
||||
|
||||
return resolveClaudeConfigHomeDir({
|
||||
configDirEnv,
|
||||
homeDir,
|
||||
})
|
||||
return getDefaultClaudeConfigHomeDir()
|
||||
},
|
||||
() =>
|
||||
`${claudeConfigHomeDirOverride ?? ''}\0${process.env.OPENCLAUDE_CONFIG_DIR ?? ''}\0${process.env.CLAUDE_CONFIG_DIR ?? ''}`,
|
||||
{ cache: getDefaultClaudeConfigHomeDir.cache },
|
||||
)
|
||||
|
||||
export function getTeamsDir(): string {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as fs from 'fs'
|
||||
import {
|
||||
cp as cpPromise,
|
||||
lstat as lstatPromise,
|
||||
mkdir as mkdirPromise,
|
||||
mkdtemp as mkdtempPromise,
|
||||
open,
|
||||
readdir as readdirPromise,
|
||||
readFile as readFilePromise,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
rm as rmPromise,
|
||||
stat as statPromise,
|
||||
unlink as unlinkPromise,
|
||||
writeFile as writeFilePromise,
|
||||
} from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import * as nodePath from 'path'
|
||||
@@ -29,6 +32,8 @@ export type FsOperations = {
|
||||
existsSync(path: string): boolean
|
||||
/** Gets file stats asynchronously */
|
||||
stat(path: string): Promise<fs.Stats>
|
||||
/** Gets file stats asynchronously without following symlinks */
|
||||
lstat(path: string): Promise<fs.Stats>
|
||||
/** Lists directory contents with file type information asynchronously */
|
||||
readdir(path: string): Promise<fs.Dirent[]>
|
||||
/** Deletes file asynchronously */
|
||||
@@ -42,15 +47,28 @@ export type FsOperations = {
|
||||
): Promise<void>
|
||||
/** Creates directory recursively asynchronously. */
|
||||
mkdir(path: string, options?: { mode?: number }): Promise<void>
|
||||
/** Creates a unique temporary directory asynchronously. */
|
||||
mkdtemp(prefix: string): Promise<string>
|
||||
/** Reads file content as string asynchronously */
|
||||
readFile(path: string, options: { encoding: BufferEncoding }): Promise<string>
|
||||
/** Writes file content asynchronously */
|
||||
writeFile(
|
||||
path: string,
|
||||
data: string | Buffer,
|
||||
options?: { encoding?: BufferEncoding },
|
||||
): Promise<void>
|
||||
/** Renames/moves file asynchronously */
|
||||
rename(oldPath: string, newPath: string): Promise<void>
|
||||
/** Copies a file or directory tree asynchronously */
|
||||
cp(
|
||||
source: string,
|
||||
destination: string,
|
||||
options?: { recursive?: boolean },
|
||||
options?: {
|
||||
recursive?: boolean
|
||||
errorOnExist?: boolean
|
||||
force?: boolean
|
||||
preserveTimestamps?: boolean
|
||||
},
|
||||
): Promise<void>
|
||||
/** Gets file stats */
|
||||
statSync(path: string): fs.Stats
|
||||
@@ -402,6 +420,10 @@ export const NodeFsOperations: FsOperations = {
|
||||
return statPromise(fsPath)
|
||||
},
|
||||
|
||||
async lstat(fsPath) {
|
||||
return lstatPromise(fsPath)
|
||||
},
|
||||
|
||||
async readdir(fsPath) {
|
||||
return readdirPromise(fsPath, { withFileTypes: true })
|
||||
},
|
||||
@@ -439,10 +461,18 @@ export const NodeFsOperations: FsOperations = {
|
||||
}
|
||||
},
|
||||
|
||||
async mkdtemp(prefix) {
|
||||
return mkdtempPromise(prefix)
|
||||
},
|
||||
|
||||
async readFile(fsPath, options) {
|
||||
return readFilePromise(fsPath, { encoding: options.encoding })
|
||||
},
|
||||
|
||||
async writeFile(fsPath, data, options) {
|
||||
await writeFilePromise(fsPath, data, options)
|
||||
},
|
||||
|
||||
async rename(oldPath, newPath) {
|
||||
return renamePromise(oldPath, newPath)
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, afterAll } from 'bun:test'
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
|
||||
import {
|
||||
addGlobalEntity,
|
||||
addGlobalSummary,
|
||||
@@ -18,8 +18,7 @@ import { getFsImplementation } from './fsOperations.js'
|
||||
describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalOrama = process.env.OPENCLAUDE_KNOWLEDGE_ORAMA
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-'))
|
||||
const cwd = getFsImplementation().cwd()
|
||||
let configDir: string | undefined
|
||||
|
||||
const removeDirWithRetry = (dir: string) => {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
@@ -47,6 +46,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
process.env.OPENCLAUDE_KNOWLEDGE_ORAMA = '1'
|
||||
setClaudeConfigHomeDirForTesting(configDir)
|
||||
@@ -69,14 +69,18 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
}
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
const dirToRemove = configDir
|
||||
configDir = undefined
|
||||
try {
|
||||
if (dirToRemove) {
|
||||
removeDirWithRetry(dirToRemove)
|
||||
}
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
removeDirWithRetry(configDir)
|
||||
})
|
||||
|
||||
it('handles high-volume entity insertion (Stress Test)', async () => {
|
||||
const count = 50
|
||||
|
||||
@@ -109,6 +113,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
// 1. Create a valid DB
|
||||
await addGlobalEntity('type', 'valid', { val: '1' })
|
||||
const { getOramaPersistencePath } = await import('./knowledgeGraph.js')
|
||||
const cwd = getFsImplementation().cwd()
|
||||
const oramaPath = getOramaPersistencePath(cwd)
|
||||
expect(existsSync(oramaPath)).toBe(true)
|
||||
|
||||
@@ -126,9 +131,10 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
|
||||
// 5. Verify the corrupted file was moved
|
||||
const { readdirSync } = await import('fs')
|
||||
const oramaDir = dirname(oramaPath)
|
||||
expect(existsSync(oramaDir)).toBe(true)
|
||||
// Search from the actual persistence directory for the corrupted file.
|
||||
// Search recursively from the actual Orama location. The config home can
|
||||
// be redirected by other tests, but the persisted file path is authoritative.
|
||||
const projectDir = dirname(oramaPath)
|
||||
expect(existsSync(projectDir)).toBe(true)
|
||||
const findCorrupted = (dir: string): boolean => {
|
||||
const entries = readdirSync(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
@@ -140,7 +146,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
}
|
||||
return false
|
||||
}
|
||||
expect(findCorrupted(oramaDir)).toBe(true)
|
||||
expect(findCorrupted(projectDir)).toBe(true)
|
||||
})
|
||||
|
||||
it('maintains consistency between JSON and Orama', async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export const CLAUDE_CONFIG_DIRECTORIES = [
|
||||
|
||||
export type ClaudeConfigDirectory = (typeof CLAUDE_CONFIG_DIRECTORIES)[number]
|
||||
|
||||
const PROJECT_CONFIG_DIR_NAMES = ['.claude', '.openclaude'] as const
|
||||
export const PROJECT_CONFIG_DIR_NAMES = ['.claude', '.openclaude'] as const
|
||||
|
||||
// Concurrency cap for parallel readFile + parseFrontmatter when loading
|
||||
// commands/agents/skills/etc. With unbounded Promise.all, a directory holding
|
||||
|
||||
@@ -917,7 +917,7 @@ test('saveProfileFile writes a profile that loadProfileFile can read back', () =
|
||||
}
|
||||
})
|
||||
|
||||
test('saveProfileFile defaults to user config instead of the working directory', () => {
|
||||
test('saveProfileFile defaults to user config instead of the working directory', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-workspace-profile-'))
|
||||
const configRoot = mkdtempSync(join(tmpdir(), 'openclaude-config-profile-'))
|
||||
const configDir = join(configRoot, 'config')
|
||||
@@ -956,7 +956,7 @@ test('saveProfileFile defaults to user config instead of the working directory',
|
||||
}
|
||||
})
|
||||
|
||||
test('loadProfileFile keeps project-local files as a legacy fallback', () => {
|
||||
test('loadProfileFile keeps project-local files as a legacy fallback', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-legacy-profile-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-empty-config-profile-'))
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
@@ -989,7 +989,7 @@ test('loadProfileFile keeps project-local files as a legacy fallback', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('loadProfileFile does not fall back when user config profile is invalid', () => {
|
||||
test('loadProfileFile does not fall back when user config profile is invalid', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-invalid-profile-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-invalid-config-profile-'))
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
@@ -1023,7 +1023,7 @@ test('loadProfileFile does not fall back when user config profile is invalid', (
|
||||
}
|
||||
})
|
||||
|
||||
test('deleteProfileFile clears the default profile and legacy workspace fallback', () => {
|
||||
test('deleteProfileFile clears the default profile and legacy workspace fallback', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-delete-profile-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-delete-config-profile-'))
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import * as platformPath from 'path'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
@@ -18,6 +19,7 @@ let executeConfigChangeHooksImpl = async () => hookResults
|
||||
let executeConfigChangeHooks = mock(async () => hookResults)
|
||||
let dynamicSkillsLoadedCallback: (() => void) | undefined
|
||||
let unregisterDynamicSkillsLoaded = mock(() => {})
|
||||
let additionalDirectories: string[] = []
|
||||
let getSkillsPathImpl = (_source: string, _dir: string) => ''
|
||||
let statImpl = mock(async (_path: string) => {})
|
||||
let chokidarWatch = mock(() => ({
|
||||
@@ -36,6 +38,7 @@ function installMocks(): void {
|
||||
executeConfigChangeHooks = mock(() => executeConfigChangeHooksImpl())
|
||||
dynamicSkillsLoadedCallback = undefined
|
||||
unregisterDynamicSkillsLoaded = mock(() => {})
|
||||
additionalDirectories = []
|
||||
getSkillsPathImpl = () => ''
|
||||
statImpl = mock(async () => {})
|
||||
chokidarWatch = mock(() => ({
|
||||
@@ -55,6 +58,7 @@ async function importFreshModule(): Promise<SkillChangeDetectorModule> {
|
||||
getFsImplementation: () => ({
|
||||
stat: statImpl,
|
||||
}),
|
||||
getAdditionalDirectoriesForClaudeMd: () => additionalDirectories,
|
||||
getSkillsPath: (source: string, dir: string) =>
|
||||
getSkillsPathImpl(source, dir),
|
||||
hasBlockingResult: (results: { blocked: boolean }[]) =>
|
||||
@@ -115,12 +119,16 @@ describe('skillChangeDetector reload batching', () => {
|
||||
source === 'userSettings' && dir === 'skills' ? '/tmp/skills' : ''
|
||||
|
||||
let resolveStat: (() => void) | undefined
|
||||
statImpl = mock(
|
||||
async () =>
|
||||
await new Promise<void>(resolve => {
|
||||
resolveStat = resolve
|
||||
}),
|
||||
)
|
||||
let blockedFirstStat = false
|
||||
statImpl = mock(async () => {
|
||||
if (blockedFirstStat) {
|
||||
throw new Error('missing')
|
||||
}
|
||||
blockedFirstStat = true
|
||||
await new Promise<void>(resolve => {
|
||||
resolveStat = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const initializePromise = detector.initialize()
|
||||
await sleep(0)
|
||||
@@ -131,6 +139,58 @@ describe('skillChangeDetector reload batching', () => {
|
||||
expect(chokidarWatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('watches native and legacy project/add-dir skill paths that the loader reads', async () => {
|
||||
const detector = await importFreshModule()
|
||||
const addDir = platformPath.join('/tmp', 'openclaude-add-dir')
|
||||
const userSkillsPath = platformPath.join('/tmp', 'user', 'skills')
|
||||
const userCommandsPath = platformPath.join('/tmp', 'user', 'commands')
|
||||
additionalDirectories = [addDir]
|
||||
getSkillsPathImpl = (source, dir) => {
|
||||
if (source === 'userSettings' && dir === 'skills') return userSkillsPath
|
||||
if (source === 'userSettings' && dir === 'commands') return userCommandsPath
|
||||
return ''
|
||||
}
|
||||
statImpl = mock(async () => {})
|
||||
|
||||
await detector.initialize()
|
||||
|
||||
expect(chokidarWatch).toHaveBeenCalledTimes(1)
|
||||
const [watchedPaths = [], watchOptions = {}] = (
|
||||
chokidarWatch.mock.calls as unknown as Array<
|
||||
[string[] | undefined, { depth?: number } | undefined]
|
||||
>
|
||||
)[0] ?? []
|
||||
expect(watchedPaths).toContain(userSkillsPath)
|
||||
expect(watchedPaths).toContain(userCommandsPath)
|
||||
expect(watchedPaths).toContain(
|
||||
platformPath.join(addDir, '.claude', 'skills'),
|
||||
)
|
||||
expect(watchedPaths).toContain(
|
||||
platformPath.join(addDir, '.openclaude', 'skills'),
|
||||
)
|
||||
expect(
|
||||
watchedPaths.some(path =>
|
||||
path.endsWith(platformPath.join('.claude', 'skills')),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
watchedPaths.some(path =>
|
||||
path.endsWith(platformPath.join('.openclaude', 'skills')),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
watchedPaths.some(path =>
|
||||
path.endsWith(platformPath.join('.claude', 'commands')),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
watchedPaths.some(path =>
|
||||
path.endsWith(platformPath.join('.openclaude', 'commands')),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(watchOptions.depth).toBeUndefined()
|
||||
})
|
||||
|
||||
test('batches rapid reload requests into one hook/cache clear/notification', async () => {
|
||||
const detector = await importFreshModule()
|
||||
await detector.resetForTesting({ reloadDebounce: 5, reloadCooldown: 20 })
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getSkillsPath,
|
||||
onDynamicSkillsLoaded,
|
||||
} from '../../skills/loadSkillsDir.js'
|
||||
import { PROJECT_CONFIG_DIR_NAMES } from '../markdownConfigLoader.js'
|
||||
import { resetSentSkillNames } from '../attachments.js'
|
||||
import { registerCleanup } from '../cleanupRegistry.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
@@ -92,6 +93,7 @@ const defaultDependencies = {
|
||||
clearCommandsCache,
|
||||
executeConfigChangeHooks,
|
||||
getFsImplementation,
|
||||
getAdditionalDirectoriesForClaudeMd,
|
||||
getSkillsPath,
|
||||
hasBlockingResult,
|
||||
onDynamicSkillsLoaded,
|
||||
@@ -140,7 +142,6 @@ export async function initialize(): Promise<void> {
|
||||
watcher = dependencies.watch(paths, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
depth: 2, // Skills use skill-name/SKILL.md format
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold:
|
||||
testOverrides?.stabilityThreshold ?? FILE_STABILITY_THRESHOLD_MS,
|
||||
@@ -204,71 +205,41 @@ async function getWatchablePaths(): Promise<string[]> {
|
||||
const fs = dependencies.getFsImplementation()
|
||||
const paths: string[] = []
|
||||
|
||||
// User skills directory (~/.openclaude/skills)
|
||||
const userSkillsPath = dependencies.getSkillsPath('userSettings', 'skills')
|
||||
if (userSkillsPath) {
|
||||
async function pushIfExists(path: string): Promise<void> {
|
||||
try {
|
||||
await fs.stat(userSkillsPath)
|
||||
paths.push(userSkillsPath)
|
||||
await fs.stat(path)
|
||||
paths.push(path)
|
||||
} catch {
|
||||
// Path doesn't exist, skip it
|
||||
}
|
||||
}
|
||||
|
||||
// User skills directory (~/.openclaude/skills)
|
||||
const userSkillsPath = dependencies.getSkillsPath('userSettings', 'skills')
|
||||
if (userSkillsPath) {
|
||||
await pushIfExists(userSkillsPath)
|
||||
}
|
||||
|
||||
// User commands directory (~/.openclaude/commands)
|
||||
const userCommandsPath = dependencies.getSkillsPath(
|
||||
'userSettings',
|
||||
'commands',
|
||||
)
|
||||
if (userCommandsPath) {
|
||||
try {
|
||||
await fs.stat(userCommandsPath)
|
||||
paths.push(userCommandsPath)
|
||||
} catch {
|
||||
// Path doesn't exist, skip it
|
||||
}
|
||||
await pushIfExists(userCommandsPath)
|
||||
}
|
||||
|
||||
// Project skills directory (.claude/skills)
|
||||
const projectSkillsPath = dependencies.getSkillsPath(
|
||||
'projectSettings',
|
||||
'skills',
|
||||
)
|
||||
if (projectSkillsPath) {
|
||||
try {
|
||||
// For project settings, resolve to absolute path
|
||||
const absolutePath = platformPath.resolve(projectSkillsPath)
|
||||
await fs.stat(absolutePath)
|
||||
paths.push(absolutePath)
|
||||
} catch {
|
||||
// Path doesn't exist, skip it
|
||||
}
|
||||
}
|
||||
|
||||
// Project commands directory (.claude/commands)
|
||||
const projectCommandsPath = dependencies.getSkillsPath(
|
||||
'projectSettings',
|
||||
'commands',
|
||||
)
|
||||
if (projectCommandsPath) {
|
||||
try {
|
||||
// For project settings, resolve to absolute path
|
||||
const absolutePath = platformPath.resolve(projectCommandsPath)
|
||||
await fs.stat(absolutePath)
|
||||
paths.push(absolutePath)
|
||||
} catch {
|
||||
// Path doesn't exist, skip it
|
||||
}
|
||||
// Project skills/commands directories. The loader accepts both native
|
||||
// .openclaude and legacy .claude project paths, so live reload watches both.
|
||||
for (const configDirName of PROJECT_CONFIG_DIR_NAMES) {
|
||||
await pushIfExists(platformPath.resolve(configDirName, 'skills'))
|
||||
await pushIfExists(platformPath.resolve(configDirName, 'commands'))
|
||||
}
|
||||
|
||||
// Additional directories (--add-dir) skills
|
||||
for (const dir of getAdditionalDirectoriesForClaudeMd()) {
|
||||
const additionalSkillsPath = platformPath.join(dir, '.claude', 'skills')
|
||||
try {
|
||||
await fs.stat(additionalSkillsPath)
|
||||
paths.push(additionalSkillsPath)
|
||||
} catch {
|
||||
// Path doesn't exist, skip it
|
||||
for (const dir of dependencies.getAdditionalDirectoriesForClaudeMd()) {
|
||||
for (const configDirName of PROJECT_CONFIG_DIR_NAMES) {
|
||||
await pushIfExists(platformPath.join(dir, configDirName, 'skills'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user