Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -638,31 +638,39 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/

**Install:**

1. Clone the extension:
1. Install context-mode globally:

```bash
git clone https://github.com/mksglu/context-mode.git ~/.pi/extensions/context-mode
cd ~/.pi/extensions/context-mode
npm install
npm run build
npm install -g context-mode
```

2. Install the package into Pi:

```bash
pi install npm:context-mode
```

Alternative — add it manually to `~/.pi/agent/settings.json` (or `.pi/settings.json` for project-level):

```json
{
"packages": ["npm:context-mode"]
}
```

2. Add to `~/.pi/agent/mcp.json` (or `.pi/mcp.json` for project-level):
3. Add to `~/.pi/agent/mcp.json` (or `.pi/mcp.json` for project-level):

```json
{
"mcpServers": {
"context-mode": {
"command": "node",
"args": ["/home/youruser/.pi/extensions/context-mode/node_modules/context-mode/start.mjs"]
"command": "context-mode"
}
}
}
```

> **Note:** JSON does not expand `~`. Replace `/home/youruser` with your actual home directory (run `echo $HOME` to find it).
3. Restart Pi.
4. Restart Pi.

**Verify:** In a Pi session, type `ctx stats`. Context-mode tools should appear and respond.

Expand Down
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,22 @@
"sandbox",
"code-execution",
"fts5",
"bm25"
"bm25",
"pi-package"
],
"repository": {
"type": "git",
"url": "https://github.com/mksglu/context-mode"
},
"homepage": "https://github.com/mksglu/context-mode#readme",
"pi": {
"extensions": [
"./build/pi-extension.js"
],
"skills": [
"./skills"
]
},
"openclaw": {
"extensions": [
"./build/openclaw-plugin.js"
Expand Down
86 changes: 24 additions & 62 deletions src/adapters/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ import { homedir } from "node:os";
import type { PlatformId, DetectionSignal, HookAdapter } from "./types.js";
import { CLIENT_NAME_TO_PLATFORM } from "./client-map.js";

/**
* High-confidence env vars per platform, checked in priority order.
* Single source of truth — consumed by detectPlatform() below and by
* tests that need to clear platform-related env vars deterministically.
*/
export const PLATFORM_ENV_VARS = [
["claude-code", ["CLAUDE_PROJECT_DIR", "CLAUDE_SESSION_ID"]],
["gemini-cli", ["GEMINI_PROJECT_DIR", "GEMINI_CLI"]],
["openclaw", ["OPENCLAW_HOME", "OPENCLAW_CLI"]],
["kilo", ["KILO", "KILO_PID"]],
["opencode", ["OPENCODE", "OPENCODE_PID"]],
["codex", ["CODEX_CI", "CODEX_THREAD_ID"]],
["cursor", ["CURSOR_TRACE_ID", "CURSOR_CLI"]],
["vscode-copilot", ["VSCODE_PID", "VSCODE_CWD"]],
] as const satisfies ReadonlyArray<readonly [PlatformId, readonly string[]]>;

/**
* Detect the current platform by checking env vars and config dirs.
*
Expand Down Expand Up @@ -61,68 +77,14 @@ export function detectPlatform(clientInfo?: { name: string; version?: string }):

// ── High confidence: environment variables ─────────────

if (process.env.CLAUDE_PROJECT_DIR || process.env.CLAUDE_SESSION_ID) {
return {
platform: "claude-code",
confidence: "high",
reason: "CLAUDE_PROJECT_DIR or CLAUDE_SESSION_ID env var set",
};
}

if (process.env.GEMINI_PROJECT_DIR || process.env.GEMINI_CLI) {
return {
platform: "gemini-cli",
confidence: "high",
reason: "GEMINI_PROJECT_DIR or GEMINI_CLI env var set",
};
}

if (process.env.OPENCLAW_HOME || process.env.OPENCLAW_CLI) {
return {
platform: "openclaw",
confidence: "high",
reason: "OPENCLAW_HOME or OPENCLAW_CLI env var set",
};
}

if (process.env.KILO || process.env.KILO_PID) {
return {
platform: "kilo",
confidence: "high",
reason: "KILO or KILO_PID env var set",
};
}

if (process.env.OPENCODE || process.env.OPENCODE_PID) {
return {
platform: "opencode",
confidence: "high",
reason: "OPENCODE or OPENCODE_PID env var set",
};
}

if (process.env.CODEX_CI || process.env.CODEX_THREAD_ID) {
return {
platform: "codex",
confidence: "high",
reason: "CODEX_CI or CODEX_THREAD_ID env var set",
};
}

if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_CLI) {
return {
platform: "cursor",
confidence: "high",
reason: "CURSOR_TRACE_ID or CURSOR_CLI env var set",
};
}

if (process.env.VSCODE_PID || process.env.VSCODE_CWD) {
return {
platform: "vscode-copilot",
confidence: "high",
reason: "VSCODE_PID or VSCODE_CWD env var set",
};
for (const [platform, vars] of PLATFORM_ENV_VARS) {
if (vars.some((v) => process.env[v])) {
return {
platform,
confidence: "high",
reason: `${vars.join(" or ")} env var set`,
};
}
}

// ── Medium confidence: config directory existence ──────
Expand Down
148 changes: 148 additions & 0 deletions tests/adapters/detect-config-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Behavioral tests for the medium-confidence config-directory branch of
* detectPlatform() and the env-var priority chain.
*
* The adjacent detect.test.ts covers env vars, clientInfo, and the
* CONTEXT_MODE_PLATFORM override — but the ~80 lines of `~/.<platform>`
* and `~/.config/<platform>` existsSync checks (detect.ts:128-210) are
* not exercised. These tests mock `node:fs` to force each branch
* deterministically and lock the priority ordering.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { resolve } from "node:path";
import { homedir } from "node:os";

vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return { ...actual, existsSync: vi.fn() };
});

// Imports after vi.mock so the mock is in place before detect.ts resolves fs.
import * as fs from "node:fs";
import { detectPlatform, PLATFORM_ENV_VARS } from "../../src/adapters/detect.js";

const existsSyncMock = vi.mocked(fs.existsSync);

// Derived from detect.ts's source-of-truth list so renames can't drift.
const ALL_PLATFORM_ENV_VARS = [
...PLATFORM_ENV_VARS.flatMap(([, vars]) => [...vars]),
"CONTEXT_MODE_PLATFORM",
];

describe("detectPlatform — config directory branches", () => {
const home = homedir();
let savedEnv: NodeJS.ProcessEnv;

beforeEach(() => {
savedEnv = { ...process.env };
for (const v of ALL_PLATFORM_ENV_VARS) delete process.env[v];
existsSyncMock.mockReset();
});

afterEach(() => {
process.env = savedEnv;
existsSyncMock.mockReset();
});

const forceDir = (target: string) => {
existsSyncMock.mockImplementation(((p: unknown) => p === target) as typeof fs.existsSync);
};

it.each<[string, string]>([
[".claude", "claude-code"],
[".gemini", "gemini-cli"],
[".codex", "codex"],
[".cursor", "cursor"],
[".kiro", "kiro"],
[".pi", "pi"],
[".openclaw", "openclaw"],
])("detects %s → %s at medium confidence", (dir, expected) => {
forceDir(resolve(home, dir));
const signal = detectPlatform();
expect(signal.platform).toBe(expected);
expect(signal.confidence).toBe("medium");
expect(signal.reason).toContain(dir);
});

it.each<[string[], string]>([
[[".config", "kilo"], "kilo"],
[[".config", "opencode"], "opencode"],
[[".config", "zed"], "zed"],
])("detects XDG ~/%s/%s → %s at medium confidence", (segs, expected) => {
forceDir(resolve(home, ...segs));
const signal = detectPlatform();
expect(signal.platform).toBe(expected);
expect(signal.confidence).toBe("medium");
expect(signal.reason).toContain(segs.join("/"));
});

it("falls back to claude-code low-confidence when no dirs exist", () => {
existsSyncMock.mockReturnValue(false);
const signal = detectPlatform();
expect(signal.platform).toBe("claude-code");
expect(signal.confidence).toBe("low");
expect(signal.reason).toContain("No platform detected");
});

it("prefers ~/.claude over ~/.gemini when both dirs exist", () => {
existsSyncMock.mockImplementation((
((p: unknown) =>
p === resolve(home, ".claude") || p === resolve(home, ".gemini")) as typeof fs.existsSync
));
expect(detectPlatform().platform).toBe("claude-code");
});

it("env var wins over a matching config dir", () => {
forceDir(resolve(home, ".claude"));
process.env.CODEX_CI = "1";
const signal = detectPlatform();
expect(signal.platform).toBe("codex");
expect(signal.confidence).toBe("high");
});

it("CONTEXT_MODE_PLATFORM override wins over a matching config dir", () => {
forceDir(resolve(home, ".claude"));
process.env.CONTEXT_MODE_PLATFORM = "antigravity";
expect(detectPlatform().platform).toBe("antigravity");
});
});

describe("detectPlatform — env var priority chain", () => {
let savedEnv: NodeJS.ProcessEnv;

beforeEach(() => {
savedEnv = { ...process.env };
for (const v of ALL_PLATFORM_ENV_VARS) delete process.env[v];
existsSyncMock.mockReturnValue(false);
});

afterEach(() => {
process.env = savedEnv;
existsSyncMock.mockReset();
});

it("CLAUDE beats GEMINI when both envs are set", () => {
process.env.CLAUDE_PROJECT_DIR = "/p";
process.env.GEMINI_CLI = "1";
expect(detectPlatform().platform).toBe("claude-code");
});

it("GEMINI beats OPENCLAW when both envs are set", () => {
process.env.GEMINI_CLI = "1";
process.env.OPENCLAW_HOME = "/h";
expect(detectPlatform().platform).toBe("gemini-cli");
});

it("OPENCLAW beats KILO when both envs are set", () => {
process.env.OPENCLAW_HOME = "/h";
process.env.KILO = "1";
expect(detectPlatform().platform).toBe("openclaw");
});

it("CODEX beats CURSOR when both envs are set", () => {
process.env.CODEX_THREAD_ID = "t";
process.env.CURSOR_TRACE_ID = "tr";
expect(detectPlatform().platform).toBe("codex");
});
});
69 changes: 69 additions & 0 deletions tests/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,75 @@ describe("Temp Cleanup Resilience", () => {
});
});

describe("Background Mode", () => {
test("background: true returns partial output with backgrounded flag", async () => {
const bgExecutor = new PolyglotExecutor({ runtimes });
const r = await bgExecutor.execute({
language: "javascript",
code: `console.log("started"); setInterval(() => {}, 1000);`,
timeout: 500,
background: true,
});
assert.equal(r.backgrounded, true, "Should be marked as backgrounded");
assert.equal(r.timedOut, true, "Background detach fires on timeout");
assert.equal(r.exitCode, 0, "Backgrounded processes return exitCode 0");
assert.ok(r.stdout.includes("started"), "Should capture output before detach");
bgExecutor.cleanupBackgrounded();
}, 10_000);

test("cleanupBackgrounded kills detached process", async () => {
const bgExecutor = new PolyglotExecutor({ runtimes });
const r = await bgExecutor.execute({
language: "javascript",
code: `process.stdout.write(String(process.pid)); setInterval(() => {}, 1000);`,
timeout: 500,
background: true,
});
const pid = parseInt(r.stdout.trim(), 10);
assert.ok(pid > 0, `Expected valid PID, got: "${r.stdout}"`);

let alive = false;
try { process.kill(pid, 0); alive = true; } catch { /* ESRCH */ }
assert.equal(alive, true, "Process should be alive before cleanup");

bgExecutor.cleanupBackgrounded();
await new Promise((r) => setTimeout(r, 300));

alive = false;
try { process.kill(pid, 0); alive = true; } catch { /* ESRCH */ }
assert.equal(alive, false, `Process ${pid} should be dead after cleanup`);
}, 10_000);
});

describe("hardCapBytes Enforcement", () => {
test("kills process when combined output exceeds byte cap", async () => {
const cappedExecutor = new PolyglotExecutor({
runtimes,
hardCapBytes: 1024,
});
const r = await cappedExecutor.execute({
language: "javascript",
code: `for (let i = 0; i < 10000; i++) console.log("x".repeat(100));`,
timeout: 10_000,
});
assert.ok(r.stderr.includes("output capped at"), "Should indicate cap was hit");
assert.notEqual(r.exitCode, 0, "Process should be killed (non-zero exit)");
}, 15_000);

test("stderr contributes to byte cap", async () => {
const cappedExecutor = new PolyglotExecutor({
runtimes,
hardCapBytes: 1024,
});
const r = await cappedExecutor.execute({
language: "javascript",
code: `for (let i = 0; i < 10000; i++) console.error("e".repeat(100));`,
timeout: 10_000,
});
assert.ok(r.stderr.includes("output capped at"), "stderr should trigger cap");
}, 15_000);
});

describe("Windows Shell Support", () => {
test("shell runtime is always a non-empty string", async () => {
assert.ok(
Expand Down
Loading
Loading