Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ openclaude skills show <name> Show details for an installed
openclaude skills validate <path> Validate a local skill directory
openclaude skills install <idOrUrlOrPath> [options] Install a skill
openclaude skills remove <name> [--global] Remove an installed skill
openclaude skills verify [options] Check installed skills for revocations and, with eyebrow, drift
```

## Install from the registry
Expand Down Expand Up @@ -69,6 +70,22 @@ $ openclaude skills list
ci-fix enabled Diagnoses and fixes CI pipeline failures.
```

## Verify installed skills

`verify` reads the skills installed in the project's `.openclaude/skills` and in your user skills directory. Skills that `list` shows from plugins, MCP servers, or `--add-dir` directories are outside it. For each skill it reads the id, version, and digest from `skill.json` as written on disk, and matches them against the registry's `revocations.json` with the rule the installer uses. The digest is the registry pin recorded at install, not a hash of the current files, so `verify` says nothing about the contents on disk and trusts `skill.json` as it finds it. A skill whose `skill.json` has no id and no digest, such as one written by hand, is listed as skipped. A local install that copied a `skill.json` keeps that file's id and digest, so `verify` matches it like a registry install. The list comes from the default registry, from `--registry`, or from the environment variables named above. A list that cannot be read fails the command, the same as an install does.

```text
$ openclaude skills verify
Found 2 installed skills. Revocation list: https://raw.githubusercontent.com/Gitlawb/openclaude-skills/main/revocations.json
ci-fix ok
hand-made skipped (no registry metadata)
eyebrow not found; install it to check skill contents against a lockfile (see docs/skills.md).
```

When an executable named `eyebrow` is on `PATH`, or `OPENCLAUDE_EYEBROW_BIN` names one, and the project has a lockfile, `verify` runs `eyebrow verify --path . --lockfile eyebrowlock.json` in the project directory, shows its output, and prints the path of the binary it ran. `--lockfile` names another lockfile and `--policy` adds `--ci` and the policy file to the eyebrow run. `verify` does not check the eyebrow version. An eyebrow older than 0.4.4 finds no OpenClaude skills and reports every locked skill as removed, so use 0.4.6 or newer, as the next section explains. That section also shows how to create the lockfile and how to read the verdict.

Exit codes: `1` when a skill is revoked, when the revocation list cannot be read, or when eyebrow cannot be started; otherwise the eyebrow exit code; `0` when both checks pass, and also when eyebrow is absent or the lockfile is missing, in which case `verify` prints what to install or run.

## Check installed skills after install

