diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index f529c8350..ead361dd7 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "62dc25b99e2b6ccd60a22014d9c75f9a98cb89ffd6870a4e7d32e653b76e0e63", + "registryDigest": "2c1024b127b18808916ac0d31037bc1a90c7c1c8df8021d0882dbe2273ae02fe", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/skills/ck/hooks/session-start.mjs b/skills/ck/hooks/session-start.mjs index 0ef9cd2fa..47d3623b7 100644 --- a/skills/ck/hooks/session-start.mjs +++ b/skills/ck/hooks/session-start.mjs @@ -89,8 +89,11 @@ function main() { // Load skill (always inject — now only ~50 lines) // The host does not expand ${CLAUDE_SKILL_DIR} in hook output, so fill it in here. + // SKILL.md uses it inside double-quoted bash, so escape the characters bash + // still interprets there; the callback keeps `$&` etc. in the path literal. + const shellSkillDir = SKILL_DIR.replace(/[\\$`"]/g, '\\$&'); const skill = existsSync(SKILL_FILE) - ? readFileSync(SKILL_FILE, 'utf8').replaceAll('${CLAUDE_SKILL_DIR}', SKILL_DIR) + ? readFileSync(SKILL_FILE, 'utf8').replaceAll('${CLAUDE_SKILL_DIR}', () => shellSkillDir) : ''; const projects = readJson(PROJECTS_FILE) || {}; diff --git a/tests/skills/ck-paths.test.js b/tests/skills/ck-paths.test.js index f9685e879..9ab5d88d4 100644 --- a/tests/skills/ck-paths.test.js +++ b/tests/skills/ck-paths.test.js @@ -78,7 +78,31 @@ async function runTests() { const { additionalContext } = JSON.parse(result.stdout); assert.ok(additionalContext.includes('# ck — Context Keeper'), 'SKILL.md was not injected'); assert.ok(!additionalContext.includes('${CLAUDE_SKILL_DIR}'), 'skill dir placeholder left unexpanded'); - assert.ok(additionalContext.includes(path.join(SKILL_DIR, 'commands')), 'skill dir not substituted'); + // Escaped for double-quoted bash (Windows backslashes are doubled). + const shellCommandsDir = path.join(SKILL_DIR, 'commands').replace(/[\\$`"]/g, '\\$&'); + assert.ok(additionalContext.includes(shellCommandsDir), 'skill dir not substituted'); + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (process.platform === 'win32') { + console.log(' - skipped shell-special skill dir test on Windows'); + } else if (await test('hook-injected commands run from a skill dir with shell-special characters', async () => { + const ctx = tempEnv(); + try { + const skillDir = path.join(ctx.root, 'skills $USER $& `x` "q"', 'ck'); + fs.cpSync(SKILL_DIR, skillDir, { recursive: true }); + const result = spawnSync(process.execPath, [path.join(skillDir, 'hooks', 'session-start.mjs')], { + cwd: ctx.project, env: ctx.env, input: '{}', encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stderr); + const { additionalContext } = JSON.parse(result.stdout); + const listCommand = additionalContext.split('\n').find(line => line.includes('commands/list.mjs')); + assert.ok(listCommand, 'no list.mjs command injected'); + const list = spawnSync('bash', ['-c', listCommand], { cwd: ctx.project, env: ctx.env, encoding: 'utf8' }); + // No projects are registered, so list.mjs reaching its own message proves the path resolved. + assert.ok(list.stdout.includes('No projects registered'), `${listCommand}\n${list.stdout}${list.stderr}`); } finally { fs.rmSync(ctx.root, { recursive: true, force: true }); }