refactor: flatten monorepo to src/ and add adapter pattern for multi-platform hooks

Migrate from 3-package monorepo (shared, session, core) to flat src/ structure.
Add HookAdapter interface with Claude Code adapter implementation for future
multi-platform support (Gemini CLI, OpenCode, Copilot CLI, Cursor per #46).

- Move packages/*/src/ → src/ and src/session/
- Replace @context-mode/* imports with relative paths
- Add src/adapters/types.ts (HookAdapter interface, normalized event types)
- Add src/adapters/claude-code/ (adapter, hooks, config modules)
- Refactor CLI to use adapter pattern instead of hardcoded logic
- Remove packages/, tsconfig.base.json, scripts/fix-imports.mjs
- Simplify to single tsconfig.json (no project references)
- All 492 tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mert Koseoglu
2026-03-05 01:34:46 +03:00
co-authored by Claude Opus 4.6
parent a3c579e4e1
commit 49e0d7f135
56 changed files with 1194 additions and 503 deletions
+1 -1
View File
@@ -55,4 +55,4 @@ jobs:
run: npm test
- name: Doctor (verify server starts)
run: npx tsx packages/core/src/cli.ts doctor
run: npx tsx src/cli.ts doctor
+35 -37
View File
@@ -226,36 +226,6 @@ try {
tableRows.push(`| Last request | 1 | "${promptPreview}" |`);
}
if (tableRows.length > 0) {
block += `\n <display_table>`;
block += `\n MANDATORY OUTPUT — Display this table as your FIRST output:`;
block += `\n ---`;
block += `\n **context-mode** — session ${isCompact ? "restored" : "loaded"}`;
block += `\n `;
block += `\n | Preserved | Count | Details |`;
block += `\n |-----------|------:|---------|`;
for (const row of tableRows) {
block += `\n ${row}`;
}
if (isCompact) {
block += `\n `;
block += `\n *Context compacted — session knowledge restored.*`;
} else {
block += `\n `;
block += `\n *Previous session loaded via --continue.*`;
}
block += `\n ---`;
block += `\n </display_table>`;
}
// ── Last user prompt — always in context (critical for compact) ──
if (lastPrompt) {
block += `\n <last_user_prompt>${lastPrompt}</last_user_prompt>`;
if (isCompact) {
block += `\n <continue_from>Continue working on the request above. Do NOT ask the user to repeat themselves.</continue_from>`;
}
}
// ── Mandatory search — LLM must restore working memory from FTS5 ──
const queries = [];
if (fileNames.size > 0) queries.push("active files tracked");
@@ -266,13 +236,41 @@ try {
if (grouped.error?.length > 0) queries.push("errors encountered");
if (!queries.length) queries.push("session resume overview");
block += `\n <mandatory_actions>`;
block += `\n BEFORE doing anything else:`;
block += `\n 1. Display the summary table to the user.`;
block += `\n 2. Restore working memory — search session events:`;
block += `\n mcp__plugin_context-mode_context-mode__search(queries: ${JSON.stringify(queries)}, source: "session-events")`;
block += `\n 3. Then continue the user's last request.`;
block += `\n </mandatory_actions>`;
// ── STEP 1: Search call — must fire BEFORE any text output ──
block += `\n <required_tool_call priority="immediate">`;
block += `\n You MUST call this tool FIRST, before generating ANY text output:`;
block += `\n mcp__plugin_context-mode_context-mode__search(queries: ${JSON.stringify(queries)}, source: "session-events")`;
block += `\n This is a blocking requirement. Do not skip this step.`;
block += `\n </required_tool_call>`;
// ── STEP 2: Display table — must be FIRST text the user sees ──
if (tableRows.length > 0) {
block += `\n <required_output priority="first_message">`;
block += `\n After the search completes, your FIRST text output to the user MUST be exactly this table:`;
block += `\n \`\`\``;
block += `\n ---`;
block += `\n **context-mode** — session ${isCompact ? "restored" : "loaded"}`;
block += `\n `;
block += `\n | Preserved | Count | Details |`;
block += `\n |-----------|------:|---------|`;
for (const row of tableRows) {
block += `\n ${row}`;
}
block += `\n `;
block += `\n *${isCompact ? "Context compacted — session knowledge restored." : "Previous session loaded via --continue."}*`;
block += `\n ---`;
block += `\n \`\`\``;
block += `\n Do NOT skip this table. Do NOT paraphrase it. Copy it exactly as shown above.`;
block += `\n </required_output>`;
}
// ── STEP 3: Last user prompt + continue ──
if (lastPrompt) {
block += `\n <last_user_prompt>${lastPrompt}</last_user_prompt>`;
if (isCompact) {
block += `\n <continue_from>After displaying the table, continue working on the request above. Do NOT ask the user to repeat themselves.</continue_from>`;
}
}
block += `\n</session_knowledge>`;
return block;
+7 -14
View File
@@ -1,8 +1,5 @@
{
"name": "context-mode",
"workspaces": [
"packages/*"
],
"version": "0.9.23-dev",
"type": "module",
"description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.",
@@ -31,10 +28,6 @@
"files": [
"build",
"hooks",
"packages/shared/dist",
"packages/shared/package.json",
"packages/session/dist",
"packages/session/package.json",
"server.bundle.mjs",
"skills",
".claude-plugin",
@@ -44,13 +37,13 @@
"LICENSE"
],
"scripts": {
"build": "tsc -b",
"bundle": "esbuild packages/core/src/server.ts --bundle --platform=node --target=node18 --format=esm --outfile=server.bundle.mjs --external:better-sqlite3 --external:turndown --external:turndown-plugin-gfm --external:@mixmark-io/domino --minify",
"prepublishOnly": "npm run build && node scripts/fix-imports.mjs",
"dev": "npx tsx packages/core/src/server.ts",
"setup": "npx tsx packages/core/src/cli.ts setup",
"doctor": "npx tsx packages/core/src/cli.ts doctor",
"typecheck": "tsc -b --noEmit",
"build": "tsc",
"bundle": "esbuild src/server.ts --bundle --platform=node --target=node18 --format=esm --outfile=server.bundle.mjs --external:better-sqlite3 --external:turndown --external:turndown-plugin-gfm --external:@mixmark-io/domino --minify",
"prepublishOnly": "npm run build",
"dev": "npx tsx src/server.ts",
"setup": "npx tsx src/cli.ts setup",
"doctor": "npx tsx src/cli.ts doctor",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"benchmark": "npx tsx tests/benchmark.ts",
-1
View File
@@ -1 +0,0 @@
dist/
-8
View File
@@ -1,8 +0,0 @@
{
"name": "@context-mode/core",
"private": true,
"type": "module",
"dependencies": {
"@context-mode/shared": "*"
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../build", "rootDir": "src", "composite": true },
"references": [{ "path": "../shared" }],
"include": ["src"]
}
-1
View File
@@ -1 +0,0 @@
dist/
-13
View File
@@ -1,13 +0,0 @@
{
"name": "@context-mode/session",
"private": true,
"type": "module",
"exports": {
"./extract": { "types": "./src/extract.ts", "default": "./dist/extract.js" },
"./db": { "types": "./src/db.ts", "default": "./dist/db.js" },
"./snapshot": { "types": "./src/snapshot.ts", "default": "./dist/snapshot.js" }
},
"dependencies": {
"@context-mode/shared": "*"
}
}
View File
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "src",
"composite": true
},
"references": [{ "path": "../shared" }],
"include": ["src"]
}
-1
View File
@@ -1 +0,0 @@
dist/
-10
View File
@@ -1,10 +0,0 @@
{
"name": "@context-mode/shared",
"private": true,
"type": "module",
"exports": {
"./db-base": { "types": "./src/db-base.ts", "default": "./dist/db-base.js" },
"./truncate": { "types": "./src/truncate.ts", "default": "./dist/truncate.js" },
"./types": { "types": "./src/types.ts", "default": "./dist/types.js" }
}
}
View File
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "src",
"composite": true
},
"include": ["src"]
}
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env node
/**
* Post-build import rewriter.
*
* Rewrites workspace package imports (@context-mode/shared/*)
* to relative paths in compiled output directories.
* This ensures the published npm package works without workspace symlinks.
*
* Targets:
* - build/ (core package output — imports become ../packages/shared/dist/*)
* - packages/session/dist/ (session package output — imports become ../../shared/dist/*)
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
// ── Core build output (build/*.js, build/*.d.ts) ──
const CORE_BUILD = join(import.meta.dirname, "..", "build");
const CORE_REWRITES = [
[/@context-mode\/shared\/db-base/g, "../packages/shared/dist/db-base.js"],
[/@context-mode\/shared\/truncate/g, "../packages/shared/dist/truncate.js"],
[/@context-mode\/shared\/types/g, "../packages/shared/dist/types.js"],
];
// ── Session dist output (packages/session/dist/*.js) ──
const SESSION_DIST = join(import.meta.dirname, "..", "packages", "session", "dist");
const SESSION_REWRITES = [
[/@context-mode\/shared\/db-base/g, "../../shared/dist/db-base.js"],
[/@context-mode\/shared\/truncate/g, "../../shared/dist/truncate.js"],
[/@context-mode\/shared\/types/g, "../../shared/dist/types.js"],
];
function rewriteDir(dir, rewrites, label) {
let rewritten = 0;
let files;
try {
files = readdirSync(dir);
} catch {
console.log(` ${label}: directory not found, skipping`);
return 0;
}
for (const file of files) {
if (!file.endsWith(".js") && !file.endsWith(".d.ts")) continue;
const filePath = join(dir, file);
let content = readFileSync(filePath, "utf-8");
let changed = false;
for (const [pattern, replacement] of rewrites) {
if (pattern.test(content)) {
// Reset lastIndex since we're reusing the regex
pattern.lastIndex = 0;
content = content.replace(pattern, replacement);
changed = true;
}
}
if (changed) {
writeFileSync(filePath, content);
rewritten++;
console.log(` Rewritten: ${label}/${file}`);
}
}
return rewritten;
}
const coreCount = rewriteDir(CORE_BUILD, CORE_REWRITES, "build");
const sessionCount = rewriteDir(SESSION_DIST, SESSION_REWRITES, "session");
console.log(`fix-imports: ${coreCount + sessionCount} files rewritten (core: ${coreCount}, session: ${sessionCount})`);
+400
View File
@@ -0,0 +1,400 @@
/**
* adapters/claude-code/config — Claude Code configuration management.
*
* Handles reading/writing ~/.claude/settings.json, hook installation,
* plugin registration verification, and upgrade operations specific
* to the Claude Code platform.
*/
import {
readFileSync,
writeFileSync,
copyFileSync,
accessSync,
readdirSync,
chmodSync,
constants,
} from "node:fs";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { ClaudeCodeAdapter } from "./index.js";
import {
HOOK_TYPES,
HOOK_SCRIPTS,
PRE_TOOL_USE_MATCHER_PATTERN,
isContextModeHook,
buildHookCommand,
type HookType,
} from "./hooks.js";
// ─────────────────────────────────────────────────────────
// Settings management
// ─────────────────────────────────────────────────────────
const adapter = new ClaudeCodeAdapter();
/** Read Claude Code settings from ~/.claude/settings.json. */
export function readSettings(): Record<string, unknown> | null {
return adapter.readSettings();
}
/** Write Claude Code settings to ~/.claude/settings.json. */
export function writeSettings(settings: Record<string, unknown>): void {
adapter.writeSettings(settings);
}
/** Get the settings file path. */
export function getSettingsPath(): string {
return adapter.getSettingsPath();
}
/** Backup settings.json before modifications. Returns backup path or null. */
export function backupSettings(): string | null {
const settingsPath = adapter.getSettingsPath();
try {
accessSync(settingsPath, constants.R_OK);
const backupPath = settingsPath + ".bak";
copyFileSync(settingsPath, backupPath);
return backupPath;
} catch {
return null;
}
}
// ─────────────────────────────────────────────────────────
// Hook configuration
// ─────────────────────────────────────────────────────────
/**
* Configure a specific hook type in settings.json.
* Returns a description of what was done.
*/
export function configureHook(
settings: Record<string, unknown>,
hookType: HookType,
pluginRoot: string,
): string {
const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
const command = buildHookCommand(hookType, pluginRoot);
if (hookType === HOOK_TYPES.PRE_TOOL_USE) {
// PreToolUse uses a consolidated matcher pattern
const entry = {
matcher: PRE_TOOL_USE_MATCHER_PATTERN,
hooks: [{ type: "command", command }],
};
const existing = hooks.PreToolUse as
| Array<Record<string, unknown>>
| undefined;
if (existing && Array.isArray(existing)) {
const idx = existing.findIndex((e) =>
isContextModeHook(
e as { hooks?: Array<{ command?: string }> },
hookType,
),
);
if (idx >= 0) {
existing[idx] = entry;
hooks.PreToolUse = existing;
settings.hooks = hooks;
return `Updated existing ${hookType} hook entry`;
}
existing.push(entry);
hooks.PreToolUse = existing;
settings.hooks = hooks;
return `Added ${hookType} hook entry to existing hooks`;
}
hooks.PreToolUse = [entry];
settings.hooks = hooks;
return `Created ${hookType} hooks section`;
}
// All other hook types use empty matcher (match all)
const entry = {
matcher: "",
hooks: [{ type: "command", command }],
};
const existing = hooks[hookType] as
| Array<Record<string, unknown>>
| undefined;
if (existing && Array.isArray(existing)) {
const idx = existing.findIndex((e) =>
isContextModeHook(
e as { hooks?: Array<{ command?: string }> },
hookType,
),
);
if (idx >= 0) {
existing[idx] = entry;
hooks[hookType] = existing;
settings.hooks = hooks;
return `Updated existing ${hookType} hook entry`;
}
existing.push(entry);
hooks[hookType] = existing;
settings.hooks = hooks;
return `Added ${hookType} hook entry to existing hooks`;
}
hooks[hookType] = [entry];
settings.hooks = hooks;
return `Created ${hookType} hooks section`;
}
/**
* Configure all context-mode hooks in settings.json.
* Returns list of change descriptions.
*/
export function configureAllHooks(
settings: Record<string, unknown>,
pluginRoot: string,
): string[] {
const hookTypes: HookType[] = [
HOOK_TYPES.PRE_TOOL_USE,
HOOK_TYPES.POST_TOOL_USE,
HOOK_TYPES.PRE_COMPACT,
HOOK_TYPES.SESSION_START,
HOOK_TYPES.USER_PROMPT_SUBMIT,
];
return hookTypes.map((type) => configureHook(settings, type, pluginRoot));
}
// ─────────────────────────────────────────────────────────
// Hook validation (for doctor command)
// ─────────────────────────────────────────────────────────
export interface HookCheckResult {
hookType: HookType;
status: "pass" | "fail" | "warn";
message: string;
}
/**
* Validate that all required hooks are properly configured.
*/
export function validateHooks(pluginRoot: string): HookCheckResult[] {
const results: HookCheckResult[] = [];
const settings = readSettings();
if (!settings) {
results.push({
hookType: HOOK_TYPES.PRE_TOOL_USE,
status: "fail",
message: "Could not read ~/.claude/settings.json",
});
return results;
}
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
// Check PreToolUse
const preToolUse = hooks?.PreToolUse as
| Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>
| undefined;
if (preToolUse && preToolUse.length > 0) {
const hasHook = preToolUse.some((entry) =>
isContextModeHook(entry, HOOK_TYPES.PRE_TOOL_USE),
);
results.push({
hookType: HOOK_TYPES.PRE_TOOL_USE,
status: hasHook ? "pass" : "fail",
message: hasHook
? "PreToolUse hook configured"
: "PreToolUse exists but does not point to pretooluse.mjs",
});
} else {
results.push({
hookType: HOOK_TYPES.PRE_TOOL_USE,
status: "fail",
message: "No PreToolUse hooks found",
});
}
// Check SessionStart
const sessionStart = hooks?.SessionStart as
| Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>
| undefined;
if (sessionStart && sessionStart.length > 0) {
const hasHook = sessionStart.some((entry) =>
isContextModeHook(entry, HOOK_TYPES.SESSION_START),
);
results.push({
hookType: HOOK_TYPES.SESSION_START,
status: hasHook ? "pass" : "fail",
message: hasHook
? "SessionStart hook configured"
: "SessionStart exists but does not point to sessionstart.mjs",
});
} else {
results.push({
hookType: HOOK_TYPES.SESSION_START,
status: "fail",
message: "No SessionStart hooks found",
});
}
return results;
}
// ─────────────────────────────────────────────────────────
// Plugin registration
// ─────────────────────────────────────────────────────────
export interface PluginCheckResult {
status: "pass" | "warn";
pluginKey?: string;
message: string;
}
/** Check if context-mode is registered in enabledPlugins. */
export function checkPluginRegistration(): PluginCheckResult {
const settings = readSettings();
if (!settings) {
return {
status: "warn",
message: "Could not read settings.json",
};
}
const enabledPlugins = settings.enabledPlugins as
| Record<string, boolean>
| undefined;
if (!enabledPlugins) {
return {
status: "warn",
message: "No enabledPlugins section found (might be using standalone MCP mode)",
};
}
const pluginKey = Object.keys(enabledPlugins).find((k) =>
k.startsWith("context-mode"),
);
if (pluginKey && enabledPlugins[pluginKey]) {
return {
status: "pass",
pluginKey,
message: `Plugin enabled: ${pluginKey}`,
};
}
return {
status: "warn",
message:
"context-mode not in enabledPlugins (might be using standalone MCP mode)",
};
}
// ─────────────────────────────────────────────────────────
// Version management
// ─────────────────────────────────────────────────────────
/** Get the installed marketplace/plugin version. */
export function getMarketplaceVersion(): string {
// Primary: read from installed_plugins.json
try {
const ipPath = resolve(
homedir(),
".claude",
"plugins",
"installed_plugins.json",
);
const ipRaw = JSON.parse(readFileSync(ipPath, "utf-8"));
const plugins = ipRaw.plugins ?? {};
for (const [key, entries] of Object.entries(plugins)) {
if (!key.toLowerCase().includes("context-mode")) continue;
const arr = entries as Array<Record<string, unknown>>;
if (arr.length > 0 && typeof arr[0].version === "string") {
return arr[0].version;
}
}
} catch {
/* fallback below */
}
// Fallback: scan common plugin cache locations
const bases = [
resolve(homedir(), ".claude"),
resolve(homedir(), ".config", "claude"),
];
for (const base of bases) {
const cacheDir = resolve(
base,
"plugins",
"cache",
"claude-context-mode",
"context-mode",
);
try {
const entries = readdirSync(cacheDir);
const versions = entries
.filter((e) => /^\d+\.\d+\.\d+/.test(e))
.sort((a, b) => {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
});
if (versions.length > 0) return versions[versions.length - 1];
} catch {
/* continue */
}
}
return "not installed";
}
/** Update installed_plugins.json registry to point to given path and version. */
export function updatePluginRegistry(
pluginRoot: string,
newVersion: string,
): void {
try {
const ipPath = resolve(
homedir(),
".claude",
"plugins",
"installed_plugins.json",
);
const ipRaw = JSON.parse(readFileSync(ipPath, "utf-8"));
for (const [key, entries] of Object.entries(ipRaw.plugins || {})) {
if (!key.toLowerCase().includes("context-mode")) continue;
for (const entry of entries as Array<Record<string, unknown>>) {
entry.installPath = pluginRoot;
entry.version = newVersion;
entry.lastUpdated = new Date().toISOString();
}
}
writeFileSync(ipPath, JSON.stringify(ipRaw, null, 2) + "\n", "utf-8");
} catch {
/* best effort */
}
}
// ─────────────────────────────────────────────────────────
// Hook script permissions
// ─────────────────────────────────────────────────────────
/** Set executable permissions on all hook scripts. Returns paths that were set. */
export function setHookPermissions(pluginRoot: string): string[] {
const set: string[] = [];
for (const [, scriptName] of Object.entries(HOOK_SCRIPTS)) {
const scriptPath = resolve(pluginRoot, "hooks", scriptName);
try {
accessSync(scriptPath, constants.R_OK);
chmodSync(scriptPath, 0o755);
set.push(scriptPath);
} catch {
/* skip missing scripts */
}
}
return set;
}
+108
View File
@@ -0,0 +1,108 @@
/**
* adapters/claude-code/hooks — Claude Code hook definitions and matchers.
*
* Defines the hook types, matchers, and registration format specific to
* Claude Code's hook system. This module is used by:
* - CLI setup/upgrade commands (to configure hooks in settings.json)
* - Doctor command (to validate hook configuration)
* - hooks.json generation
*
* Claude Code hook system reference:
* - Hooks are registered in ~/.claude/settings.json under "hooks" key
* - Each hook type maps to an array of { matcher, hooks } entries
* - matcher: tool name pattern (empty = match all tools)
* - hooks: array of { type: "command", command: "..." }
* - Input: JSON on stdin
* - Output: JSON on stdout (or empty for passthrough)
*/
// ─────────────────────────────────────────────────────────
// Hook type constants
// ─────────────────────────────────────────────────────────
/** Claude Code hook types. */
export const HOOK_TYPES = {
PRE_TOOL_USE: "PreToolUse",
POST_TOOL_USE: "PostToolUse",
PRE_COMPACT: "PreCompact",
SESSION_START: "SessionStart",
USER_PROMPT_SUBMIT: "UserPromptSubmit",
} as const;
export type HookType = (typeof HOOK_TYPES)[keyof typeof HOOK_TYPES];
// ─────────────────────────────────────────────────────────
// PreToolUse matchers
// ─────────────────────────────────────────────────────────
/** Tools that context-mode's PreToolUse hook intercepts. */
export const PRE_TOOL_USE_MATCHERS = [
"Bash",
"WebFetch",
"Read",
"Grep",
"Task",
"mcp__plugin_context-mode_context-mode__execute",
"mcp__plugin_context-mode_context-mode__execute_file",
"mcp__plugin_context-mode_context-mode__batch_execute",
] as const;
/**
* Combined matcher pattern for settings.json (pipe-separated).
* Used by the upgrade command when writing a single consolidated entry.
*/
export const PRE_TOOL_USE_MATCHER_PATTERN = PRE_TOOL_USE_MATCHERS.join("|");
// ─────────────────────────────────────────────────────────
// Hook script file names
// ─────────────────────────────────────────────────────────
/** Map of hook types to their script file names. */
export const HOOK_SCRIPTS: Record<HookType, string> = {
PreToolUse: "pretooluse.mjs",
PostToolUse: "posttooluse.mjs",
PreCompact: "precompact.mjs",
SessionStart: "sessionstart.mjs",
UserPromptSubmit: "userpromptsubmit.mjs",
};
// ─────────────────────────────────────────────────────────
// Hook validation
// ─────────────────────────────────────────────────────────
/** Required hooks that must be configured for context-mode to function. */
export const REQUIRED_HOOKS: HookType[] = [
HOOK_TYPES.PRE_TOOL_USE,
HOOK_TYPES.SESSION_START,
];
/** Optional hooks that enhance functionality but aren't critical. */
export const OPTIONAL_HOOKS: HookType[] = [
HOOK_TYPES.POST_TOOL_USE,
HOOK_TYPES.PRE_COMPACT,
HOOK_TYPES.USER_PROMPT_SUBMIT,
];
/**
* Check if a hook entry points to a context-mode hook script.
*/
export function isContextModeHook(
entry: { hooks?: Array<{ command?: string }> },
hookType: HookType,
): boolean {
const scriptName = HOOK_SCRIPTS[hookType];
return (
entry.hooks?.some((h) => h.command?.includes(scriptName)) ?? false
);
}
/**
* Build the hook command string for a given hook type and plugin root.
*/
export function buildHookCommand(
hookType: HookType,
pluginRoot: string,
): string {
const scriptName = HOOK_SCRIPTS[hookType];
return `node ${pluginRoot}/hooks/${scriptName}`;
}
+301
View File
@@ -0,0 +1,301 @@
/**
* adapters/claude-code — Claude Code platform adapter.
*
* Implements HookAdapter for Claude Code's JSON stdin/stdout hook paradigm.
*
* Claude Code hook specifics:
* - I/O: JSON on stdin, JSON on stdout
* - Arg modification: `updatedInput` field in response
* - Blocking: `permissionDecision: "deny"` in response
* - PostToolUse output: `updatedMCPToolOutput` field
* - PreCompact: stdout on exit 0
* - Session ID: transcript_path UUID > session_id > CLAUDE_SESSION_ID > ppid
* - Config: ~/.claude/settings.json
* - Session dir: ~/.claude/context-mode/sessions/
*/
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve, join } from "node:path";
import { homedir } from "node:os";
import type {
HookAdapter,
HookParadigm,
PlatformCapabilities,
PreToolUseEvent,
PostToolUseEvent,
PreCompactEvent,
SessionStartEvent,
PreToolUseResponse,
PostToolUseResponse,
PreCompactResponse,
SessionStartResponse,
HookRegistration,
} from "../types.js";
// ─────────────────────────────────────────────────────────
// Claude Code raw input types
// ─────────────────────────────────────────────────────────
interface ClaudeCodeHookInput {
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_output?: string;
is_error?: boolean;
session_id?: string;
transcript_path?: string;
source?: string;
}
// ─────────────────────────────────────────────────────────
// Adapter implementation
// ─────────────────────────────────────────────────────────
export class ClaudeCodeAdapter implements HookAdapter {
readonly name = "Claude Code";
readonly paradigm: HookParadigm = "json-stdio";
readonly capabilities: PlatformCapabilities = {
preToolUse: true,
postToolUse: true,
preCompact: true,
sessionStart: true,
canModifyArgs: true,
canModifyOutput: true,
canInjectSessionContext: true,
};
// ── Input parsing ──────────────────────────────────────
parsePreToolUseInput(raw: unknown): PreToolUseEvent {
const input = raw as ClaudeCodeHookInput;
return {
toolName: input.tool_name ?? "",
toolInput: input.tool_input ?? {},
sessionId: this.extractSessionId(input),
projectDir: process.env.CLAUDE_PROJECT_DIR,
raw,
};
}
parsePostToolUseInput(raw: unknown): PostToolUseEvent {
const input = raw as ClaudeCodeHookInput;
return {
toolName: input.tool_name ?? "",
toolInput: input.tool_input ?? {},
toolOutput: input.tool_output,
isError: input.is_error,
sessionId: this.extractSessionId(input),
projectDir: process.env.CLAUDE_PROJECT_DIR,
raw,
};
}
parsePreCompactInput(raw: unknown): PreCompactEvent {
const input = raw as ClaudeCodeHookInput;
return {
sessionId: this.extractSessionId(input),
projectDir: process.env.CLAUDE_PROJECT_DIR,
raw,
};
}
parseSessionStartInput(raw: unknown): SessionStartEvent {
const input = raw as ClaudeCodeHookInput;
const rawSource = input.source ?? "startup";
let source: SessionStartEvent["source"];
switch (rawSource) {
case "compact":
source = "compact";
break;
case "resume":
source = "resume";
break;
case "clear":
source = "clear";
break;
default:
source = "startup";
}
return {
sessionId: this.extractSessionId(input),
source,
projectDir: process.env.CLAUDE_PROJECT_DIR,
raw,
};
}
// ── Response formatting ────────────────────────────────
formatPreToolUseResponse(response: PreToolUseResponse): unknown {
if (response.decision === "deny") {
return {
permissionDecision: "deny",
reason: response.reason ?? "Blocked by context-mode hook",
};
}
if (response.decision === "modify" && response.updatedInput) {
return { updatedInput: response.updatedInput };
}
// "allow" — return null/undefined for passthrough
return undefined;
}
formatPostToolUseResponse(response: PostToolUseResponse): unknown {
const result: Record<string, unknown> = {};
if (response.additionalContext) {
result.additionalContext = response.additionalContext;
}
if (response.updatedOutput) {
result.updatedMCPToolOutput = response.updatedOutput;
}
return Object.keys(result).length > 0 ? result : undefined;
}
formatPreCompactResponse(response: PreCompactResponse): unknown {
// Claude Code: stdout content on exit 0 is injected as context
return response.context ?? "";
}
formatSessionStartResponse(response: SessionStartResponse): unknown {
// Claude Code: stdout content is injected as additional context
return response.context ?? "";
}
// ── Configuration ──────────────────────────────────────
getSettingsPath(): string {
return resolve(homedir(), ".claude", "settings.json");
}
getSessionDir(): string {
const dir = join(homedir(), ".claude", "context-mode", "sessions");
mkdirSync(dir, { recursive: true });
return dir;
}
getSessionDBPath(projectDir: string): string {
const hash = createHash("sha256")
.update(projectDir)
.digest("hex")
.slice(0, 16);
return join(this.getSessionDir(), `${hash}.db`);
}
getSessionEventsPath(projectDir: string): string {
const hash = createHash("sha256")
.update(projectDir)
.digest("hex")
.slice(0, 16);
return join(this.getSessionDir(), `${hash}-events.md`);
}
generateHookConfig(pluginRoot: string): HookRegistration {
const preToolUseCommand = `node ${pluginRoot}/hooks/pretooluse.mjs`;
const preToolUseMatchers = [
"Bash",
"WebFetch",
"Read",
"Grep",
"Task",
"mcp__plugin_context-mode_context-mode__execute",
"mcp__plugin_context-mode_context-mode__execute_file",
"mcp__plugin_context-mode_context-mode__batch_execute",
];
return {
PreToolUse: preToolUseMatchers.map((matcher) => ({
matcher,
hooks: [{ type: "command", command: preToolUseCommand }],
})),
PostToolUse: [
{
matcher: "",
hooks: [
{
type: "command",
command: `node ${pluginRoot}/hooks/posttooluse.mjs`,
},
],
},
],
PreCompact: [
{
matcher: "",
hooks: [
{
type: "command",
command: `node ${pluginRoot}/hooks/precompact.mjs`,
},
],
},
],
UserPromptSubmit: [
{
matcher: "",
hooks: [
{
type: "command",
command: `node ${pluginRoot}/hooks/userpromptsubmit.mjs`,
},
],
},
],
SessionStart: [
{
matcher: "",
hooks: [
{
type: "command",
command: `node ${pluginRoot}/hooks/sessionstart.mjs`,
},
],
},
],
};
}
readSettings(): Record<string, unknown> | null {
try {
const raw = readFileSync(this.getSettingsPath(), "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return null;
}
}
writeSettings(settings: Record<string, unknown>): void {
writeFileSync(
this.getSettingsPath(),
JSON.stringify(settings, null, 2) + "\n",
"utf-8",
);
}
// ── Internal helpers ───────────────────────────────────
/**
* Extract session ID from Claude Code hook input.
* Priority: transcript_path UUID > session_id field > CLAUDE_SESSION_ID env > ppid fallback.
*/
private extractSessionId(input: ClaudeCodeHookInput): string {
if (input.transcript_path) {
const match = input.transcript_path.match(
/([a-f0-9-]{36})\.jsonl$/,
);
if (match) return match[1];
}
if (input.session_id) return input.session_id;
if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
return `pid-${process.ppid}`;
}
}
/**
* Singleton adapter instance.
* Use this for all Claude Code platform operations.
*/
export const claudeCodeAdapter = new ClaudeCodeAdapter();
+221
View File
@@ -0,0 +1,221 @@
/**
* adapters/types — Platform adapter interface for multi-platform hook support.
*
* Defines the contract that each platform adapter must implement.
* Three paradigms exist across supported platforms:
* A) JSON stdin/stdout — Claude Code, Gemini CLI, VS Code Copilot, Copilot CLI, Cursor
* B) TS Plugin Functions — OpenCode
* C) MCP-only (no hooks) — Codex CLI
*
* The MCP server layer is 100% portable and needs no adapter.
* Only the hook layer requires platform-specific adapters.
*/
// ─────────────────────────────────────────────────────────
// Hook paradigm
// ─────────────────────────────────────────────────────────
export type HookParadigm = "json-stdio" | "ts-plugin" | "mcp-only";
// ─────────────────────────────────────────────────────────
// Platform capabilities
// ─────────────────────────────────────────────────────────
export interface PlatformCapabilities {
/** Platform supports PreToolUse / BeforeTool / tool.execute.before hooks. */
preToolUse: boolean;
/** Platform supports PostToolUse / AfterTool / tool.execute.after hooks. */
postToolUse: boolean;
/** Platform supports PreCompact / PreCompress / session.compacting hooks. */
preCompact: boolean;
/** Platform supports SessionStart / session.created hooks. */
sessionStart: boolean;
/** Platform allows modifying tool input arguments via hooks. */
canModifyArgs: boolean;
/** Platform allows modifying tool output via PostToolUse hooks. */
canModifyOutput: boolean;
/** Platform allows injecting context during session start or compaction. */
canInjectSessionContext: boolean;
}
// ─────────────────────────────────────────────────────────
// Normalized hook event types
// ─────────────────────────────────────────────────────────
/** Normalized PreToolUse event — platform-agnostic representation. */
export interface PreToolUseEvent {
/** Tool name being invoked (e.g., "Bash", "Read", "WebFetch"). */
toolName: string;
/** Tool input arguments as key-value pairs. */
toolInput: Record<string, unknown>;
/** Session ID extracted by the adapter. */
sessionId: string;
/** Project directory (if available). */
projectDir?: string;
/** Raw platform-specific input (for passthrough if needed). */
raw: unknown;
}
/** Normalized PostToolUse event — platform-agnostic representation. */
export interface PostToolUseEvent {
/** Tool name that was invoked. */
toolName: string;
/** Tool input arguments. */
toolInput: Record<string, unknown>;
/** Tool output/response (if available). */
toolOutput?: string;
/** Whether the tool call resulted in an error. */
isError?: boolean;
/** Session ID extracted by the adapter. */
sessionId: string;
/** Project directory (if available). */
projectDir?: string;
/** Raw platform-specific input. */
raw: unknown;
}
/** Normalized PreCompact event. */
export interface PreCompactEvent {
/** Session ID. */
sessionId: string;
/** Project directory (if available). */
projectDir?: string;
/** Raw platform-specific input. */
raw: unknown;
}
/** Normalized SessionStart event. */
export interface SessionStartEvent {
/** Session ID. */
sessionId: string;
/** Lifecycle source: fresh start, compaction, resume, or clear. */
source: "startup" | "compact" | "resume" | "clear";
/** Project directory (if available). */
projectDir?: string;
/** Raw platform-specific input. */
raw: unknown;
}
// ─────────────────────────────────────────────────────────
// Hook response types
// ─────────────────────────────────────────────────────────
/** Response from PreToolUse hook — can block, modify, or pass through. */
export interface PreToolUseResponse {
/** "allow" = pass through, "deny" = block tool execution, "modify" = change input. */
decision: "allow" | "deny" | "modify";
/** Reason for denial (shown to the model). */
reason?: string;
/** Modified tool input (only when decision = "modify"). */
updatedInput?: Record<string, unknown>;
}
/** Response from PostToolUse hook — can inject context or modify output. */
export interface PostToolUseResponse {
/** Additional context to inject after tool output. */
additionalContext?: string;
/** Modified tool output (if platform supports it). */
updatedOutput?: string;
}
/** Response from PreCompact hook — injects context before compaction. */
export interface PreCompactResponse {
/** Context to preserve across compaction. */
context?: string;
}
/** Response from SessionStart hook — injects context at session start. */
export interface SessionStartResponse {
/** Context to inject at session start. */
context?: string;
}
// ─────────────────────────────────────────────────────────
// Hook config types
// ─────────────────────────────────────────────────────────
/** A single hook entry in platform configuration. */
export interface HookEntry {
/** Tool matcher pattern (empty = match all). */
matcher: string;
/** Hook commands/handlers to execute. */
hooks: Array<{
type: string;
command: string;
}>;
}
/** Hook registration map — maps hook types to their entries. */
export type HookRegistration = Record<string, HookEntry[]>;
// ─────────────────────────────────────────────────────────
// Adapter interface
// ─────────────────────────────────────────────────────────
/**
* HookAdapter — contract for platform-specific hook implementations.
*
* Each supported platform (Claude Code, Gemini CLI, OpenCode, etc.)
* provides an adapter that normalizes its hook I/O into a common format.
*/
export interface HookAdapter {
/** Human-readable platform name (e.g., "Claude Code", "Gemini CLI"). */
readonly name: string;
/** Hook I/O paradigm used by this platform. */
readonly paradigm: HookParadigm;
/** What this platform supports. */
readonly capabilities: PlatformCapabilities;
// ── Input parsing ──────────────────────────────────────
/** Parse raw PreToolUse input into normalized form. */
parsePreToolUseInput(raw: unknown): PreToolUseEvent;
/** Parse raw PostToolUse input into normalized form. */
parsePostToolUseInput(raw: unknown): PostToolUseEvent;
/** Parse raw PreCompact input (optional — not all platforms support it). */
parsePreCompactInput?(raw: unknown): PreCompactEvent;
/** Parse raw SessionStart input (optional — not all platforms support it). */
parseSessionStartInput?(raw: unknown): SessionStartEvent;
// ── Response formatting ────────────────────────────────
/** Format a PreToolUse response into platform-specific output. */
formatPreToolUseResponse(response: PreToolUseResponse): unknown;
/** Format a PostToolUse response into platform-specific output. */
formatPostToolUseResponse(response: PostToolUseResponse): unknown;
/** Format a PreCompact response into platform-specific output. */
formatPreCompactResponse?(response: PreCompactResponse): unknown;
/** Format a SessionStart response into platform-specific output. */
formatSessionStartResponse?(response: SessionStartResponse): unknown;
// ── Configuration ──────────────────────────────────────
/** Path to the platform's settings file (e.g., ~/.claude/settings.json). */
getSettingsPath(): string;
/** Directory where session data is stored. */
getSessionDir(): string;
/** Compute per-project session DB path. */
getSessionDBPath(projectDir: string): string;
/** Compute per-project session events file path. */
getSessionEventsPath(projectDir: string): string;
/** Generate hook registration config for this platform. */
generateHookConfig(pluginRoot: string): HookRegistration;
/** Read current platform settings. */
readSettings(): Record<string, unknown> | null;
/** Write platform settings. */
writeSettings(settings: Record<string, unknown>): void;
}
+58 -249
View File
@@ -13,10 +13,9 @@
import * as p from "@clack/prompts";
import color from "picocolors";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, copyFileSync, cpSync, chmodSync, accessSync, readdirSync, rmSync, constants } from "node:fs";
import { readFileSync, cpSync, accessSync, readdirSync, rmSync, constants } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import {
detectRuntimes,
getRuntimeSummary,
@@ -24,6 +23,21 @@ import {
getAvailableLanguages,
} from "./runtime.js";
// ── Adapter imports ──────────────────────────────────────
import {
readSettings,
getSettingsPath,
backupSettings,
configureHook,
validateHooks,
checkPluginRegistration,
getMarketplaceVersion,
updatePluginRegistry,
setHookPermissions,
writeSettings,
} from "./adapters/claude-code/config.js";
import { HOOK_TYPES } from "./adapters/claude-code/hooks.js";
const args = process.argv.slice(2);
if (args[0] === "setup") {
@@ -52,23 +66,6 @@ function getPluginRoot(): string {
return resolve(__dirname, "..");
}
function getSettingsPath(): string {
return resolve(homedir(), ".claude", "settings.json");
}
function readSettings(): Record<string, unknown> | null {
try {
const raw = readFileSync(getSettingsPath(), "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return null;
}
}
function getHookScriptPath(): string {
return resolve(getPluginRoot(), "hooks", "pretooluse.mjs");
}
function getLocalVersion(): string {
try {
const pkg = JSON.parse(readFileSync(resolve(getPluginRoot(), "package.json"), "utf-8"));
@@ -89,50 +86,6 @@ async function fetchLatestVersion(): Promise<string> {
}
}
function getMarketplaceVersion(): string {
// Primary: read from installed_plugins.json (source of truth for Claude Code)
try {
const ipPath = resolve(homedir(), ".claude", "plugins", "installed_plugins.json");
const ipRaw = JSON.parse(readFileSync(ipPath, "utf-8"));
const plugins = ipRaw.plugins ?? {};
for (const [key, entries] of Object.entries(plugins)) {
if (!key.toLowerCase().includes("context-mode")) continue;
const arr = entries as Array<Record<string, unknown>>;
if (arr.length > 0 && typeof arr[0].version === "string") {
return arr[0].version;
}
}
} catch { /* fallback below */ }
// Fallback: read from own package.json
const localVer = getLocalVersion();
if (localVer !== "unknown") return localVer;
// Last resort: scan common plugin cache locations
const bases = [
resolve(homedir(), ".claude"),
resolve(homedir(), ".config", "claude"),
];
for (const base of bases) {
const cacheDir = resolve(base, "plugins", "cache", "claude-context-mode", "context-mode");
try {
const entries = readdirSync(cacheDir);
const versions = entries
.filter((e) => /^\d+\.\d+\.\d+/.test(e))
.sort((a, b) => {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
});
if (versions.length > 0) return versions[versions.length - 1];
} catch { /* continue */ }
}
return "not installed";
}
function semverGt(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
@@ -232,68 +185,26 @@ async function doctor(): Promise<number> {
}
}
// Hooks installed
// Hooks installed — using adapter validation
p.log.step("Checking hooks configuration...");
const settings = readSettings();
const hookScriptPath = getHookScriptPath();
const pluginRoot = getPluginRoot();
const hookResults = validateHooks(pluginRoot);
if (settings) {
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
const preToolUse = hooks?.PreToolUse as Array<{ matcher?: string; hooks?: Array<{ command?: string }> }> | undefined;
if (preToolUse && preToolUse.length > 0) {
const hasCorrectHook = preToolUse.some((entry) =>
entry.hooks?.some((h) => h.command?.includes("pretooluse.mjs")),
);
if (hasCorrectHook) {
p.log.success(color.green("Hooks installed: PASS") + " — PreToolUse hook configured");
} else {
p.log.error(
color.red("Hooks installed: FAIL") +
" — PreToolUse exists but does not point to pretooluse.mjs" +
color.dim("\n Run: npx context-mode upgrade"),
);
}
for (const result of hookResults) {
if (result.status === "pass") {
p.log.success(color.green(`${result.hookType} hook: PASS`) + ` — ${result.message}`);
} else {
p.log.error(
color.red("Hooks installed: FAIL") +
" — No PreToolUse hooks found" +
color.red(`${result.hookType} hook: FAIL`) +
` — ${result.message}` +
color.dim("\n Run: npx context-mode upgrade"),
);
}
// Check SessionStart hook
const sessionStart = hooks?.SessionStart as Array<{ matcher?: string; hooks?: Array<{ command?: string }> }> | undefined;
if (sessionStart && sessionStart.length > 0) {
const hasSessionHook = sessionStart.some((entry) =>
entry.hooks?.some((h) => h.command?.includes("sessionstart.mjs")),
);
if (hasSessionHook) {
p.log.success(color.green("SessionStart hook: PASS") + " — SessionStart hook configured");
} else {
p.log.error(
color.red("SessionStart hook: FAIL") +
" — SessionStart exists but does not point to sessionstart.mjs" +
color.dim("\n Run: npx context-mode upgrade"),
);
}
} else {
p.log.error(
color.red("SessionStart hook: FAIL") +
" — No SessionStart hooks found" +
color.dim("\n Run: npx context-mode upgrade"),
);
}
} else {
p.log.error(
color.red("Hooks installed: FAIL") +
" — Could not read ~/.claude/settings.json" +
color.dim("\n Run: npx context-mode upgrade"),
);
}
// Hook script exists
p.log.step("Checking hook script...");
const hookScriptPath = resolve(pluginRoot, "hooks", "pretooluse.mjs");
try {
accessSync(hookScriptPath, constants.R_OK);
p.log.success(color.green("Hook script exists: PASS") + color.dim(` — ${hookScriptPath}`));
@@ -304,34 +215,15 @@ async function doctor(): Promise<number> {
);
}
// Plugin enabled
// Plugin enabled — using adapter check
p.log.step("Checking plugin registration...");
if (settings) {
const enabledPlugins = settings.enabledPlugins as Record<string, boolean> | undefined;
if (enabledPlugins) {
const pluginKey = Object.keys(enabledPlugins).find((k) =>
k.startsWith("context-mode"),
);
if (pluginKey && enabledPlugins[pluginKey]) {
p.log.success(color.green("Plugin enabled: PASS") + color.dim(` — ${pluginKey}`));
} else {
p.log.warn(
color.yellow("Plugin enabled: WARN") +
" — context-mode not in enabledPlugins" +
color.dim(" (might be using standalone MCP mode)"),
);
}
} else {
p.log.warn(
color.yellow("Plugin enabled: WARN") +
" — no enabledPlugins section found" +
color.dim(" (might be using standalone MCP mode)"),
);
}
const pluginCheck = checkPluginRegistration();
if (pluginCheck.status === "pass") {
p.log.success(color.green("Plugin enabled: PASS") + color.dim(` — ${pluginCheck.pluginKey}`));
} else {
p.log.warn(
color.yellow("Plugin enabled: WARN") +
" — could not read settings.json",
` — ${pluginCheck.message}`,
);
}
@@ -439,7 +331,6 @@ async function upgrade() {
p.intro(color.bgCyan(color.black(" context-mode upgrade ")));
let pluginRoot = getPluginRoot();
const settingsPath = getSettingsPath();
const changes: string[] = [];
const s = p.spinner();
@@ -520,21 +411,9 @@ async function upgrade() {
}
s.stop(color.green(`Updated in-place to v${newVersion}`));
// Fix registry to point back to this pluginRoot (self-heal may have changed it)
try {
const ipPath = resolve(homedir(), ".claude", "plugins", "installed_plugins.json");
const ipRaw = JSON.parse(readFileSync(ipPath, "utf-8"));
for (const [key, entries] of Object.entries(ipRaw.plugins || {})) {
if (!key.toLowerCase().includes("context-mode")) continue;
for (const entry of (entries as Array<Record<string, unknown>>)) {
entry.installPath = pluginRoot;
entry.version = newVersion;
entry.lastUpdated = new Date().toISOString();
}
}
writeFileSync(ipPath, JSON.stringify(ipRaw, null, 2) + "\n", "utf-8");
p.log.info(color.dim(" Registry synced to " + pluginRoot));
} catch { /* best effort */ }
// Fix registry — using adapter
updatePluginRegistry(pluginRoot, newVersion);
p.log.info(color.dim(" Registry synced to " + pluginRoot));
// Install production deps (rebuild native modules if needed)
s.start("Installing production dependencies");
@@ -580,105 +459,38 @@ async function upgrade() {
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
// Step 3: Backup settings.json
// Step 3: Backup settings.json — using adapter
p.log.step("Backing up settings.json...");
try {
accessSync(settingsPath, constants.R_OK);
const backupPath = settingsPath + ".bak";
copyFileSync(settingsPath, backupPath);
const backupPath = backupSettings();
if (backupPath) {
p.log.success(color.green("Backup created") + color.dim(" -> " + backupPath));
changes.push("Backed up settings.json");
} catch {
} else {
p.log.warn(
color.yellow("No existing settings.json to backup") +
" — a new one will be created",
);
}
// Step 4: Fix hooks
p.log.step("Configuring PreToolUse hooks...");
const hookScriptPath = toUnixPath(resolve(pluginRoot, "hooks", "pretooluse.mjs"));
// Step 4: Fix hooks — using adapter
p.log.step("Configuring hooks...");
const settings = readSettings() ?? {};
const desiredHookEntry = {
matcher: "Bash|Read|Grep|WebFetch|Task|mcp__plugin_context-mode_context-mode__execute|mcp__plugin_context-mode_context-mode__execute_file|mcp__plugin_context-mode_context-mode__batch_execute",
hooks: [
{
type: "command",
command: "node " + hookScriptPath,
},
],
};
const hookTypes = [
HOOK_TYPES.PRE_TOOL_USE,
HOOK_TYPES.SESSION_START,
] as const;
const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
const existingPreToolUse = hooks.PreToolUse as Array<Record<string, unknown>> | undefined;
if (existingPreToolUse && Array.isArray(existingPreToolUse)) {
const existingIdx = existingPreToolUse.findIndex((entry) => {
const entryHooks = entry.hooks as Array<{ command?: string }> | undefined;
return entryHooks?.some((h) => h.command?.includes("pretooluse.mjs"));
});
if (existingIdx >= 0) {
existingPreToolUse[existingIdx] = desiredHookEntry;
p.log.info(color.dim("Updated existing PreToolUse hook entry"));
changes.push("Updated existing PreToolUse hook entry");
} else {
existingPreToolUse.push(desiredHookEntry);
p.log.info(color.dim("Added PreToolUse hook entry"));
changes.push("Added PreToolUse hook entry to existing hooks");
}
hooks.PreToolUse = existingPreToolUse;
} else {
hooks.PreToolUse = [desiredHookEntry];
p.log.info(color.dim("Created PreToolUse hooks section"));
changes.push("Created PreToolUse hooks section");
for (const hookType of hookTypes) {
const result = configureHook(settings, hookType, pluginRoot);
p.log.info(color.dim(` ${result}`));
changes.push(result);
}
// --- SessionStart hook ---
p.log.step("Configuring SessionStart hook...");
const sessionHookScriptPath = resolve(pluginRoot, "hooks", "sessionstart.mjs");
const desiredSessionHookEntry = {
matcher: "",
hooks: [
{
type: "command",
command: "node " + sessionHookScriptPath,
},
],
};
const existingSessionStart = hooks.SessionStart as Array<Record<string, unknown>> | undefined;
if (existingSessionStart && Array.isArray(existingSessionStart)) {
const existingSessionIdx = existingSessionStart.findIndex((entry) => {
const entryHooks = entry.hooks as Array<{ command?: string }> | undefined;
return entryHooks?.some((h) => h.command?.includes("sessionstart.mjs"));
});
if (existingSessionIdx >= 0) {
existingSessionStart[existingSessionIdx] = desiredSessionHookEntry;
p.log.info(color.dim("Updated existing SessionStart hook entry"));
changes.push("Updated existing SessionStart hook entry");
} else {
existingSessionStart.push(desiredSessionHookEntry);
p.log.info(color.dim("Added SessionStart hook entry"));
changes.push("Added SessionStart hook entry to existing hooks");
}
hooks.SessionStart = existingSessionStart;
} else {
hooks.SessionStart = [desiredSessionHookEntry];
p.log.info(color.dim("Created SessionStart hooks section"));
changes.push("Created SessionStart hooks section");
}
settings.hooks = hooks;
// Write updated settings
// Write updated settings — using adapter
try {
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
p.log.success(color.green("Hooks configured") + color.dim(" -> " + settingsPath));
writeSettings(settings);
p.log.success(color.green("Hooks configured") + color.dim(" -> " + getSettingsPath()));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
p.log.error(color.red("Failed to write settings.json") + " — " + message);
@@ -686,17 +498,16 @@ async function upgrade() {
process.exit(1);
}
// Step 5: Set hook script permissions
// Step 5: Set hook script permissions — using adapter
p.log.step("Setting hook script permissions...");
try {
accessSync(hookScriptPath, constants.R_OK);
chmodSync(hookScriptPath, 0o755);
p.log.success(color.green("Permissions set") + color.dim(" — chmod +x " + hookScriptPath));
changes.push("Set pretooluse.mjs as executable");
} catch {
const permSet = setHookPermissions(pluginRoot);
if (permSet.length > 0) {
p.log.success(color.green("Permissions set") + color.dim(` — ${permSet.length} hook script(s)`));
changes.push(`Set ${permSet.length} hook scripts as executable`);
} else {
p.log.error(
color.red("Hook script not found") +
color.dim(" — expected at " + hookScriptPath),
color.red("No hook scripts found") +
color.dim(" — expected in " + resolve(pluginRoot, "hooks")),
);
}
@@ -893,5 +704,3 @@ async function setup() {
color.dim(available.length + " languages ready."),
);
}
@@ -8,9 +8,9 @@ import {
type RuntimeMap,
type Language,
} from "./runtime.js";
import { smartTruncate } from "@context-mode/shared/truncate";
export type { ExecResult } from "@context-mode/shared/types";
import type { ExecResult } from "@context-mode/shared/types";
import { smartTruncate } from "./truncate.js";
export type { ExecResult } from "./types.js";
import type { ExecResult } from "./types.js";
const isWin = process.platform === "win32";
@@ -6,9 +6,9 @@
* the shared package.
*/
import { SQLiteBase, defaultDBPath } from "@context-mode/shared/db-base";
import type { PreparedStatement } from "@context-mode/shared/db-base";
import type { SessionEvent } from "@context-mode/shared/types";
import { SQLiteBase, defaultDBPath } from "../db-base.js";
import type { PreparedStatement } from "../db-base.js";
import type { SessionEvent } from "../types.js";
import { createHash } from "node:crypto";
// ─────────────────────────────────────────────────────────
@@ -11,7 +11,7 @@
* P3-P4 (subagent, skill, role, data, intent): 15% = ~308 bytes
*/
import { escapeXML, truncateString } from "@context-mode/shared/truncate";
import { escapeXML, truncateString } from "../truncate.js";
// ── Types ────────────────────────────────────────────────────────────────────
+4 -4
View File
@@ -9,8 +9,8 @@
*/
import type { Database as DatabaseInstance } from "better-sqlite3";
import { loadDatabase, applyWALPragmas } from "@context-mode/shared/db-base";
import type { PreparedStatement } from "@context-mode/shared/db-base";
import { loadDatabase, applyWALPragmas } from "./db-base.js";
import type { PreparedStatement } from "./db-base.js";
import { readFileSync, readdirSync, unlinkSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -25,8 +25,8 @@ interface Chunk {
hasCode: boolean;
}
import type { IndexResult, SearchResult, StoreStats } from "@context-mode/shared/types";
export type { IndexResult, SearchResult, StoreStats } from "@context-mode/shared/types";
import type { IndexResult, SearchResult, StoreStats } from "./types.js";
export type { IndexResult, SearchResult, StoreStats } from "./types.js";
// ─────────────────────────────────────────────────────────
// Constants
@@ -3,7 +3,7 @@
*
* Contains interfaces that are genuinely shared between the core (ContentStore,
* PolyglotExecutor) and the session domain (SessionDB, event extraction).
* Import from "@context-mode/shared/types".
* Import from "./types.js".
*/
// ─────────────────────────────────────────────────────────
+1 -1
View File
@@ -87,7 +87,7 @@ if (existsSync(resolve(__dirname, "server.bundle.mjs"))) {
}
if (!existsSync(resolve(__dirname, "build", "server.js"))) {
try {
execSync("npx tsc -b --silent", { cwd: __dirname, stdio: "pipe", timeout: 30000 });
execSync("npx tsc --silent", { cwd: __dirname, stdio: "pipe", timeout: 30000 });
} catch { /* best effort */ }
}
await import("./build/server.js");
+2 -2
View File
@@ -1,10 +1,10 @@
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { PolyglotExecutor } from "../src/executor.js";
import {
detectRuntimes,
getRuntimeSummary,
hasBunRuntime,
type Language,
} from "../packages/core/src/runtime.js";
} from "../src/runtime.js";
const runtimes = detectRuntimes();
const executor = new PolyglotExecutor({ runtimes });
+1 -1
View File
@@ -10,7 +10,7 @@
import { strict as assert } from "node:assert";
import { describe, test } from "vitest";
import { toUnixPath } from "../packages/core/src/cli.js";
import { toUnixPath } from "../src/cli.js";
describe("CLI Hook Path Tests", () => {
test("toUnixPath: converts backslashes to forward slashes", () => {
+2 -2
View File
@@ -8,8 +8,8 @@
* Fixtures in tests/fixtures/ represent real-world data Claude Code encounters daily.
*/
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { detectRuntimes, getRuntimeSummary } from "../packages/core/src/runtime.js";
import { PolyglotExecutor } from "../src/executor.js";
import { detectRuntimes, getRuntimeSummary } from "../src/runtime.js";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
+2 -2
View File
@@ -13,8 +13,8 @@
* This provides honest, reproducible benchmarks for the open-source community.
*/
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { detectRuntimes, getRuntimeSummary } from "../packages/core/src/runtime.js";
import { PolyglotExecutor } from "../src/executor.js";
import { detectRuntimes, getRuntimeSummary } from "../src/runtime.js";
import { readFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
+3 -3
View File
@@ -3,13 +3,13 @@ import { strict as assert } from "node:assert";
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { PolyglotExecutor } from "../src/executor.js";
import {
detectRuntimes,
buildCommand,
getRuntimeSummary,
type RuntimeMap,
} from "../packages/core/src/runtime.js";
} from "../src/runtime.js";
const runtimes = detectRuntimes();
const executor = new PolyglotExecutor({ runtimes });
@@ -1127,7 +1127,7 @@ describe("Windows Shell Support", () => {
});
test("getAvailableLanguages always includes shell", async () => {
const { getAvailableLanguages } = await import("../packages/core/src/runtime.js");
const { getAvailableLanguages } = await import("../src/runtime.js");
const langs = getAvailableLanguages(runtimes);
assert.ok(langs.includes("shell"), `shell should always be in available languages, got: ${langs}`);
});
+2 -2
View File
@@ -9,8 +9,8 @@
import { describe, test } from "vitest";
import { strict as assert } from "node:assert";
import { extractSnippet, positionsFromHighlight } from "../packages/core/src/server.js";
import { ContentStore } from "../packages/core/src/store.js";
import { extractSnippet, positionsFromHighlight } from "../src/server.js";
import { ContentStore } from "../src/store.js";
const STX = "\x02";
const ETX = "\x03";
+1 -1
View File
@@ -14,7 +14,7 @@ import { describe, test, expect } from "vitest";
import { strict as assert } from "node:assert";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ContentStore } from "../packages/core/src/store.js";
import { ContentStore } from "../src/store.js";
function createStore(): ContentStore {
const path = join(
+1 -1
View File
@@ -11,7 +11,7 @@
import { strict as assert } from "node:assert";
import { describe, test } from "vitest";
import { ContentStore } from "../packages/core/src/store.js";
import { ContentStore } from "../src/store.js";
// ─────────────────────────────────────────────────────────
// Smart Truncation simulation (60% head + 40% tail)
+3 -3
View File
@@ -3,9 +3,9 @@
* Tests ALL 5 tools with real-world use cases
*/
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { ContentStore } from "../packages/core/src/store.js";
import { detectRuntimes } from "../packages/core/src/runtime.js";
import { PolyglotExecutor } from "../src/executor.js";
import { ContentStore } from "../src/store.js";
import { detectRuntimes } from "../src/runtime.js";
import { readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
+1 -1
View File
@@ -8,7 +8,7 @@
import { readFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { ContentStore } from "../packages/core/src/store.js";
import { ContentStore } from "../src/store.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixtureDir = join(__dirname, "fixtures");
+2 -2
View File
@@ -3,8 +3,8 @@ import { writeFileSync, mkdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { describe, test, afterAll } from "vitest";
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { detectRuntimes } from "../packages/core/src/runtime.js";
import { PolyglotExecutor } from "../src/executor.js";
import { detectRuntimes } from "../src/runtime.js";
const runtimes = detectRuntimes();
+1 -1
View File
@@ -16,7 +16,7 @@ import { describe, test } from "vitest";
import { strict as assert } from "node:assert";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ContentStore } from "../packages/core/src/store.js";
import { ContentStore } from "../src/store.js";
function createStore(): ContentStore {
const path = join(
+1 -1
View File
@@ -15,7 +15,7 @@ import { describe, test, expect } from "vitest";
import { strict as assert } from "node:assert";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ContentStore } from "../packages/core/src/store.js";
import { ContentStore } from "../src/store.js";
function createStore(): ContentStore {
const path = join(
+1 -1
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { afterAll, describe, test } from "vitest";
import { SessionDB } from "../../packages/session/src/db.js";
import { SessionDB } from "../../src/session/db.js";
const cleanups: Array<() => void> = [];
+1 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { describe, test } from "vitest";
import { extractEvents, extractUserEvents } from "../../packages/session/src/extract.js";
import { extractEvents, extractUserEvents } from "../../src/session/extract.js";
// ════════════════════════════════════════════
// SLICE 1: FILE EVENT EXTRACTION
+3 -3
View File
@@ -3,9 +3,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { afterAll, describe, test } from "vitest";
import { extractEvents, extractUserEvents } from "../../packages/session/src/extract.js";
import { SessionDB } from "../../packages/session/src/db.js";
import { buildResumeSnapshot } from "../../packages/session/src/snapshot.js";
import { extractEvents, extractUserEvents } from "../../src/session/extract.js";
import { SessionDB } from "../../src/session/db.js";
import { buildResumeSnapshot } from "../../src/session/snapshot.js";
const cleanups: Array<() => void> = [];
+1 -1
View File
@@ -10,7 +10,7 @@ import {
renderErrors,
renderIntent,
type StoredEvent,
} from "../../packages/session/src/snapshot.js";
} from "../../src/session/snapshot.js";
// ── Helpers ──
function makeEvent(overrides: Partial<StoredEvent> & Pick<StoredEvent, "type" | "category">): StoredEvent {
+1 -1
View File
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { ContentStore, cleanupStaleDBs } from "../packages/core/src/store.js";
import { ContentStore, cleanupStaleDBs } from "../src/store.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixtureDir = join(__dirname, "fixtures");
+2 -2
View File
@@ -3,8 +3,8 @@ import { strict as assert } from "node:assert";
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { detectRuntimes } from "../packages/core/src/runtime.js";
import { PolyglotExecutor } from "../src/executor.js";
import { detectRuntimes } from "../src/runtime.js";
const runtimes = detectRuntimes();
+1 -1
View File
@@ -15,7 +15,7 @@ import { spawnSync, execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, test } from "vitest";
import { ContentStore } from "../packages/core/src/store.js";
import { ContentStore } from "../src/store.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOOK_PATH = join(__dirname, "..", "hooks", "pretooluse.mjs");
+1 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { describe, test } from "vitest";
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { PolyglotExecutor } from "../src/executor.js";
import { createRequire } from "node:module";
// Resolve turndown path the same way server.ts will
+2 -2
View File
@@ -6,8 +6,8 @@
*/
import { strict as assert } from "node:assert";
import { PolyglotExecutor } from "../packages/core/src/executor.js";
import { detectRuntimes, getRuntimeSummary } from "../packages/core/src/runtime.js";
import { PolyglotExecutor } from "../src/executor.js";
import { detectRuntimes, getRuntimeSummary } from "../src/runtime.js";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}
+15 -6
View File
@@ -1,8 +1,17 @@
{
"files": [],
"references": [
{ "path": "packages/shared" },
{ "path": "packages/core" },
{ "path": "packages/session" }
]
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"]
}