Skip to content

Commit 1a0e39b

Browse files
author
OpenClaude Worker 3
committed
fix(skills): preserve namespaced local installs
1 parent 4ff47fe commit 1a0e39b

3 files changed

Lines changed: 89 additions & 38 deletions

File tree

src/cli/handlers/skills.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,22 @@ Use this skill for install tests.
3939
Document token scopes without storing secret values.
4040
`
4141

42+
const NAMESPACED_SKILL = `---
43+
name: git:commit
44+
title: Git Commit
45+
description: Nested git commit skill used by install tests.
46+
version: 0.1.0
47+
category: test
48+
author: OpenClaude Tests
49+
license: MIT
50+
trust: local
51+
---
52+
53+
# Git Commit
54+
55+
Use this skill for commit workflows.
56+
`
57+
4258
const PATH_TRAVERSAL_SKILL = `---
4359
name: ../escape
4460
title: Unsafe Skill
@@ -298,6 +314,30 @@ test.serial('installs a local skill directory into project skills by default', a
298314
})
299315
})
300316

317+
test.serial('preserves namespaced names when installing local skill directories', async () => {
318+
await withTempDir(async tempDir => {
319+
const cwd = join(tempDir, 'project')
320+
const source = join(tempDir, 'source', 'git', 'commit')
321+
mkdirSync(source, { recursive: true })
322+
mkdirSync(cwd, { recursive: true })
323+
writeFileSync(join(source, 'SKILL.md'), NAMESPACED_SKILL, 'utf8')
324+
325+
await skillsInstallHandler(source, { projectDir: cwd })
326+
327+
const nestedPath = join(
328+
cwd,
329+
'.openclaude',
330+
'skills',
331+
'git',
332+
'commit',
333+
'SKILL.md',
334+
)
335+
const flatPath = join(cwd, '.openclaude', 'skills', 'commit', 'SKILL.md')
336+
assert.equal(existsSync(flatPath), false)
337+
assert.equal(readFileSync(nestedPath, 'utf8'), NAMESPACED_SKILL)
338+
})
339+
})
340+
301341
test.serial('refuses to overwrite installed skills without --force', async () => {
302342
await withTempDir(async tempDir => {
303343
const cwd = join(tempDir, 'project')

src/cli/handlers/skillsInstall.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,25 @@ function resolveContainedPath(root: string, child: string): string {
272272
return resolvedChild
273273
}
274274

275+
function skillNameToInstallPath(skillName: string): string {
276+
return join(...skillName.split(':'))
277+
}
278+
279+
function resolveSkillInstallPath(root: string, skillName: string): string {
280+
return resolveContainedPath(root, skillNameToInstallPath(skillName))
281+
}
282+
283+
async function getSkillNameFromDirectory(sourcePath: string): Promise<string> {
284+
const fallbackName = basename(sourcePath)
285+
try {
286+
const markdown = await readFile(join(sourcePath, 'SKILL.md'), 'utf8')
287+
return getSkillNameFromMarkdown(markdown, fallbackName)
288+
} catch {
289+
// Validation reports missing or malformed SKILL.md after the directory is staged.
290+
return fallbackName
291+
}
292+
}
293+
275294
async function prepareSkillFromMarkdown({
276295
markdown,
277296
fallbackName,
@@ -287,7 +306,7 @@ async function prepareSkillFromMarkdown({
287306
: getSkillNameFromMarkdown(markdown, fallbackName),
288307
)
289308
const tempRoot = await mkdtemp(join(tmpdir(), 'openclaude-skill-install-'))
290-
const tempDir = resolveContainedPath(tempRoot, skillName)
309+
const tempDir = resolveSkillInstallPath(tempRoot, skillName)
291310
await mkdir(tempDir, { recursive: true })
292311
await writeFile(join(tempDir, 'SKILL.md'), markdown, 'utf8')
293312
if (registryEntry) {
@@ -316,9 +335,11 @@ async function prepareInstallCandidate(
316335
const sourcePath = resolve(spec)
317336
const sourceStats = await stat(sourcePath)
318337
if (sourceStats.isDirectory()) {
319-
const skillName = normalizeInstallSkillName(basename(sourcePath))
338+
const skillName = normalizeInstallSkillName(
339+
await getSkillNameFromDirectory(sourcePath),
340+
)
320341
const tempRoot = await mkdtemp(join(tmpdir(), 'openclaude-skill-install-'))
321-
const tempDir = resolveContainedPath(tempRoot, skillName)
342+
const tempDir = resolveSkillInstallPath(tempRoot, skillName)
322343
await cp(sourcePath, tempDir, {
323344
recursive: true,
324345
errorOnExist: true,
@@ -410,7 +431,7 @@ export async function skillsInstallHandler(
410431
}
411432

412433
const root = installRoot(options)
413-
const targetDir = resolveContainedPath(root, candidate.skillName)
434+
const targetDir = resolveSkillInstallPath(root, candidate.skillName)
414435
if ((await pathExists(targetDir)) && !options.force) {
415436
console.error(
416437
`Skill "${candidate.skillName}" already exists at ${getDisplayPath(targetDir)}. Use --force to overwrite.`,

src/skills/loadSkillsDir.test.ts

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import assert from 'node:assert/strict'
2-
import { spawnSync } from 'node:child_process'
32
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
43
import { tmpdir } from 'node:os'
54
import { join } from 'node:path'
@@ -162,9 +161,11 @@ test('prefers .openclaude project skills over legacy .claude skills with the sam
162161
})
163162

164163
test('project skills are ordered before user skills with the same name', async () => {
164+
await acquireSharedMutationLock('loadSkillsDir.test.ts')
165165
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
166166
const cwd = join(configDir, 'workspace')
167-
const scriptPath = join(configDir, 'check-skill-order.mjs')
167+
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
168+
const originalSources = enableUserAndProjectSettingSources()
168169

169170
try {
170171
mkdirSync(cwd, { recursive: true })
@@ -174,44 +175,33 @@ test('project skills are ordered before user skills with the same name', async (
174175
description: 'project skill',
175176
})
176177

177-
writeFileSync(
178-
scriptPath,
179-
`
180-
import { clearSkillCaches, getSkillDirCommands } from ${JSON.stringify(join(process.cwd(), 'src/skills/loadSkillsDir.ts'))}
181-
182-
clearSkillCaches()
183-
const skills = await getSkillDirCommands(${JSON.stringify(cwd)})
184-
const sharedSkills = skills
185-
.filter(skill => skill.type === 'prompt' && skill.name === 'shared')
186-
.map(skill => ({
187-
description: skill.description,
188-
source: skill.source,
189-
}))
190-
console.log(JSON.stringify(sharedSkills))
191-
`,
192-
'utf8',
193-
)
194-
195-
const result = spawnSync(process.execPath, [scriptPath], {
196-
cwd: process.cwd(),
197-
env: {
198-
...process.env,
199-
CLAUDE_CONFIG_DIR: configDir,
200-
},
201-
encoding: 'utf8',
202-
})
178+
process.env.CLAUDE_CONFIG_DIR = configDir
179+
clearSkillCaches()
203180

204-
assert.equal(result.status, 0, result.stderr)
205-
const sharedSkills = JSON.parse(result.stdout) as Array<{
206-
description: string
207-
source: string
208-
}>
181+
const skills = await getSkillDirCommands(cwd)
182+
const sharedSkills = skills
183+
.filter(skill => skill.type === 'prompt' && skill.name === 'shared')
184+
.map(skill => ({
185+
description: skill.description,
186+
source: skill.source,
187+
}))
209188

210189
assert.equal(sharedSkills.length, 2)
211190
assert.equal(sharedSkills[0]?.description, 'project skill')
212191
assert.equal(sharedSkills[0]?.source, 'projectSettings')
213192
} finally {
214-
rmSync(configDir, { recursive: true, force: true })
193+
try {
194+
if (originalConfigDir === undefined) {
195+
delete process.env.CLAUDE_CONFIG_DIR
196+
} else {
197+
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
198+
}
199+
clearSkillCaches()
200+
setAllowedSettingSources(originalSources)
201+
rmSync(configDir, { recursive: true, force: true })
202+
} finally {
203+
releaseSharedMutationLock()
204+
}
215205
}
216206
})
217207

0 commit comments

Comments
 (0)