[eyebrow](https://github.com/alexverify/eyebrow) is a separate, MIT-licensed single binary that records a hash of every skill, MCP server, hook, and rule it finds across coding tools in a lockfile you commit, and reports what changed since. Discovery of OpenClaude skills in the project's `.openclaude/skills` and in `~/.openclaude/skills` shipped in eyebrow 0.4.4, and 0.4.5 added the egress fingerprint used below; see the [changelog](https://github.com/alexverify/eyebrow/blob/main/CHANGELOG.md). Use 0.4.6 or newer: in 0.4.5 a skill folder that is a symlink appeared in the inventory with no hashed files, so a clean `verify` said nothing about the contents behind the link. 0.4.6 hashes the linked contents.
Expand All @@ -86,6 +103,8 @@ Check them again at any time, or in CI:
eyebrow verify --path . --lockfile eyebrowlock.json --ci
```

`openclaude skills verify` runs this command for you when eyebrow is installed.

`verify` exit codes: `0` clean under the active policy, which includes content changes the policy permits; `1` rejected drift or a policy violation; `2` usage error, such as a bad flag; `3` internal or I/O error, such as a missing lockfile. In CI, treat `1` as a review task and `2` or `3` as a broken job. On the edited skill above, with no policy file:

```text
Expand Down
1 change: 1 addition & 0 deletions src/cli/handlers/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { validateSkillPath } from './skillsValidation.js'

export { skillsInstallHandler } from './skillsInstall.js'
export { skillsVerifyHandler } from './skillsVerify.js'

type SkillCommand = SkillListCommand
type ListOptions = { json?: boolean }
Expand Down
44 changes: 43 additions & 1 deletion src/cli/handlers/skillsCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ type SkillsCliOptions = {
global?: boolean
help?: boolean
json?: boolean
lockfile?: string
policy?: string
registry?: string
sha256?: string
}
Expand Down Expand Up @@ -85,7 +87,13 @@ Commands:
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`
remove <name> [--global] Remove an installed skill
verify [options] Check installed skills for revocations and, with eyebrow, drift

Verify options:
--registry <urlOrPath> Registry whose revocations.json to read
--lockfile <path> eyebrow lockfile (default: eyebrowlock.json)
--policy <path> eyebrow policy file; adds --ci to the eyebrow run`

function parseSkillsCliArgs(args: string[]): {
options: SkillsCliOptions
Expand Down Expand Up @@ -119,6 +127,20 @@ function parseSkillsCliArgs(args: string[]): {
}
options.sha256 = value
index += 1
} else if (arg === '--lockfile') {
const value = args[index + 1]
if (!value || value.startsWith('--')) {
return { options, positionals, error: '--lockfile requires a value.' }
}
options.lockfile = value
index += 1
} else if (arg === '--policy') {
const value = args[index + 1]
if (!value || value.startsWith('--')) {
return { options, positionals, error: '--policy requires a value.' }
}
options.policy = value
index += 1
} else if (arg?.startsWith('--registry=')) {
const value = arg.slice('--registry='.length)
if (!value) {
Expand All @@ -131,6 +153,18 @@ function parseSkillsCliArgs(args: string[]): {
return { options, positionals, error: '--sha256 requires a value.' }
}
options.sha256 = value
} else if (arg?.startsWith('--lockfile=')) {
const value = arg.slice('--lockfile='.length)
if (!value) {
return { options, positionals, error: '--lockfile requires a value.' }
}
options.lockfile = value
} else if (arg?.startsWith('--policy=')) {
const value = arg.slice('--policy='.length)
if (!value) {
return { options, positionals, error: '--policy requires a value.' }
}
options.policy = value
} else if (TRAILING_GLOBAL_BOOLEAN_FLAGS.has(arg)) {
continue
} else if (TRAILING_GLOBAL_VALUE_FLAGS.has(arg)) {
Expand Down Expand Up @@ -217,6 +251,7 @@ export async function runSkillsCli(args: string[]): Promise<void> {
skillsRemoveHandler,
skillsShowHandler,
skillsValidateHandler,
skillsVerifyHandler,
} = await import('./skills.js')

await runSkillsCliAction(async () => {
Expand Down Expand Up @@ -260,6 +295,13 @@ export async function runSkillsCli(args: string[]): Promise<void> {
await skillsRemoveHandler(name, { global: options.global })
break
}
case 'verify':
await skillsVerifyHandler({
registry: options.registry,
lockfile: options.lockfile,
policy: options.policy,
})
break
default:
console.error(`Unknown skills command: ${subcommand}`)
process.exit(1)
Expand Down
48 changes: 27 additions & 21 deletions src/cli/handlers/skillsInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type RegistryEntriesResult = {
registrySource: string
}

type SkillRevocation = {
export type SkillRevocation = {
id?: unknown
version?: unknown
sha256?: unknown
Expand Down Expand Up @@ -167,20 +167,30 @@ async function readSourceText(source: string): Promise<string> {
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
}
/**
* The registry an install or verify reads: the given value, then the
* OPENCLAUDE_SKILLS_REGISTRY_URL env var, then the default registry. A
* local directory resolves to the registry.json inside it.
*/
export async function resolveRegistrySource(registry?: string): Promise<string> {
const source =
registry ??
process.env.OPENCLAUDE_SKILLS_REGISTRY_URL ??
DEFAULT_SKILLS_REGISTRY_URL
if (isUrl(source)) {
return source
}
const resolved = resolve(source)
try {
const sourceStats = await getFsImplementation().stat(resolved)
return sourceStats.isDirectory() ? join(resolved, 'registry.json') : resolved
} catch {
return resolved
}
}

async function readRegistryEntries(source?: string): Promise<RegistryEntriesResult> {
const registrySource = await resolveRegistrySource(source)
const raw = await readSourceText(registrySource)
const parsed = JSON.parse(raw) as unknown
return {
Expand Down Expand Up @@ -209,7 +219,7 @@ function resolveRegistryEntrySource(
* OPENCLAUDE_SKILLS_REVOCATIONS_URL env var when set, otherwise a
* revocations.json sibling of the registry (URL or local file).
*/
function resolveRevocationsSource(registrySource: string): string {
export function resolveRevocationsSource(registrySource: string): string {
return (
process.env.OPENCLAUDE_SKILLS_REVOCATIONS_URL ??
resolveRegistryEntrySource('revocations.json', registrySource)
Expand Down Expand Up @@ -279,7 +289,7 @@ function parseRevocation(
* list revokes nothing; any other read, parse, or schema failure throws
* so the install fails closed.
*/
async function readRevocations(registrySource: string): Promise<SkillRevocation[]> {
export async function readRevocations(registrySource: string): Promise<SkillRevocation[]> {
const source = resolveRevocationsSource(registrySource)
let raw: string
try {
Expand Down Expand Up @@ -311,11 +321,7 @@ 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 registry = await readRegistryEntries(options.registry)
const entry = registry.entries.find(
candidate =>
candidate.id === idOrName ||
Expand Down Expand Up @@ -395,7 +401,7 @@ function assertSha256Matches(text: string, expectedSha256: string, spec: string)
* specifies must match (id alone covers all versions, sha256 covers the
* exact content under any id).
*/
function revocationApplies(
export function revocationApplies(
revocation: SkillRevocation,
skill: { id?: string; version?: string; sha256: string },
): boolean {
Expand Down
Loading
Loading