Skip to content

Commit 732be43

Browse files
committed
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
1 parent 1a0e39b commit 732be43

4 files changed

Lines changed: 249 additions & 2 deletions

File tree

src/cli/handlers/skills.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
getCommands,
1111
type Command,
1212
} from '../../commands.js'
13-
import { initBundledSkills } from '../../skills/bundled/index.js'
1413
import { getCwd } from '../../utils/cwd.js'
1514
import { getDisplayPath } from '../../utils/file.js'
1615
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
@@ -47,7 +46,6 @@ function isSkillCommand(cmd: Command): cmd is SkillCommand {
4746
}
4847

4948
function loadSkills(): Promise<SkillCommand[]> {
50-
initBundledSkills()
5149
return getCommands(getCwd()).then(commands => commands.filter(isSkillCommand))
5250
}
5351

src/cli/handlers/skillsCli.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
type SkillsCliOptions = {
2+
force?: boolean
3+
global?: boolean
4+
help?: boolean
5+
json?: boolean
6+
registry?: string
7+
}
8+
9+
const SKILLS_HELP = `Usage: openclaude skills <command> [options]
10+
11+
Commands:
12+
list [--json] List installed skills
13+
show <name> Show details for an installed skill
14+
validate <path> Validate a local skill directory
15+
install <idOrUrlOrPath> [options] Install a skill
16+
remove <name> [--global] Remove an installed skill`
17+
18+
function parseSkillsCliArgs(args: string[]): {
19+
options: SkillsCliOptions
20+
positionals: string[]
21+
error?: string
22+
} {
23+
const options: SkillsCliOptions = {}
24+
const positionals: string[] = []
25+
26+
for (let index = 0; index < args.length; index += 1) {
27+
const arg = args[index]
28+
if (arg === '--json') {
29+
options.json = true
30+
} else if (arg === '--global') {
31+
options.global = true
32+
} else if (arg === '--force') {
33+
options.force = true
34+
} else if (arg === '--help' || arg === '-h') {
35+
options.help = true
36+
} else if (arg === '--registry') {
37+
const value = args[index + 1]
38+
if (!value || value.startsWith('--')) {
39+
return { options, positionals, error: '--registry requires a value.' }
40+
}
41+
options.registry = value
42+
index += 1
43+
} else if (arg?.startsWith('--registry=')) {
44+
const value = arg.slice('--registry='.length)
45+
if (!value) {
46+
return { options, positionals, error: '--registry requires a value.' }
47+
}
48+
options.registry = value
49+
} else if (arg?.startsWith('--')) {
50+
return { options, positionals, error: `Unknown skills option: ${arg}` }
51+
} else if (arg) {
52+
positionals.push(arg)
53+
}
54+
}
55+
56+
return { options, positionals }
57+
}
58+
59+
export async function runSkillsCli(args: string[]): Promise<void> {
60+
const subcommand = args[1] ?? 'list'
61+
const { options, positionals, error } = parseSkillsCliArgs(args.slice(2))
62+
if (error) {
63+
console.error(error)
64+
process.exit(1)
65+
}
66+
if (subcommand === '--help' || subcommand === '-h' || options.help) {
67+
console.log(SKILLS_HELP)
68+
process.exit(0)
69+
}
70+
71+
const {
72+
skillsInstallHandler,
73+
skillsListHandler,
74+
skillsRemoveHandler,
75+
skillsShowHandler,
76+
skillsValidateHandler,
77+
} = await import('./skills.js')
78+
79+
switch (subcommand) {
80+
case 'list':
81+
await skillsListHandler({ json: options.json })
82+
break
83+
case 'show': {
84+
const name = positionals[0]
85+
if (!name) {
86+
console.error('Skill name is required.')
87+
process.exit(1)
88+
}
89+
await skillsShowHandler(name)
90+
break
91+
}
92+
case 'validate': {
93+
const path = positionals[0]
94+
if (!path) {
95+
console.error('Skill path is required.')
96+
process.exit(1)
97+
}
98+
await skillsValidateHandler(path)
99+
break
100+
}
101+
case 'install': {
102+
const idOrUrlOrPath = positionals[0]
103+
if (!idOrUrlOrPath) {
104+
console.error('Skill ID, URL, or path is required.')
105+
process.exit(1)
106+
}
107+
await skillsInstallHandler(idOrUrlOrPath, options)
108+
break
109+
}
110+
case 'remove': {
111+
const name = positionals[0]
112+
if (!name) {
113+
console.error('Skill name is required.')
114+
process.exit(1)
115+
}
116+
await skillsRemoveHandler(name, { global: options.global })
117+
break
118+
}
119+
default:
120+
console.error(`Unknown skills command: ${subcommand}`)
121+
process.exit(1)
122+
}
123+
124+
process.exit(process.exitCode ?? 0)
125+
}

