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:
Anandan
2026-07-06 11:08:17 +08:00
committed by GitHub
co-authored by OpenClaude Worker 3 jatmn
parent 6038681fc8
commit 214ee3dd2e
26 changed files with 3611 additions and 164 deletions
-1
View File
@@ -1734,4 +1734,3 @@ export function resetAllReplayIndexBuilders(): Array<{
STATE.replayIndexBuilders.clear()
return entries
}
+897
View File
@@ -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 530 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 530 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()
}
})
+228
View File
@@ -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)}.`)
}
+269
View File
@@ -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)
}
+611
View File
@@ -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,
})
}
}
}
+218
View File
@@ -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')
}
+45
View File
@@ -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.`
}
+210
View File
@@ -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
View File
@@ -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
View File
@@ -506,12 +506,12 @@ const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
])
return [
...bundledSkills,
...builtinPluginSkills,
...skillDirCommands,
...workflowCommands,
...pluginCommands,
...pluginSkills,
...bundledSkills,
...builtinPluginSkills,
...COMMANDS(),
]
})
+218
View File
@@ -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
View File
@@ -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 {
+31
View File
@@ -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.
+317 -16
View File
@@ -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
View File
@@ -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)
}
/**
+2
View File
@@ -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
+51
View File
@@ -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()
}
+5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 {
+31 -1
View File
@@ -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)
},
+18 -12
View File
@@ -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 () => {
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+66 -6
View File
@@ -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 })
+20 -49
View File
@@ -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'))
}
}