kojee-mcp 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -7
- package/dist/chunk-36DMIXH7.js +51 -0
- package/dist/chunk-E7TE4QZD.js +33 -0
- package/dist/chunk-VZVGTHGF.js +142 -0
- package/dist/chunk-WHTH6WBP.js +72 -0
- package/dist/{chunk-QKAUM3TR.js → chunk-ZGVUM4AG.js} +409 -24
- package/dist/cli.js +190 -16
- package/dist/event-log-ETWR6PPY.js +112 -0
- package/dist/event-queue-5YVJFR3E.js +43 -0
- package/dist/hook-server-43QS7L7P.js +71 -0
- package/dist/index.d.ts +0 -13
- package/dist/index.js +2 -1
- package/dist/install-WV25CRU2.js +182 -0
- package/dist/paired-config-OAR3O3XY.js +10 -0
- package/dist/session-discovery-WSHLR4OV.js +26 -0
- package/dist/stop-hook-5XU3EQAE.js +76 -0
- package/dist/user-prompt-submit-hook-WSRIJVF4.js +54 -0
- package/package.json +9 -13
package/dist/cli.js
CHANGED
|
@@ -1,37 +1,211 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
AuthModule,
|
|
3
4
|
startProxy
|
|
4
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-ZGVUM4AG.js";
|
|
6
|
+
import {
|
|
7
|
+
loadPairedConfig,
|
|
8
|
+
pairedConfigPath,
|
|
9
|
+
savePairedConfig
|
|
10
|
+
} from "./chunk-E7TE4QZD.js";
|
|
11
|
+
import "./chunk-36DMIXH7.js";
|
|
5
12
|
|
|
6
13
|
// src/cli.ts
|
|
7
14
|
import { Command } from "commander";
|
|
8
15
|
import crypto from "crypto";
|
|
9
16
|
import path from "path";
|
|
17
|
+
|
|
18
|
+
// src/tandem/pair.ts
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
async function runPair(opts) {
|
|
21
|
+
if (loadPairedConfig(opts.configPath) !== null) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Already paired (config exists at ${opts.configPath}). To re-pair this slot, delete the config file first. For a second account, use --keystore-path /custom/keypair.json.`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
fs.unlinkSync(opts.keystorePath);
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
const auth = new AuthModule(opts.code, opts.url, opts.keystorePath);
|
|
31
|
+
await auth.ensureEnrolled();
|
|
32
|
+
let principal_id;
|
|
33
|
+
let agent_id;
|
|
34
|
+
try {
|
|
35
|
+
const me = await fetch(`${opts.url}/api/v1/users/me/`, {
|
|
36
|
+
headers: { Authorization: `DPoP ${opts.code}` }
|
|
37
|
+
});
|
|
38
|
+
if (me.ok) {
|
|
39
|
+
const body = await me.json();
|
|
40
|
+
principal_id = body.principal_id;
|
|
41
|
+
agent_id = body.agent_id;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
const config = {
|
|
46
|
+
token: opts.code,
|
|
47
|
+
broker_url: opts.url,
|
|
48
|
+
paired_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
49
|
+
...principal_id ? { principal_id } : {},
|
|
50
|
+
...agent_id ? { agent_id } : {}
|
|
51
|
+
};
|
|
52
|
+
savePairedConfig(opts.configPath, config);
|
|
53
|
+
const who = principal_id && agent_id ? `${principal_id} (${agent_id})` : "(use kojee-mcp without args to start the proxy)";
|
|
54
|
+
return { message: `Paired as ${who}. Keypair: ${opts.keystorePath}. Config: ${opts.configPath}.` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/cli.ts
|
|
10
58
|
var KOJEE_DIR = path.join(process.env["HOME"] ?? "~", ".kojee");
|
|
11
59
|
function deriveKeystorePath(token) {
|
|
12
60
|
const hash = crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
13
61
|
return path.join(KOJEE_DIR, `keypair-${hash}.json`);
|
|
14
62
|
}
|
|
63
|
+
function defaultPairedKeystorePath() {
|
|
64
|
+
return path.join(KOJEE_DIR, "keypair.json");
|
|
65
|
+
}
|
|
15
66
|
var program = new Command().name("kojee-mcp").description(
|
|
16
67
|
"Local MCP proxy for Kojee \u2014 handles DPoP auth, tool discovery, and governance transparently"
|
|
17
|
-
).version("0.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
)
|
|
68
|
+
).version("0.3.0").enablePositionalOptions();
|
|
69
|
+
program.command("pair <code>").description("Pair this machine against Kojee using a pair code from the dashboard").requiredOption("--url <url>", "Broker base URL (e.g. https://rosie-server.kojee.net)").option("--keystore-path <path>", "Path to keypair.json (default ~/.kojee/keypair.json)").action(async (code, opts) => {
|
|
70
|
+
const url = opts.url.replace(/\/+$/, "");
|
|
71
|
+
const keystorePath = opts.keystorePath ?? defaultPairedKeystorePath();
|
|
72
|
+
const configPath = opts.keystorePath ? path.join(path.dirname(opts.keystorePath), "config.json") : pairedConfigPath();
|
|
73
|
+
try {
|
|
74
|
+
const result = await runPair({ code, url, keystorePath, configPath });
|
|
75
|
+
console.error(result.message);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error("[kojee-mcp pair] Failed:", err.message);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
program.command("hook").description("Run a kojee MCP hook script (called by Claude Code via ~/.claude/settings.json)").requiredOption("--type <type>", "Hook type: stop or user-prompt-submit").action(async (opts) => {
|
|
82
|
+
if (opts.type === "stop") {
|
|
83
|
+
const { runStopHook } = await import("./stop-hook-5XU3EQAE.js");
|
|
84
|
+
await runStopHook();
|
|
85
|
+
process.exit(0);
|
|
86
|
+
} else if (opts.type === "user-prompt-submit") {
|
|
87
|
+
const { runUserPromptSubmitHook } = await import("./user-prompt-submit-hook-WSRIJVF4.js");
|
|
88
|
+
await runUserPromptSubmitHook();
|
|
89
|
+
process.exit(0);
|
|
90
|
+
} else {
|
|
91
|
+
console.error(`Unknown hook type: ${opts.type}. Expected 'stop' or 'user-prompt-submit'.`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
program.command("install-hooks").description("Install kojee Stop + UserPromptSubmit hooks in ~/.claude/settings.json (idempotent)").option("--hooks-path <path>", "Override default ~/.claude/settings.json").option("--uninstall", "Remove kojee hook entries instead of installing them").action(async (opts) => {
|
|
96
|
+
const { installHooks, uninstallHooks } = await import("./install-WV25CRU2.js");
|
|
97
|
+
if (opts.uninstall) {
|
|
98
|
+
const removed = uninstallHooks({ hooksPath: opts.hooksPath });
|
|
99
|
+
console.error(removed ? "Removed kojee hook entries." : "No kojee hook entries found.");
|
|
100
|
+
} else {
|
|
101
|
+
const { stop, ups } = installHooks({ hooksPath: opts.hooksPath });
|
|
102
|
+
console.error(
|
|
103
|
+
"Installed kojee hooks in " + (opts.hooksPath ?? "~/.claude/settings.json") + `:
|
|
104
|
+
- Stop: ${stop === "added" ? "\u2713 added" : "\u21BB already installed"}
|
|
105
|
+
- UserPromptSubmit: ${ups === "added" ? "\u2713 added" : "\u21BB already installed"}
|
|
106
|
+
Restart Claude Code for hooks to take effect.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
program.command("init").description("Install kojee into Claude Code (MCP server entry + hooks). Run after `kojee-mcp pair`.").option("--config-path <path>", "Override default ~/.claude.json").option("--uninstall", "Remove the kojee MCP server entry and hooks").action(async (opts) => {
|
|
111
|
+
const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-OAR3O3XY.js");
|
|
112
|
+
if (loadPairedConfig2() === null && !opts.uninstall) {
|
|
113
|
+
console.error("Not paired. Run `kojee-mcp pair <code> --url <broker>` first, then re-run `init`.");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
const { runInit, runUninstall } = await import("./install-WV25CRU2.js");
|
|
117
|
+
if (opts.uninstall) {
|
|
118
|
+
const report = runUninstall({ configPath: opts.configPath });
|
|
119
|
+
console.error(formatUninstall(report));
|
|
120
|
+
} else {
|
|
121
|
+
const report = runInit({ configPath: opts.configPath });
|
|
122
|
+
console.error(formatInit(report));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
function formatInit(report) {
|
|
126
|
+
const tick = (s) => {
|
|
127
|
+
if (s === "added") return "\u2713 added";
|
|
128
|
+
if (s === "already-installed") return "\u21BB already installed";
|
|
129
|
+
if (s === "preserved-different") return "\u26A0 preserved (existing entry differs \u2014 left untouched)";
|
|
130
|
+
if (s === "not-found") return "\u2014 not found";
|
|
131
|
+
return s ?? "\u2014";
|
|
132
|
+
};
|
|
133
|
+
const lines = ["Installing kojee for Claude:"];
|
|
134
|
+
for (const t of report.targets) {
|
|
135
|
+
const label = t.kind === "cli" ? "CLI" : "Claude.app";
|
|
136
|
+
lines.push("");
|
|
137
|
+
lines.push(` ${t.path} (${label})`);
|
|
138
|
+
lines.push(` mcpServers.kojee ${tick(t.mcpServer)}`);
|
|
139
|
+
if (t.kind === "cli") {
|
|
140
|
+
if (t.hooksPath) {
|
|
141
|
+
lines.push(` ${t.hooksPath} (hooks)`);
|
|
142
|
+
}
|
|
143
|
+
lines.push(` hooks.Stop ${tick(t.stopHook)}`);
|
|
144
|
+
lines.push(` hooks.UserPromptSubmit ${tick(t.userPromptSubmitHook)}`);
|
|
145
|
+
} else {
|
|
146
|
+
lines.push(` (hooks not applicable for Claude.app agent mode)`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
const desktopWritten = report.targets.some((t) => t.kind === "desktop" && t.mcpServer === "added");
|
|
151
|
+
if (desktopWritten) {
|
|
152
|
+
lines.push(
|
|
153
|
+
"Existing Claude.app agent-mode sessions snapshotted the previous config",
|
|
154
|
+
"and won't pick up this change automatically. Start a NEW agent-mode",
|
|
155
|
+
"session (not a resumed one) to use the updated kojee config.",
|
|
156
|
+
""
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
lines.push("To verify: in any new CC session, run /mcp and confirm `kojee` is listed.");
|
|
160
|
+
lines.push(" run /hooks and confirm both Stop and UserPromptSubmit show the kojee entries.");
|
|
161
|
+
lines.push("To remove: `kojee-mcp init --uninstall`");
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
function formatUninstall(report) {
|
|
165
|
+
const lines = ["Removing kojee from Claude:"];
|
|
166
|
+
for (const t of report.targets) {
|
|
167
|
+
const label = t.kind === "cli" ? "CLI" : "Claude.app";
|
|
168
|
+
lines.push("");
|
|
169
|
+
lines.push(` ${t.path} (${label})`);
|
|
170
|
+
lines.push(` mcpServers.kojee ${t.mcpServer ? "\u2713 removed" : "\u2014 not found"}`);
|
|
171
|
+
if (t.kind === "cli") {
|
|
172
|
+
if (t.hooksPath) {
|
|
173
|
+
lines.push(` ${t.hooksPath} (hooks)`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(` hook entries ${t.hooks ? "\u2713 removed" : "\u2014 not found"}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
program.option("--token <token>", "Gateway token (for token-mode)").option("--url <url>", "Broker base URL (for token-mode; required if --token is passed)").option(
|
|
24
181
|
"--keystore-path <path>",
|
|
25
|
-
"Path to
|
|
182
|
+
"Path to keypair file (defaults to per-token path under ~/.kojee/)"
|
|
26
183
|
).action(async (opts) => {
|
|
27
|
-
|
|
28
|
-
|
|
184
|
+
let token = opts.token;
|
|
185
|
+
let url = opts.url;
|
|
186
|
+
let keystorePath = opts.keystorePath;
|
|
187
|
+
if (token) {
|
|
188
|
+
if (!url) {
|
|
189
|
+
console.error("--url is required when --token is provided");
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
url = url.replace(/\/+$/, "");
|
|
193
|
+
keystorePath ??= deriveKeystorePath(token);
|
|
194
|
+
} else {
|
|
195
|
+
const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-OAR3O3XY.js");
|
|
196
|
+
const cfg = loadPairedConfig2();
|
|
197
|
+
if (!cfg) {
|
|
198
|
+
console.error(
|
|
199
|
+
"No --token provided and no ~/.kojee/config.json found. Run `kojee-mcp pair <code> --url <broker>` first."
|
|
200
|
+
);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
token = cfg.token;
|
|
204
|
+
url = (opts.url ?? cfg.broker_url).replace(/\/+$/, "");
|
|
205
|
+
keystorePath ??= defaultPairedKeystorePath();
|
|
206
|
+
}
|
|
29
207
|
try {
|
|
30
|
-
await startProxy({
|
|
31
|
-
token: opts.token,
|
|
32
|
-
url,
|
|
33
|
-
keystorePath
|
|
34
|
-
});
|
|
208
|
+
await startProxy({ token, url, keystorePath });
|
|
35
209
|
} catch (err) {
|
|
36
210
|
console.error("[kojee-mcp] Fatal error:", err);
|
|
37
211
|
process.exit(1);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sessionDiscoveryDir
|
|
3
|
+
} from "./chunk-VZVGTHGF.js";
|
|
4
|
+
|
|
5
|
+
// src/tandem/event-log.ts
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
var DEFAULT_DIR = "/tmp";
|
|
9
|
+
var MAX_BODY_CHARS = 200;
|
|
10
|
+
var DEFAULT_MIN_AGE_MS = 6e4;
|
|
11
|
+
function startEventLog(opts) {
|
|
12
|
+
const dir = opts.dir ?? DEFAULT_DIR;
|
|
13
|
+
const filePath = path.join(dir, `kojee-events-${opts.key}.log`);
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
fs.writeFileSync(filePath, "", { mode: 384 });
|
|
16
|
+
try {
|
|
17
|
+
fs.chmodSync(filePath, 384);
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
path: filePath,
|
|
22
|
+
async append(event) {
|
|
23
|
+
const line = formatLine(event);
|
|
24
|
+
try {
|
|
25
|
+
await fs.promises.appendFile(filePath, line + "\n", { encoding: "utf8" });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error("[event-log] append failed:", err.message);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
cleanup() {
|
|
31
|
+
try {
|
|
32
|
+
fs.unlinkSync(filePath);
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function formatLine(event) {
|
|
39
|
+
const body = (event.content?.body ?? "").replace(/[\r\n]+/g, " ").slice(0, MAX_BODY_CHARS + 1);
|
|
40
|
+
const truncated = body.length > MAX_BODY_CHARS;
|
|
41
|
+
const safeBody = truncated ? body.slice(0, MAX_BODY_CHARS) + "\u2026" : body;
|
|
42
|
+
return `[${event.time}] tandem=${event.tandem_id} from=${event.from.displayname} (${event.from.principal}) kind=${event.kind} cursor=${event.cursor}: ${safeBody}`;
|
|
43
|
+
}
|
|
44
|
+
function isProcessAlive(pid) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err.code === "EPERM") return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function listActiveSessionIds() {
|
|
54
|
+
const dir = sessionDiscoveryDir();
|
|
55
|
+
const active = /* @__PURE__ */ new Set();
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = fs.readdirSync(dir);
|
|
59
|
+
} catch {
|
|
60
|
+
return active;
|
|
61
|
+
}
|
|
62
|
+
for (const name of entries) {
|
|
63
|
+
if (!name.endsWith(".json")) continue;
|
|
64
|
+
const rawId = name.slice(0, -".json".length);
|
|
65
|
+
const sessionId = rawId.startsWith("cc-") ? rawId.slice("cc-".length) : rawId;
|
|
66
|
+
const filePath = path.join(dir, name);
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
69
|
+
if (typeof data.pid === "number" && isProcessAlive(data.pid)) {
|
|
70
|
+
active.add(sessionId);
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(filePath);
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return active;
|
|
81
|
+
}
|
|
82
|
+
function sweepStaleEventLogs(dir = DEFAULT_DIR, minAgeMs = DEFAULT_MIN_AGE_MS) {
|
|
83
|
+
try {
|
|
84
|
+
fs.unlinkSync(path.join(dir, "kojee-events-no-session.log"));
|
|
85
|
+
} catch {
|
|
86
|
+
}
|
|
87
|
+
const active = listActiveSessionIds();
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = fs.readdirSync(dir);
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
for (const name of entries) {
|
|
96
|
+
if (!name.startsWith("kojee-events-") || !name.endsWith(".log")) continue;
|
|
97
|
+
const sessionId = name.slice("kojee-events-".length, -".log".length);
|
|
98
|
+
if (active.has(sessionId)) continue;
|
|
99
|
+
const filePath = path.join(dir, name);
|
|
100
|
+
try {
|
|
101
|
+
const stat = fs.statSync(filePath);
|
|
102
|
+
const ageMs = Math.max(0, now - stat.mtimeMs);
|
|
103
|
+
if (ageMs < minAgeMs) continue;
|
|
104
|
+
fs.unlinkSync(filePath);
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
startEventLog,
|
|
111
|
+
sweepStaleEventLogs
|
|
112
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/tandem/event-queue.ts
|
|
2
|
+
var EventQueue = class {
|
|
3
|
+
entries = [];
|
|
4
|
+
capacity;
|
|
5
|
+
maxAgeMs;
|
|
6
|
+
constructor(opts = {}) {
|
|
7
|
+
this.capacity = opts.capacity ?? 200;
|
|
8
|
+
this.maxAgeMs = opts.maxAgeMs ?? 10 * 6e4;
|
|
9
|
+
}
|
|
10
|
+
push(event) {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
this.entries = this.entries.filter((e) => now - e.receivedAt <= this.maxAgeMs);
|
|
13
|
+
this.entries.push({
|
|
14
|
+
event,
|
|
15
|
+
receivedAt: now,
|
|
16
|
+
deliveredViaChannel: false,
|
|
17
|
+
deliveredViaMonitor: false,
|
|
18
|
+
deliveredViaHook: false
|
|
19
|
+
});
|
|
20
|
+
while (this.entries.length > this.capacity) this.entries.shift();
|
|
21
|
+
}
|
|
22
|
+
markChannelDelivered(eventId) {
|
|
23
|
+
const entry = this.entries.find((e) => e.event.id === eventId);
|
|
24
|
+
if (entry) entry.deliveredViaChannel = true;
|
|
25
|
+
}
|
|
26
|
+
markMonitorDelivered(eventId) {
|
|
27
|
+
const entry = this.entries.find((e) => e.event.id === eventId);
|
|
28
|
+
if (entry) entry.deliveredViaMonitor = true;
|
|
29
|
+
}
|
|
30
|
+
takeForHook() {
|
|
31
|
+
const eligible = this.entries.filter(
|
|
32
|
+
(e) => !e.deliveredViaChannel && !e.deliveredViaMonitor && !e.deliveredViaHook
|
|
33
|
+
);
|
|
34
|
+
for (const e of eligible) e.deliveredViaHook = true;
|
|
35
|
+
return eligible;
|
|
36
|
+
}
|
|
37
|
+
snapshot() {
|
|
38
|
+
return [...this.entries];
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
export {
|
|
42
|
+
EventQueue
|
|
43
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/tandem/hook-server.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
async function startHookServer(opts) {
|
|
4
|
+
const server = createServer((req, res) => {
|
|
5
|
+
handleRequest(req, res, opts).catch((err) => {
|
|
6
|
+
console.error("[hook-server] error:", err);
|
|
7
|
+
if (!res.headersSent) {
|
|
8
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
9
|
+
res.end(JSON.stringify({ error: "internal_error" }));
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
|
|
14
|
+
const addr = server.address();
|
|
15
|
+
return {
|
|
16
|
+
port: addr.port,
|
|
17
|
+
stop: () => new Promise((resolve, reject) => {
|
|
18
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function handleRequest(req, res, opts) {
|
|
23
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
24
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
25
|
+
return json(res, 200, { ok: true });
|
|
26
|
+
}
|
|
27
|
+
if (req.method === "GET" && url.pathname === "/poll") {
|
|
28
|
+
const type = url.searchParams.get("type") ?? "";
|
|
29
|
+
const timeoutMs = Number.parseInt(url.searchParams.get("timeout_ms") ?? "0", 10);
|
|
30
|
+
if (type === "user-prompt-submit") {
|
|
31
|
+
return respondWithEvents(res, opts);
|
|
32
|
+
}
|
|
33
|
+
if (type === "stop") {
|
|
34
|
+
return longPollAndRespond(res, opts, Math.max(0, timeoutMs));
|
|
35
|
+
}
|
|
36
|
+
return json(res, 400, { error: "unknown type", detail: "type must be 'stop' or 'user-prompt-submit'" });
|
|
37
|
+
}
|
|
38
|
+
return json(res, 404, { error: "not_found", detail: `${req.method} ${url.pathname}` });
|
|
39
|
+
}
|
|
40
|
+
function respondWithEvents(res, opts) {
|
|
41
|
+
const entries = opts.queue.takeForHook();
|
|
42
|
+
const events = entries.map((entry) => opts.adapter.formatTandemEvent(entry.event));
|
|
43
|
+
json(res, 200, { events, count: events.length });
|
|
44
|
+
}
|
|
45
|
+
async function longPollAndRespond(res, opts, timeoutMs) {
|
|
46
|
+
const immediate = opts.queue.takeForHook();
|
|
47
|
+
if (immediate.length > 0) {
|
|
48
|
+
const events = immediate.map((e) => opts.adapter.formatTandemEvent(e.event));
|
|
49
|
+
return json(res, 200, { events, count: events.length });
|
|
50
|
+
}
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
await sleep(100);
|
|
54
|
+
const batch = opts.queue.takeForHook();
|
|
55
|
+
if (batch.length > 0) {
|
|
56
|
+
const events = batch.map((e) => opts.adapter.formatTandemEvent(e.event));
|
|
57
|
+
return json(res, 200, { events, count: events.length });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
json(res, 200, { events: [], count: 0 });
|
|
61
|
+
}
|
|
62
|
+
function json(res, status, body) {
|
|
63
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
64
|
+
res.end(JSON.stringify(body));
|
|
65
|
+
}
|
|
66
|
+
function sleep(ms) {
|
|
67
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
startHookServer
|
|
71
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -4,19 +4,6 @@ interface ProxyConfig {
|
|
|
4
4
|
keystorePath: string;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Bootstrap and start the Kojee MCP proxy.
|
|
9
|
-
*
|
|
10
|
-
* Startup sequence:
|
|
11
|
-
* 1. Enroll keypair (or load existing)
|
|
12
|
-
* 2. Derive session ID
|
|
13
|
-
* 3. Create gateway client
|
|
14
|
-
* 4. Discover tools from gateway
|
|
15
|
-
* 5. Start MCP stdio server
|
|
16
|
-
*
|
|
17
|
-
* If step 4 fails with an auth error that looks like a stale keystore,
|
|
18
|
-
* we wipe the keystore and retry once with a fresh enrollment.
|
|
19
|
-
*/
|
|
20
7
|
declare function startProxy(config: ProxyConfig): Promise<void>;
|
|
21
8
|
|
|
22
9
|
export { type ProxyConfig, startProxy };
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/hooks/install.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function discoverInstallTargets() {
|
|
5
|
+
const home = process.env["HOME"] ?? "~";
|
|
6
|
+
const targets = [];
|
|
7
|
+
const cliPath = path.join(home, ".claude.json");
|
|
8
|
+
const cliHooksPath = path.join(home, ".claude", "settings.json");
|
|
9
|
+
targets.push({ kind: "cli", path: cliPath, exists: fs.existsSync(cliPath), hooksPath: cliHooksPath });
|
|
10
|
+
let desktopPath = null;
|
|
11
|
+
if (process.platform === "darwin") {
|
|
12
|
+
desktopPath = path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
13
|
+
} else if (process.platform === "win32") {
|
|
14
|
+
const appData = process.env["APPDATA"] ?? path.join(home, "AppData", "Roaming");
|
|
15
|
+
desktopPath = path.join(appData, "Claude", "claude_desktop_config.json");
|
|
16
|
+
} else if (process.platform === "linux") {
|
|
17
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path.join(home, ".config");
|
|
18
|
+
desktopPath = path.join(xdgConfig, "Claude", "claude_desktop_config.json");
|
|
19
|
+
}
|
|
20
|
+
if (desktopPath) {
|
|
21
|
+
targets.push({ kind: "desktop", path: desktopPath, exists: fs.existsSync(desktopPath) });
|
|
22
|
+
}
|
|
23
|
+
return targets;
|
|
24
|
+
}
|
|
25
|
+
var STOP_COMMAND = "npx -y kojee-mcp hook --type=stop";
|
|
26
|
+
var UPS_COMMAND = "npx -y kojee-mcp hook --type=user-prompt-submit";
|
|
27
|
+
var MCP_SERVER_CMD = "npx";
|
|
28
|
+
var MCP_SERVER_ARGS = ["kojee-mcp"];
|
|
29
|
+
var MCP_SERVER_ENV = { KOJEE_RUNTIME: "claude-code" };
|
|
30
|
+
function defaultConfigPath() {
|
|
31
|
+
return path.join(process.env["HOME"] ?? "~", ".claude.json");
|
|
32
|
+
}
|
|
33
|
+
function defaultHooksPath() {
|
|
34
|
+
return path.join(process.env["HOME"] ?? "~", ".claude", "settings.json");
|
|
35
|
+
}
|
|
36
|
+
function deriveHooksPath(configPath) {
|
|
37
|
+
return path.join(path.dirname(configPath), ".claude", "settings.json");
|
|
38
|
+
}
|
|
39
|
+
function readConfig(p) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function writeConfig(p, cfg) {
|
|
47
|
+
const dir = path.dirname(p);
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
50
|
+
try {
|
|
51
|
+
fs.chmodSync(p, 384);
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function hasKojeeEntry(arr, command) {
|
|
56
|
+
if (!arr) return false;
|
|
57
|
+
return arr.some((e) => e.hooks.some((h) => h.command === command));
|
|
58
|
+
}
|
|
59
|
+
function installHookEntry(cfg, event, command) {
|
|
60
|
+
cfg.hooks ??= {};
|
|
61
|
+
cfg.hooks[event] ??= [];
|
|
62
|
+
if (hasKojeeEntry(cfg.hooks[event], command)) return "already-installed";
|
|
63
|
+
cfg.hooks[event].push({ hooks: [{ type: "command", command }] });
|
|
64
|
+
return "added";
|
|
65
|
+
}
|
|
66
|
+
function uninstallHookEntries(cfg) {
|
|
67
|
+
let removed = false;
|
|
68
|
+
if (!cfg.hooks) return false;
|
|
69
|
+
for (const event of ["Stop", "UserPromptSubmit"]) {
|
|
70
|
+
const arr = cfg.hooks[event];
|
|
71
|
+
if (!arr) continue;
|
|
72
|
+
const before = arr.length;
|
|
73
|
+
cfg.hooks[event] = arr.filter(
|
|
74
|
+
(entry) => !entry.hooks.some((h) => h.command.startsWith("npx -y kojee-mcp hook"))
|
|
75
|
+
);
|
|
76
|
+
if (cfg.hooks[event].length !== before) removed = true;
|
|
77
|
+
}
|
|
78
|
+
return removed;
|
|
79
|
+
}
|
|
80
|
+
function installHooks(opts = {}) {
|
|
81
|
+
const p = opts.hooksPath ?? (opts.configPath ? deriveHooksPath(opts.configPath) : defaultHooksPath());
|
|
82
|
+
const cfg = readConfig(p);
|
|
83
|
+
const stop = installHookEntry(cfg, "Stop", STOP_COMMAND);
|
|
84
|
+
const ups = installHookEntry(cfg, "UserPromptSubmit", UPS_COMMAND);
|
|
85
|
+
writeConfig(p, cfg);
|
|
86
|
+
return { stop, ups };
|
|
87
|
+
}
|
|
88
|
+
function uninstallHooks(opts = {}) {
|
|
89
|
+
const p = opts.hooksPath ?? (opts.configPath ? deriveHooksPath(opts.configPath) : defaultHooksPath());
|
|
90
|
+
const cfg = readConfig(p);
|
|
91
|
+
const removed = uninstallHookEntries(cfg);
|
|
92
|
+
writeConfig(p, cfg);
|
|
93
|
+
return removed;
|
|
94
|
+
}
|
|
95
|
+
function installMcpServer(opts = {}) {
|
|
96
|
+
const p = opts.configPath ?? defaultConfigPath();
|
|
97
|
+
const cfg = readConfig(p);
|
|
98
|
+
cfg.mcpServers ??= {};
|
|
99
|
+
const existing = cfg.mcpServers["kojee"];
|
|
100
|
+
if (existing) {
|
|
101
|
+
const sameCommand = existing.command === MCP_SERVER_CMD;
|
|
102
|
+
const sameArgs = JSON.stringify(existing.args ?? []) === JSON.stringify(MCP_SERVER_ARGS);
|
|
103
|
+
const sameEnv = JSON.stringify(existing.env ?? {}) === JSON.stringify(MCP_SERVER_ENV);
|
|
104
|
+
if (sameCommand && sameArgs && sameEnv) return "already-installed";
|
|
105
|
+
return "preserved-different";
|
|
106
|
+
}
|
|
107
|
+
cfg.mcpServers["kojee"] = {
|
|
108
|
+
command: MCP_SERVER_CMD,
|
|
109
|
+
args: [...MCP_SERVER_ARGS],
|
|
110
|
+
env: { ...MCP_SERVER_ENV }
|
|
111
|
+
};
|
|
112
|
+
writeConfig(p, cfg);
|
|
113
|
+
return "added";
|
|
114
|
+
}
|
|
115
|
+
function uninstallMcpServer(opts = {}) {
|
|
116
|
+
const p = opts.configPath ?? defaultConfigPath();
|
|
117
|
+
const cfg = readConfig(p);
|
|
118
|
+
if (!cfg.mcpServers || !cfg.mcpServers["kojee"]) return false;
|
|
119
|
+
delete cfg.mcpServers["kojee"];
|
|
120
|
+
writeConfig(p, cfg);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
function runInit(opts = {}) {
|
|
124
|
+
const targets = opts.configPath ? [{
|
|
125
|
+
kind: "cli",
|
|
126
|
+
path: opts.configPath,
|
|
127
|
+
exists: true,
|
|
128
|
+
hooksPath: opts.hooksPath ?? deriveHooksPath(opts.configPath)
|
|
129
|
+
}] : discoverInstallTargets();
|
|
130
|
+
const reports = [];
|
|
131
|
+
for (const t of targets) {
|
|
132
|
+
if (!t.exists) {
|
|
133
|
+
reports.push({ kind: t.kind, path: t.path, ...t.hooksPath ? { hooksPath: t.hooksPath } : {}, mcpServer: "not-found" });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const mcpServer = installMcpServer({ configPath: t.path });
|
|
137
|
+
let stopHook;
|
|
138
|
+
let upsHook;
|
|
139
|
+
if (t.kind === "cli") {
|
|
140
|
+
const hookReport = installHooks({ hooksPath: t.hooksPath ?? deriveHooksPath(t.path) });
|
|
141
|
+
stopHook = hookReport.stop;
|
|
142
|
+
upsHook = hookReport.ups;
|
|
143
|
+
}
|
|
144
|
+
reports.push({
|
|
145
|
+
kind: t.kind,
|
|
146
|
+
path: t.path,
|
|
147
|
+
...t.hooksPath ? { hooksPath: t.hooksPath } : {},
|
|
148
|
+
mcpServer,
|
|
149
|
+
...stopHook ? { stopHook } : {},
|
|
150
|
+
...upsHook ? { userPromptSubmitHook: upsHook } : {}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return { targets: reports, configPath: targets[0]?.path };
|
|
154
|
+
}
|
|
155
|
+
function runUninstall(opts = {}) {
|
|
156
|
+
const targets = opts.configPath ? [{
|
|
157
|
+
kind: "cli",
|
|
158
|
+
path: opts.configPath,
|
|
159
|
+
exists: true,
|
|
160
|
+
hooksPath: opts.hooksPath ?? deriveHooksPath(opts.configPath)
|
|
161
|
+
}] : discoverInstallTargets();
|
|
162
|
+
const reports = [];
|
|
163
|
+
for (const t of targets) {
|
|
164
|
+
if (!t.exists) {
|
|
165
|
+
reports.push({ kind: t.kind, path: t.path, ...t.hooksPath ? { hooksPath: t.hooksPath } : {}, mcpServer: false, hooks: false });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const mcpServer = uninstallMcpServer({ configPath: t.path });
|
|
169
|
+
const hooks = t.kind === "cli" ? uninstallHooks({ hooksPath: t.hooksPath ?? deriveHooksPath(t.path) }) : false;
|
|
170
|
+
reports.push({ kind: t.kind, path: t.path, ...t.hooksPath ? { hooksPath: t.hooksPath } : {}, mcpServer, hooks });
|
|
171
|
+
}
|
|
172
|
+
return { targets: reports, configPath: targets[0]?.path };
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
discoverInstallTargets,
|
|
176
|
+
installHooks,
|
|
177
|
+
installMcpServer,
|
|
178
|
+
runInit,
|
|
179
|
+
runUninstall,
|
|
180
|
+
uninstallHooks,
|
|
181
|
+
uninstallMcpServer
|
|
182
|
+
};
|