diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index a49a8c824..f529c8350 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": "498caee2738006d64530e9be34883111cf67742d8c4389f39d61299e1a1c4400", + "registryDigest": "62dc25b99e2b6ccd60a22014d9c75f9a98cb89ffd6870a4e7d32e653b76e0e63", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/skills/ck/SKILL.md b/skills/ck/SKILL.md index 8f3cbb0b1..12e191050 100644 --- a/skills/ck/SKILL.md +++ b/skills/ck/SKILL.md @@ -12,7 +12,13 @@ repo: https://github.com/sreedhargs89/context-keeper You are the **Context Keeper** assistant. When the user invokes any `/ck:*` command, run the corresponding Node.js script and present its stdout to the user verbatim. -Scripts live at: `~/.claude/skills/ck/commands/` (expand `~` with `$HOME`). +Scripts live in the `commands/` directory next to this SKILL.md (for example +`~/.claude/skills/ck/commands/` for a manual install, or the ck skill inside the ECC +plugin). The commands below refer to the skill directory as `${CLAUDE_SKILL_DIR}`, +which Claude Code fills in; in other hosts, replace it with the absolute path of the +directory containing this SKILL.md. Run the scripts from the project directory; do not +`cd` into the skill directory, because the scripts use the current directory as the +project path. --- @@ -32,7 +38,7 @@ Scripts live at: `~/.claude/skills/ck/commands/` (expand `~` with `$HOME`). ### `/ck:init` — Register a Project ```bash -node "$HOME/.claude/skills/ck/commands/init.mjs" +node "${CLAUDE_SKILL_DIR}/commands/init.mjs" ``` The script outputs JSON with auto-detected info. Present it as a confirmation draft: ``` @@ -44,9 +50,13 @@ Goal: Do-nots: Repo: ``` -Wait for user approval. Apply any edits. Then pipe confirmed JSON to save.mjs --init: +Wait for user approval. Apply any edits. Then pass the confirmed JSON to save.mjs --init +through a quoted heredoc (never `echo '...'`: an apostrophe in any field would end the +quoted string and hand the rest to the shell): ```bash -echo '' | node "$HOME/.claude/skills/ck/commands/save.mjs" --init +node "${CLAUDE_SKILL_DIR}/commands/save.mjs" --init <<'CK_JSON' + +CK_JSON ``` Confirmed JSON schema: `{"name":"...","path":"...","description":"...","stack":["..."],"goal":"...","constraints":["..."],"repo":"..." }` @@ -62,9 +72,11 @@ Confirmed JSON schema: `{"name":"...","path":"...","description":"...","stack":[ - `goal`: updated goal string **only if it changed this session**, else omit Show a draft summary to the user: `"Session: '' — save this? (yes / edit)"` -Wait for confirmation. Then pipe to save.mjs: +Wait for confirmation. Then pass the JSON to save.mjs through a quoted heredoc: ```bash -echo '' | node "$HOME/.claude/skills/ck/commands/save.mjs" +node "${CLAUDE_SKILL_DIR}/commands/save.mjs" <<'CK_JSON' + +CK_JSON ``` JSON schema (exact): `{"summary":"...","leftOff":"...","nextSteps":["..."],"decisions":[{"what":"...","why":"..."}],"blockers":["..."]}` Display the script's stdout confirmation verbatim. @@ -73,7 +85,7 @@ Display the script's stdout confirmation verbatim. ### `/ck:resume [name|number]` — Full Briefing ```bash -node "$HOME/.claude/skills/ck/commands/resume.mjs" [arg] +node "${CLAUDE_SKILL_DIR}/commands/resume.mjs" [arg] ``` Display output verbatim. Then ask: "Continue from here? Or has anything changed?" If user reports changes → run `/ck:save` immediately. @@ -82,7 +94,7 @@ If user reports changes → run `/ck:save` immediately. ### `/ck:info [name|number]` — Quick Snapshot ```bash -node "$HOME/.claude/skills/ck/commands/info.mjs" [arg] +node "${CLAUDE_SKILL_DIR}/commands/info.mjs" [arg] ``` Display output verbatim. No follow-up question. @@ -90,7 +102,7 @@ Display output verbatim. No follow-up question. ### `/ck:list` — Portfolio View ```bash -node "$HOME/.claude/skills/ck/commands/list.mjs" +node "${CLAUDE_SKILL_DIR}/commands/list.mjs" ``` Display output verbatim. If user replies with a number or name → run `/ck:resume`. @@ -101,7 +113,7 @@ First resolve the project name (run `/ck:list` if needed). Ask: `"This will permanently delete context for ''. Are you sure? (yes/no)"` If yes: ```bash -node "$HOME/.claude/skills/ck/commands/forget.mjs" [name] +node "${CLAUDE_SKILL_DIR}/commands/forget.mjs" [name] ``` Display confirmation verbatim. @@ -109,11 +121,11 @@ Display confirmation verbatim. ### `/ck:migrate` — Convert v1 Data to v2 ```bash -node "$HOME/.claude/skills/ck/commands/migrate.mjs" +node "${CLAUDE_SKILL_DIR}/commands/migrate.mjs" ``` For a dry run first: ```bash -node "$HOME/.claude/skills/ck/commands/migrate.mjs" --dry-run +node "${CLAUDE_SKILL_DIR}/commands/migrate.mjs" --dry-run ``` Display output verbatim. Migrates all v1 CONTEXT.md + meta.json files to v2 context.json. Originals are backed up as `meta.json.v1-backup` — nothing is deleted. @@ -122,19 +134,24 @@ Originals are backed up as `meta.json.v1-backup` — nothing is deleted. ## SessionStart Hook -The hook at `~/.claude/skills/ck/hooks/session-start.mjs` must be registered in -`~/.claude/settings.json` to auto-load project context on session start: +The hook at `hooks/session-start.mjs` in this skill's directory must be registered in +`~/.claude/settings.json` to auto-load project context on session start. For a manual +install in `~/.claude/skills/ck`: ```json { "hooks": { "SessionStart": [ - { "hooks": [{ "type": "command", "command": "node \"~/.claude/skills/ck/hooks/session-start.mjs\"" }] } + { "hooks": [{ "type": "command", "command": "node \"$HOME/.claude/skills/ck/hooks/session-start.mjs\"" }] } ] } } ``` +Use `$HOME`, not `~`: the shell does not expand `~` inside double quotes. If ck lives +elsewhere (for example inside the ECC plugin), use the absolute path of its +`hooks/session-start.mjs` instead. + The hook injects ~100 tokens per session (compact 5-line summary). It also detects unsaved sessions, git activity since last save, and goal mismatches vs CLAUDE.md. diff --git a/skills/ck/commands/shared.mjs b/skills/ck/commands/shared.mjs index f826f4c1f..eaac041dd 100644 --- a/skills/ck/commands/shared.mjs +++ b/skills/ck/commands/shared.mjs @@ -6,8 +6,9 @@ */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { resolve } from 'path'; +import { resolve, dirname } from 'path'; import { homedir } from 'os'; +import { fileURLToPath } from 'url'; import { spawnSync } from 'child_process'; import { randomBytes } from 'crypto'; @@ -17,7 +18,7 @@ export const CK_HOME = resolve(homedir(), '.claude', 'ck'); export const CONTEXTS_DIR = resolve(CK_HOME, 'contexts'); export const PROJECTS_FILE = resolve(CK_HOME, 'projects.json'); export const CURRENT_SESSION = resolve(CK_HOME, 'current-session.json'); -export const SKILL_FILE = resolve(homedir(), '.claude', 'skills', 'ck', 'SKILL.md'); +export const SKILL_FILE = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'SKILL.md'); // ─── JSON I/O ───────────────────────────────────────────────────────────────── @@ -183,8 +184,9 @@ export function gitSummary(projectPath, sinceDate) { // ─── Native memory path encoding ────────────────────────────────────────────── export function encodeProjectPath(absolutePath) { - // "/Users/sree/dev/app" -> "-Users-sree-dev-app" - return absolutePath.replace(/\//g, '-'); + // Same rule as Claude Code: every non-alphanumeric character becomes '-'. + // "/Users/sree/dev/my app" -> "-Users-sree-dev-my-app" + return absolutePath.replace(/[^A-Za-z0-9]/g, '-'); } export function nativeMemoryDir(absolutePath) { diff --git a/skills/ck/hooks/session-start.mjs b/skills/ck/hooks/session-start.mjs index e109149a0..0ef9cd2fa 100644 --- a/skills/ck/hooks/session-start.mjs +++ b/skills/ck/hooks/session-start.mjs @@ -15,14 +15,17 @@ */ import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; +import { resolve, dirname } from 'path'; import { homedir } from 'os'; import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; const CK_HOME = resolve(homedir(), '.claude', 'ck'); const PROJECTS_FILE = resolve(CK_HOME, 'projects.json'); const CURRENT_SESSION = resolve(CK_HOME, 'current-session.json'); -const SKILL_FILE = resolve(homedir(), '.claude', 'skills', 'ck', 'SKILL.md'); +// Resolve SKILL.md next to this hook, so plugin installs work as well as ~/.claude/skills/ck. +const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SKILL_FILE = resolve(SKILL_DIR, 'SKILL.md'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -85,7 +88,10 @@ function main() { const sessionId = readSessionId(); // Load skill (always inject — now only ~50 lines) - const skill = existsSync(SKILL_FILE) ? readFileSync(SKILL_FILE, 'utf8') : ''; + // The host does not expand ${CLAUDE_SKILL_DIR} in hook output, so fill it in here. + const skill = existsSync(SKILL_FILE) + ? readFileSync(SKILL_FILE, 'utf8').replaceAll('${CLAUDE_SKILL_DIR}', SKILL_DIR) + : ''; const projects = readJson(PROJECTS_FILE) || {}; const entry = projects[cwd]; diff --git a/tests/skills/ck-paths.test.js b/tests/skills/ck-paths.test.js new file mode 100644 index 000000000..f9685e879 --- /dev/null +++ b/tests/skills/ck-paths.test.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Tests for ck (Context Keeper) path handling: skill-relative script paths, + * heredoc JSON input, and Claude Code project directory encoding. + * + * Run with: node tests/skills/ck-paths.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const { spawnSync } = require('child_process'); + +const SKILL_DIR = path.join(__dirname, '..', '..', 'skills', 'ck'); +const SKILL_MD = path.join(SKILL_DIR, 'SKILL.md'); +const SESSION_START = path.join(SKILL_DIR, 'hooks', 'session-start.mjs'); + +async function test(name, fn) { + try { + await fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function tempEnv() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-paths-')); + const home = path.join(root, 'home'); + const project = path.join(root, "Bob's App v2"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(project, { recursive: true }); + const env = { ...process.env, HOME: home, USERPROFILE: home, PWD: project }; + return { root, home, project, env }; +} + +// Return the body of the first ```bash fence that contains `marker`. +function bashBlock(marker) { + const md = fs.readFileSync(SKILL_MD, 'utf8'); + const block = [...md.matchAll(/```bash\n([\s\S]*?)```/g)].map(m => m[1]).find(b => b.includes(marker)); + assert.ok(block, `no bash block containing ${marker}`); + return block; +} + +function runDocumentedSave(marker, placeholder, payload, ctx) { + const script = bashBlock(marker) + .replaceAll('${CLAUDE_SKILL_DIR}', SKILL_DIR) + .replace(placeholder, () => JSON.stringify(payload)); + return spawnSync('bash', ['-c', script], { cwd: ctx.project, env: ctx.env, encoding: 'utf8' }); +} + +async function runTests() { + console.log('\n=== Testing ck path handling ===\n'); + + let passed = 0; + let failed = 0; + + if (await test('encodeProjectPath maps every non-alphanumeric character to "-"', async () => { + const { encodeProjectPath } = await import(pathToFileURL(path.join(SKILL_DIR, 'commands', 'shared.mjs')).href); + assert.strictEqual(encodeProjectPath('/home/u/My Project_v1.2'), '-home-u-My-Project-v1-2'); + assert.strictEqual(encodeProjectPath('C:\\Users\\u\\app'), 'C--Users-u-app'); + })) passed++; else failed++; + + if (await test('SessionStart hook injects the SKILL.md next to it, with the skill dir filled in', async () => { + const ctx = tempEnv(); + try { + const result = spawnSync(process.execPath, [SESSION_START], { + cwd: ctx.project, env: ctx.env, input: '{}', encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stderr); + 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'); + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (process.platform === 'win32') { + console.log(' - skipped documented bash heredoc test on Windows'); + } else if (await test('documented heredoc commands save JSON with apostrophes into the native memory dir', async () => { + const ctx = tempEnv(); + try { + const init = runDocumentedSave('save.mjs" --init', '', { + name: 'bobs-app', path: ctx.project, description: "Bob's app, don't break it", + stack: ['node'], goal: "Ship Bob's release", constraints: [], + }, ctx); + assert.strictEqual(init.status, 0, init.stdout + init.stderr); + + const save = runDocumentedSave('save.mjs" <<', '', { + summary: "Fixed the user's login", leftOff: "didn't finish tests", nextSteps: ["write O'Brien's test"], + decisions: [{ what: "use Bob's parser", why: "it's faster" }], blockers: [], + }, ctx); + assert.strictEqual(save.status, 0, save.stdout + save.stderr); + + const encoded = ctx.project.replace(/[^A-Za-z0-9]/g, '-'); + const memDir = path.join(ctx.home, '.claude', 'projects', encoded, 'memory'); + const files = fs.existsSync(memDir) ? fs.readdirSync(memDir) : []; + assert.strictEqual(files.length, 1, `expected one memory entry in ${memDir}`); + assert.ok(fs.readFileSync(path.join(memDir, files[0]), 'utf8').includes("Fixed the user's login")); + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests();