mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
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:
co-authored by
Claude Opus 4.6
parent
a3c579e4e1
commit
49e0d7f135
@@ -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
@@ -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
@@ -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 +0,0 @@
|
||||
dist/
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "@context-mode/core",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@context-mode/shared": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "../../build", "rootDir": "src", "composite": true },
|
||||
"references": [{ "path": "../shared" }],
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
dist/
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"references": [{ "path": "../shared" }],
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
dist/
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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})`);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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".
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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}`);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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,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> = [];
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user