src/entrypoints/cli.skills.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { expect, test } from 'bun:test'
2+
import { mkdirSync, mkdtempSync } from 'fs'
3+
import { tmpdir } from 'os'
4+
import { join, resolve } from 'path'
5+
6+
const repoRoot = resolve(import.meta.dir, '..', '..')
7+
const cliEntrypoint = join(repoRoot, 'src', 'entrypoints', 'cli.tsx')
8+
9+
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
10+
return new Response(stream).text()
11+
}
12+
13+
async function runSkillsList(args: string[]): Promise<{
14+
exitCode: number
15+
stderr: string
16+
stdout: string
17+
}> {
18+
const root = mkdtempSync(join(tmpdir(), 'openclaude-skills-cli-'))
19+
const projectDir = join(root, 'project')
20+
const homeDir = join(root, 'home')
21+
const configDir = join(root, 'config')
22+
mkdirSync(projectDir)
23+
mkdirSync(homeDir)
24+
25+
const proc = Bun.spawn({
26+
cmd: [process.execPath, cliEntrypoint, ...args],
27+
cwd: projectDir,
28+
env: {
29+
...process.env,
30+
CLAUDE_CODE_USE_OPENAI: '1',
31+
OPENAI_BASE_URL: 'https://api.openai.com/v1',
32+
OPENAI_API_KEY: '',
33+
CLAUDE_CONFIG_DIR: configDir,
34+
HOME: homeDir,
35+
OPENCLAUDE_DISABLE_EARLY_INPUT: '1',
36+
},
37+
stderr: 'pipe',
38+
stdout: 'pipe',
39+
})
40+
41+
const [stdout, stderr, exitCode] = await Promise.all([
42+
readStream(proc.stdout),
43+
readStream(proc.stderr),
44+
proc.exited,
45+
])
46+
47+
return { exitCode, stderr, stdout }
48+
}
49+
50+
test('skills list bypasses provider startup validation', async () => {
51+
const { exitCode, stderr, stdout } = await runSkillsList(['skills', 'list'])
52+
53+
expect(exitCode).toBe(0)
54+
expect(stdout).toContain('Skills: 0 enabled')
55+
expect(stdout).toContain('No installed skills found.')
56+
expect(stderr).not.toContain('OPENAI_API_KEY is required')
57+
})
58+
59+
test('skills list bypasses provider startup validation after --bare', async () => {
60+
const { exitCode, stderr, stdout } = await runSkillsList([
61+
'--bare',
62+
'skills',
63+
'list',
64+
])
65+
66+
expect(exitCode).toBe(0)
67+
expect(stdout).toContain('Skills: 0 enabled')
68+
expect(stdout).toContain('No installed skills found.')
69+
expect(stderr).not.toContain('OPENAI_API_KEY is required')
70+
})

src/entrypoints/cli.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,50 @@ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true'
4747
// eslint-disable-next-line custom-rules/no-top-level-side-effects
4848
process.env.COREPACK_ENABLE_AUTO_PIN = '0';
4949

50+
const SKILLS_LEADING_BOOLEAN_FLAGS = new Set([
51+
'--bare',
52+
'--debug',
53+
'--debug-to-stderr',
54+
'--mcp-debug',
55+
'--verbose',
56+
])
57+
58+
const SKILLS_LEADING_VALUE_FLAGS = new Set([
59+
'--debug-file',
60+
'--model',
61+
'--provider',
62+
])
63+
64+
function getSkillsCliArgs(args: string[]): string[] | undefined {
65+
for (let index = 0; index < args.length; index += 1) {
66+
const arg = args[index]
67+
if (arg === 'skills') {
68+
return args.slice(index)
69+
}
70+
if (SKILLS_LEADING_BOOLEAN_FLAGS.has(arg)) {
71+
continue
72+
}
73+
if (
74+
SKILLS_LEADING_VALUE_FLAGS.has(arg) &&
75+
args[index + 1] &&
76+
!args[index + 1]!.startsWith('-')
77+
) {
78+
index += 1
79+
continue
80+
}
81+
if (
82+
Array.from(SKILLS_LEADING_VALUE_FLAGS).some(flag =>
83+
arg?.startsWith(`${flag}=`),
84+
)
85+
) {
86+
continue
87+
}
88+
return undefined
89+
}
90+
91+
return undefined
92+
}
93+
5094
// Set max heap size for child processes in CCR environments (containers have 16GB)
5195
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check
5296
if (process.env.CLAUDE_CODE_REMOTE === 'true') {
@@ -108,6 +152,16 @@ async function main(): Promise<void> {
108152
applySafeConfigEnvironmentVariables()
109153
}
110154

155+
// Local skills management must stay available even when provider startup
156+
// configuration is broken, so users can inspect/fix skills from scripts.
157+
const skillsCliArgs = getSkillsCliArgs(args)
158+
if (skillsCliArgs) {
159+
const { runSkillsCli } = await import('../cli/handlers/skillsCli.js')
160+
process.argv = [process.argv[0]!, process.argv[1]!, ...skillsCliArgs]
161+
await runSkillsCli(skillsCliArgs);
162+
return
163+
}
164+
111165
const hasConfiguredProviderProfile = await (async () => {
112166
const { getActiveProviderProfile } = await import('../utils/providerProfiles.js')
113167
return getActiveProviderProfile() !== undefined

0 commit comments

Comments
 (0)