fix(ck): run commands from the skill directory and pass JSON through a heredoc

- SKILL.md invoked every script through ~/.claude/skills/ck, which does
  not exist when ck is installed as part of the ECC plugin, so every
  /ck:* command failed with MODULE_NOT_FOUND. Use ${CLAUDE_SKILL_DIR}
  and tell non-Claude hosts to substitute the skill directory. Scripts
  still run from the project directory, since they use it as the
  project path.
- The documented SessionStart command quoted "~/...", which the shell
  never expands. Use $HOME and explain the plugin-install case.
- /ck:init and /ck:save piped JSON through echo '<json>'. Any apostrophe
  ends the quoted string, and init JSON carries README/package.json
  text. Pass it through a quoted heredoc instead.
- session-start.mjs and shared.mjs resolve SKILL.md next to the code
  (import.meta.url), and the hook expands ${CLAUDE_SKILL_DIR} in the
  text it injects.
- encodeProjectPath only replaced '/', while Claude Code maps every
  non-alphanumeric character to '-'. Memory entries for projects whose
  path has a space or dot went to a directory Claude Code never reads.

Signed-off-by: Wen <jwcastillo@gmail.com>
(cherry picked from commit c920005c2b)
This commit is contained in:
Wen authored and haelyra committed 2026-10-20 18:01:42 -04:00
1 parent 2eed06b585
commit cf3408097c
4 files changed
+166 -22

No files matched your search

+32 -15
View File
@@ -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: <goal>
Do-nots: <constraints or "None">
Repo: <repo or "none">
```
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 '<confirmed-json>' | node "$HOME/.claude/skills/ck/commands/save.mjs" --init
node "${CLAUDE_SKILL_DIR}/commands/save.mjs" --init <<'CK_JSON'
<confirmed-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: '<summary>' — 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 '<json>' | node "$HOME/.claude/skills/ck/commands/save.mjs"
node "${CLAUDE_SKILL_DIR}/commands/save.mjs" <<'CK_JSON'
<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 '<name>'. 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.
+6 -4
View File
@@ -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) {
+9 -3
View File
@@ -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];
+119
View File
@@ -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', '<confirmed-json>', {
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" <<', '<json>', {
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();