mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
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>
This commit is contained in:
5 files changed
+167
-23
No files matched your search
@@ -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": [
|
||||
|
||||
+32
-15
@@ -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,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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user