gisool 0.0.0-stage → 0.1.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.
Files changed (84) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +136 -2
  3. package/dist/api/routes.js +166 -0
  4. package/dist/api/security.js +86 -0
  5. package/dist/api/sse.js +55 -0
  6. package/dist/api/static.js +32 -0
  7. package/dist/catalog/fields.js +146 -0
  8. package/dist/catalog/index.js +133 -0
  9. package/dist/catalog/scanner.js +185 -0
  10. package/dist/catalog/uri.js +41 -0
  11. package/dist/catalog/watcher.js +59 -0
  12. package/dist/cli/config-writer.js +255 -0
  13. package/dist/cli/doctor.js +197 -0
  14. package/dist/cli/eval.js +64 -0
  15. package/dist/cli/export-import.js +66 -0
  16. package/dist/cli/init.js +271 -0
  17. package/dist/cli/main.js +165 -0
  18. package/dist/cli/open.js +24 -0
  19. package/dist/cli/serve.js +144 -0
  20. package/dist/config.js +78 -0
  21. package/dist/dashboard/assets/app.js +256 -0
  22. package/dist/dashboard/assets/chunk-4U4GNPH4.js +17 -0
  23. package/dist/dashboard/assets/chunk-5DFWNJC2.js +1 -0
  24. package/dist/dashboard/assets/chunk-JPIEF5XO.js +17 -0
  25. package/dist/dashboard/assets/chunk-ZBUKVJOI.js +1 -0
  26. package/dist/dashboard/assets/styles.css +487 -0
  27. package/dist/dashboard/index.html +15 -0
  28. package/dist/ingest/attribution.js +55 -0
  29. package/dist/ingest/hooks.js +168 -0
  30. package/dist/ingest/mcp-recorder.js +41 -0
  31. package/dist/ingest/otlp.js +160 -0
  32. package/dist/ingest/pipeline.js +100 -0
  33. package/dist/ingest/redact.js +36 -0
  34. package/dist/ingest/runtimes.js +25 -0
  35. package/dist/mcp/http.js +84 -0
  36. package/dist/mcp/skills-extension.js +35 -0
  37. package/dist/mcp/stdio-proxy.js +53 -0
  38. package/dist/mcp/tools.js +164 -0
  39. package/dist/packs/claude-code.js +157 -0
  40. package/dist/packs/codex.js +148 -0
  41. package/dist/packs/cursor.js +95 -0
  42. package/dist/packs/gajae.js +7 -0
  43. package/dist/packs/gemini.js +88 -0
  44. package/dist/packs/json-config.js +51 -0
  45. package/dist/packs/omo.js +149 -0
  46. package/dist/packs/openclaw.js +119 -0
  47. package/dist/packs/registry.js +13 -0
  48. package/dist/search/fts.js +61 -0
  49. package/dist/search/fusion.js +50 -0
  50. package/dist/search/guidance.js +7 -0
  51. package/dist/search/index.js +74 -0
  52. package/dist/search/lexical.js +69 -0
  53. package/dist/server.js +216 -0
  54. package/dist/store/db.js +74 -0
  55. package/dist/store/export.js +199 -0
  56. package/dist/store/index.js +13 -0
  57. package/dist/store/queries.js +458 -0
  58. package/dist/store/schema.sql +96 -0
  59. package/dist/store/writes.js +172 -0
  60. package/dist/types.js +1 -0
  61. package/dist/version.js +19 -0
  62. package/package.json +46 -4
  63. package/packs/claude-code/README.md +5 -0
  64. package/packs/claude-code/settings.template.json +24 -0
  65. package/packs/codex/README.md +5 -0
  66. package/packs/codex/config.template.toml +11 -0
  67. package/packs/cursor/hooks.example.json +8 -0
  68. package/packs/cursor/mcp.example.json +8 -0
  69. package/packs/gajae/extension/discovery.md +5 -0
  70. package/packs/gajae/extension/index.js +44 -0
  71. package/packs/gemini/settings.example.json +16 -0
  72. package/packs/hook-post.mjs +29 -0
  73. package/packs/omo/extension/discovery.md +5 -0
  74. package/packs/omo/extension/index.js +44 -0
  75. package/packs/openclaw/.codex-plugin/plugin.json +11 -0
  76. package/packs/openclaw/.mcp.json +12 -0
  77. package/packs/openclaw/gisool.json +1 -0
  78. package/packs/openclaw/hooks/gisool-discovery/HOOK.md +8 -0
  79. package/packs/openclaw/hooks/gisool-discovery/handler.js +27 -0
  80. package/packs/openclaw/index.mjs +47 -0
  81. package/packs/openclaw/openclaw.plugin.json +22 -0
  82. package/packs/openclaw/package.json +11 -0
  83. package/src/store/schema.sql +96 -0
  84. package/src/store/task-types.json +15 -0
@@ -0,0 +1,53 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, CallToolResultSchema, ErrorCode, ListToolsRequestSchema, ListToolsResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
6
+ import { packageVersion, SERVER_INSTRUCTIONS } from "./tools.js";
7
+ /**
8
+ * Bridges a stdio MCP client to the daemon's Streamable HTTP endpoint. The upstream connection opens lazily after the
9
+ * downstream initialize so the daemon sees the real client name (runtime attribution depends on it).
10
+ */
11
+ export async function runStdioProxy(opts) {
12
+ const version = packageVersion();
13
+ const server = new Server({ name: "gisool", version }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
14
+ let upstream;
15
+ const connect = async () => {
16
+ const info = server.getClientVersion();
17
+ const client = new Client({ name: info?.name ?? "gisool-stdio-proxy", version: info?.version ?? version });
18
+ const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${opts.port}/mcp`), {
19
+ requestInit: { headers: { Authorization: `Bearer ${opts.token}` } },
20
+ });
21
+ try {
22
+ await client.connect(transport);
23
+ }
24
+ catch (error) {
25
+ throw new McpError(ErrorCode.InternalError, `gisool daemon is not reachable on 127.0.0.1:${opts.port}; run \`gisool serve\` (${error.message})`);
26
+ }
27
+ client.onclose = () => { upstream = undefined; };
28
+ return client;
29
+ };
30
+ const client = () => {
31
+ upstream ??= connect().catch((error) => { upstream = undefined; throw error; });
32
+ return upstream;
33
+ };
34
+ server.setRequestHandler(ListToolsRequestSchema, async (request) => (await client()).request({ method: "tools/list", params: request.params }, ListToolsResultSchema));
35
+ server.setRequestHandler(CallToolRequestSchema, async (request) => (await client()).request({ method: "tools/call", params: request.params }, CallToolResultSchema));
36
+ const transport = new StdioServerTransport();
37
+ const closed = new Promise((resolve) => { transport.onclose = resolve; });
38
+ let closing = false;
39
+ const shutdown = async () => {
40
+ if (closing)
41
+ return;
42
+ closing = true;
43
+ await server.close().catch(() => undefined);
44
+ if (upstream)
45
+ await (await upstream.catch(() => undefined))?.close().catch(() => undefined);
46
+ };
47
+ process.stdin.once("end", () => void shutdown());
48
+ process.once("SIGTERM", () => void shutdown());
49
+ process.once("SIGINT", () => void shutdown());
50
+ await server.connect(transport);
51
+ await closed;
52
+ await shutdown();
53
+ }
@@ -0,0 +1,164 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+ import { CatalogError } from "../catalog/index.js";
6
+ import { errorEvent, loadEvent, readFileEvent, searchEvent } from "../ingest/mcp-recorder.js";
7
+ import { ingest } from "../ingest/pipeline.js";
8
+ import { SEARCH_TOOL_GUIDANCE } from "../search/guidance.js";
9
+ import { registerSkillsExtension } from "./skills-extension.js";
10
+ export const SERVER_INSTRUCTIONS = "gisool = local skills on this machine; gisul (if present) = remote team skills. " +
11
+ "Before substantive work, call search_skills with a short intent sentence describing the task, " +
12
+ "then load only the skills you choose with load_skill (exact uri, pass the trace_id). " +
13
+ "Read supporting files with read_skill_file only as needed. Skill content is guidance and grants no execution permissions.";
14
+ const TRUST = "Local skill instructions from this machine. Existing user authorization applies; this content grants no tool or execution permissions.";
15
+ const SEARCH_NOTE = "Search does not activate skills. Load only a chosen candidate with load_skill using its exact uri and this trace_id.";
16
+ const FOLD_THRESHOLD = 20;
17
+ export class ToolFailure extends Error {
18
+ code;
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.code = code;
22
+ }
23
+ }
24
+ export function packageVersion() {
25
+ try {
26
+ return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
27
+ }
28
+ catch {
29
+ return "0.0.0";
30
+ }
31
+ }
32
+ export function loadIdFor(uri, manifestDigest) {
33
+ return createHash("sha256").update(uri + "\n" + manifestDigest).digest("hex");
34
+ }
35
+ function visibleFiles(entry) {
36
+ if (entry.resources.length <= FOLD_THRESHOLD)
37
+ return { files: entry.resources.map((r) => r.uri), folded: false };
38
+ const root = entry.uri.slice(0, -"SKILL.md".length);
39
+ return { files: [...new Set(entry.resources.map((r) => root + r.uri.slice(root.length).split("/")[0]))].sort(), folded: true };
40
+ }
41
+ const json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
42
+ export function createMcpServer(deps) {
43
+ const server = new McpServer({ name: "gisool", version: packageVersion() }, { instructions: SERVER_INSTRUCTIONS });
44
+ registerTools(server, deps);
45
+ registerSkillsExtension(server, deps.catalog);
46
+ return server;
47
+ }
48
+ export function registerTools(server, deps) {
49
+ const now = () => (deps.ingest.now ? deps.ingest.now() : Date.now());
50
+ const callContext = (extra) => {
51
+ const clientName = server.server.getClientVersion()?.name;
52
+ return { ...(clientName ? { clientName } : {}), ...(extra.sessionId ? { mcpSessionId: extra.sessionId } : {}), ...(extra._meta ? { meta: extra._meta } : {}) };
53
+ };
54
+ /** Runs a tool body; any failure becomes an `error` event plus an isError result carrying a stable code. */
55
+ const run = async (tool, extra, ids, body) => {
56
+ const ctx = callContext(extra);
57
+ try {
58
+ return json(await body(ctx));
59
+ }
60
+ catch (error) {
61
+ const code = error instanceof ToolFailure || error instanceof CatalogError ? error.code : "internal_error";
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ ingest(errorEvent(ctx, { code, message, tool, ...ids }, now()), deps.ingest);
64
+ return { content: [{ type: "text", text: JSON.stringify({ error: { code, message } }) }], isError: true };
65
+ }
66
+ };
67
+ const annotations = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
68
+ server.registerTool("search_skills", {
69
+ description: `Find local skills for the current task without reading their bodies. ${SEARCH_TOOL_GUIDANCE} Returns ranked candidates and a trace_id; pass the trace_id to load_skill.`,
70
+ inputSchema: {
71
+ query: z.string().max(2048).optional(),
72
+ mode: z.enum(["discovery", "automatic", "explicit"]).default("discovery"),
73
+ limit: z.number().int().min(1).max(50).default(5),
74
+ offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
75
+ },
76
+ annotations,
77
+ }, async ({ query, mode, limit, offset }, extra) => run("search_skills", extra, {}, async (ctx) => {
78
+ const all = deps.search.search(query ?? "", { mode, limit: Math.max(1, deps.catalog.list().length) });
79
+ const page = all.candidates.slice(offset, offset + limit);
80
+ const outcome = { ...all, candidates: page };
81
+ ingest(searchEvent(ctx, outcome, { total: all.candidates.length }, now()), deps.ingest);
82
+ return {
83
+ trace_id: outcome.traceId,
84
+ candidates: page.map(({ uri, name, description, score, rank, invocation, digest }) => ({ uri, name, description, score, rank, invocation, digest })),
85
+ ...(outcome.abstained ? { abstained: outcome.abstained } : {}),
86
+ total: all.candidates.length,
87
+ note: SEARCH_NOTE,
88
+ };
89
+ }));
90
+ server.registerTool("load_skill", {
91
+ description: "Load a chosen local SKILL.md by its exact uri from search_skills (pass trace_id). Returns the verified markdown, its file list and a load_id for read_skill_file.",
92
+ inputSchema: { uri: z.string().max(4096), trace_id: z.string().max(128).optional() },
93
+ annotations,
94
+ }, async ({ uri, trace_id }, extra) => run("load_skill", extra, { ...(trace_id ? { traceId: trace_id } : {}), skillUri: uri }, async (ctx) => {
95
+ let entry = deps.catalog.get(uri);
96
+ if (!entry) {
97
+ await deps.catalog.refresh();
98
+ entry = deps.catalog.get(uri);
99
+ }
100
+ if (!entry)
101
+ throw new ToolFailure("unknown_skill", `Not a skill in the local catalog: ${uri}; use an exact uri returned by search_skills`);
102
+ let skill;
103
+ try {
104
+ skill = await deps.catalog.readResource(entry.uri);
105
+ }
106
+ catch (error) {
107
+ if (!(error instanceof CatalogError && error.code === "digest_mismatch"))
108
+ throw error;
109
+ await deps.catalog.refresh();
110
+ entry = deps.catalog.get(uri);
111
+ if (!entry)
112
+ throw new ToolFailure("unknown_skill", `Skill was removed: ${uri}`);
113
+ skill = await deps.catalog.readResource(entry.uri);
114
+ }
115
+ const loadId = loadIdFor(entry.uri, entry.manifestDigest);
116
+ const previousId = deps.store.latestLoad(entry.uri);
117
+ const previous = previousId ? deps.store.getLoad(previousId) : undefined;
118
+ deps.store.putLoad({ loadId, ...(trace_id ? { traceId: trace_id } : {}), uri: entry.uri, manifestDigest: entry.manifestDigest, resources: entry.resources });
119
+ ingest(loadEvent(ctx, { uri: entry.uri, loadId, manifestDigest: entry.manifestDigest, ...(trace_id ? { traceId: trace_id } : {}) }, now()), deps.ingest);
120
+ const { files, folded } = visibleFiles(entry);
121
+ return {
122
+ load_id: loadId, uri: entry.uri, digest: entry.digest, manifest_digest: entry.manifestDigest, markdown: skill.text,
123
+ files, filesFolded: folded, changed: !!previous && previous.manifestDigest !== entry.manifestDigest, trust: TRUST,
124
+ };
125
+ }));
126
+ server.registerTool("read_skill_file", {
127
+ description: "Read a supporting file of a loaded skill. Pass load_id to pin the version you loaded; omitted load_id uses the latest load of skill_uri. Directory uris return a listing.",
128
+ inputSchema: { skill_uri: z.string().max(4096), uri: z.string().max(4096), load_id: z.string().regex(/^[a-f0-9]{64}$/).optional() },
129
+ annotations,
130
+ }, async ({ skill_uri, uri, load_id }, extra) => run("read_skill_file", extra, { skillUri: skill_uri, ...(load_id ? { loadId: load_id } : {}) }, async (ctx) => {
131
+ const loadId = load_id ?? deps.store.latestLoad(skill_uri);
132
+ const load = loadId ? deps.store.getLoad(loadId) : undefined;
133
+ if (!loadId || !load)
134
+ throw new ToolFailure("not_loaded", load_id ? "Unknown load_id; call load_skill first" : "Call load_skill for this skill first");
135
+ if (load.uri !== skill_uri)
136
+ throw new ToolFailure("load_mismatch", "load_id belongs to a different skill");
137
+ const version = { skill_uri, uri, load_id: loadId, manifest_digest: load.manifestDigest };
138
+ const file = load.resources.find((r) => r.uri === uri);
139
+ if (!file) {
140
+ const prefix = uri.endsWith("/") ? uri : `${uri}/`;
141
+ const children = load.resources.filter((r) => r.uri.startsWith(prefix));
142
+ if (!children.length)
143
+ throw new ToolFailure("outside_manifest", "File is not part of the loaded skill manifest");
144
+ const listing = [...new Set(children.map((r) => prefix + r.uri.slice(prefix.length).split("/")[0]))].sort();
145
+ ingest(readFileEvent(ctx, { skillUri: skill_uri, uri, bytes: 0, loadId }, now()), deps.ingest);
146
+ return { ...version, kind: "directory", files: listing, note: "Directory listing only; read a listed file when needed." };
147
+ }
148
+ let live;
149
+ try {
150
+ live = await deps.catalog.readResource(uri);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof CatalogError && (error.code === "digest_mismatch" || error.code === "unknown_resource"))
154
+ live = { text: "", digest: "", size: -1 };
155
+ else
156
+ throw error;
157
+ }
158
+ if (live.digest !== file.digest || live.size !== file.size) {
159
+ throw new ToolFailure("skill_changed_since_load", "Skill changed since it was loaded; stop using this version and call load_skill again");
160
+ }
161
+ ingest(readFileEvent(ctx, { skillUri: skill_uri, uri, bytes: live.size, loadId }, now()), deps.ingest);
162
+ return { ...version, kind: "file", text: live.text, note: "Supporting content only; nested SKILL.md frontmatter is not activated." };
163
+ }));
164
+ }
@@ -0,0 +1,157 @@
1
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join, delimiter } from "node:path";
3
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
4
+ const jsonEsc = (v) => JSON.stringify(v).slice(1, -1);
5
+ function onPath(bin, env) {
6
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
7
+ if (!dir)
8
+ continue;
9
+ const p = join(dir, bin);
10
+ try {
11
+ if (statSync(p).isFile()) {
12
+ accessSync(p, constants.X_OK);
13
+ return p;
14
+ }
15
+ }
16
+ catch { /* next */ }
17
+ }
18
+ return undefined;
19
+ }
20
+ const configDir = (c) => c.env.CLAUDE_CONFIG_DIR || join(c.home, ".claude");
21
+ const settingsPath = (c) => join(configDir(c), "settings.json");
22
+ const isLoopback = (u) => /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(u);
23
+ function readText(p) {
24
+ try {
25
+ return readFileSync(p, "utf8");
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function remoteRegistered(home) {
32
+ const text = readText(join(home, ".claude.json"));
33
+ if (text === null)
34
+ return false;
35
+ try {
36
+ const servers = JSON.parse(text).mcpServers;
37
+ const g = isObj(servers) ? servers.gisul : undefined;
38
+ if (!isObj(g))
39
+ return false;
40
+ if (typeof g.url === "string")
41
+ return !isLoopback(g.url);
42
+ return JSON.stringify(g).includes("mcp-remote");
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ const hasGisoolHook = (groups) => Array.isArray(groups) && groups.some(g => isObj(g) && Array.isArray(g.hooks) &&
49
+ g.hooks.some(h => isObj(h) && typeof h.command === "string" && h.command.includes("hook-post.mjs")));
50
+ function template(ctx) {
51
+ const raw = readFileSync(join(ctx.packageRoot, "packs", "claude-code", "settings.template.json"), "utf8")
52
+ .replaceAll("{{PORT}}", String(ctx.port)).replaceAll("{{TOKEN}}", jsonEsc(ctx.token))
53
+ .replaceAll("{{NODE}}", jsonEsc(ctx.nodePath)).replaceAll("{{HOOK}}", jsonEsc(join(ctx.packageRoot, "packs", "hook-post.mjs")));
54
+ return JSON.parse(raw);
55
+ }
56
+ export const claudeCodePack = {
57
+ id: "claude-code",
58
+ async detect(ctx) {
59
+ const evidence = [];
60
+ const dir = configDir(ctx);
61
+ if (existsSync(dir))
62
+ evidence.push(dir);
63
+ const bin = onPath("claude", ctx.env);
64
+ if (bin)
65
+ evidence.push(bin);
66
+ return { installed: evidence.length > 0, evidence, remoteGisulRegistered: remoteRegistered(ctx.home) };
67
+ },
68
+ async plan(ctx) {
69
+ const plan = { files: [], commands: [], notes: [], conflicts: [] };
70
+ const path = settingsPath(ctx);
71
+ const before = readText(path);
72
+ let cfg = {};
73
+ if (before !== null) {
74
+ try {
75
+ const parsed = JSON.parse(before);
76
+ if (!isObj(parsed))
77
+ throw new Error("not an object");
78
+ cfg = parsed;
79
+ }
80
+ catch {
81
+ plan.conflicts.push({ path, code: "unparseable_json", detail: "settings.json does not parse as a JSON object; refusing to edit" });
82
+ return plan;
83
+ }
84
+ }
85
+ const tpl = template(ctx);
86
+ const wantEnv = tpl.env;
87
+ const wantHooks = tpl.hooks;
88
+ if (cfg.env !== undefined && !isObj(cfg.env))
89
+ plan.conflicts.push({ path, code: "env_not_object", detail: "settings.json `env` is not an object" });
90
+ const env = isObj(cfg.env) ? cfg.env : {};
91
+ for (const [k, v] of Object.entries(wantEnv)) {
92
+ if (Object.hasOwn(env, k) && env[k] !== v) {
93
+ plan.conflicts.push({ path, code: "env_conflict", detail: `${k} is already set to a different value` });
94
+ }
95
+ }
96
+ if (cfg.hooks !== undefined && !isObj(cfg.hooks))
97
+ plan.conflicts.push({ path, code: "hooks_not_object", detail: "settings.json `hooks` is not an object" });
98
+ const hooks = isObj(cfg.hooks) ? cfg.hooks : {};
99
+ for (const ev of Object.keys(wantHooks)) {
100
+ if (hooks[ev] !== undefined && !Array.isArray(hooks[ev]))
101
+ plan.conflicts.push({ path, code: "hook_not_array", detail: `hooks.${ev} is not an array` });
102
+ }
103
+ if (plan.conflicts.length === 0) {
104
+ const nextEnv = { ...env, ...wantEnv };
105
+ const nextHooks = { ...hooks };
106
+ for (const [ev, groups] of Object.entries(wantHooks)) {
107
+ const existing = hooks[ev] ?? [];
108
+ nextHooks[ev] = hasGisoolHook(existing) ? existing : [...existing, ...groups];
109
+ }
110
+ const next = { ...cfg, env: nextEnv, hooks: nextHooks };
111
+ const after = JSON.stringify(next, null, 2) + "\n";
112
+ const same = before !== null && JSON.stringify(JSON.parse(before)) === JSON.stringify(next);
113
+ if (!same)
114
+ plan.files.push({ path, before, after, mode: 0o600 });
115
+ }
116
+ const argv = ["claude", "mcp", "add", "--transport", "http", "--scope", "user", "gisool",
117
+ `http://127.0.0.1:${ctx.port}/mcp`, "--header", `Authorization: Bearer ${ctx.token}`];
118
+ const shown = `claude mcp add --transport http --scope user gisool http://127.0.0.1:${ctx.port}/mcp --header "Authorization: Bearer ${ctx.token}"`;
119
+ if (onPath("claude", ctx.env) && !ctx.env.GISOOL_NO_CLI) {
120
+ plan.commands.push({ argv, description: "Register the gisool MCP server with Claude Code (user scope)",
121
+ undo: { argv: ["claude", "mcp", "remove", "--scope", "user", "gisool"], description: "Remove the gisool MCP server" } });
122
+ }
123
+ else {
124
+ plan.notes.push(`manual step: run ${shown}`);
125
+ }
126
+ if (ctx.disableRemote && remoteRegistered(ctx.home)) {
127
+ plan.notes.push("manual step: run claude mcp remove --scope user gisul");
128
+ }
129
+ return plan;
130
+ },
131
+ async doctorChecks(ctx) {
132
+ const path = settingsPath(ctx);
133
+ const text = readText(path);
134
+ let cfg = {};
135
+ let parsed = false;
136
+ if (text !== null) {
137
+ try {
138
+ const p = JSON.parse(text);
139
+ if (isObj(p)) {
140
+ cfg = p;
141
+ parsed = true;
142
+ }
143
+ }
144
+ catch { /* reported below */ }
145
+ }
146
+ const env = isObj(cfg.env) ? cfg.env : {};
147
+ const hooks = isObj(cfg.hooks) ? cfg.hooks : {};
148
+ const endpoint = `http://127.0.0.1:${ctx.port}/ingest/otlp/v1/logs`;
149
+ return [
150
+ { name: "claude-code settings.json parses", ok: parsed, detail: text === null ? `missing: ${path}` : parsed ? path : "invalid JSON" },
151
+ { name: "claude-code OTel env present", ok: env.CLAUDE_CODE_ENABLE_TELEMETRY === "1" && env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT === endpoint,
152
+ detail: String(env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? "not set") },
153
+ { name: "claude-code hooks present", ok: hasGisoolHook(hooks.PostToolUse) && hasGisoolHook(hooks.UserPromptExpansion),
154
+ detail: "PostToolUse and UserPromptExpansion run hook-post.mjs" },
155
+ ];
156
+ },
157
+ };
@@ -0,0 +1,148 @@
1
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, delimiter, resolve } from "node:path";
3
+ const BEGIN = "# >>> gisool managed";
4
+ const END = "# <<< gisool managed";
5
+ const tomlEsc = (v) => JSON.stringify(v).slice(1, -1);
6
+ function onPath(bin, env) {
7
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
8
+ if (!dir)
9
+ continue;
10
+ const p = join(dir, bin);
11
+ try {
12
+ if (statSync(p).isFile()) {
13
+ accessSync(p, constants.X_OK);
14
+ return p;
15
+ }
16
+ }
17
+ catch { /* next */ }
18
+ }
19
+ return undefined;
20
+ }
21
+ const codexDir = (c) => c.env.CODEX_HOME || join(c.home, ".codex");
22
+ const configPath = (c) => join(codexDir(c), "config.toml");
23
+ function readText(p) {
24
+ try {
25
+ return readFileSync(p, "utf8");
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function realOrResolved(p) {
32
+ try {
33
+ return realpathSync(p);
34
+ }
35
+ catch {
36
+ return resolve(p);
37
+ }
38
+ }
39
+ /** Splits text into the managed block (if any) and the rest. */
40
+ function splitManaged(text) {
41
+ const s = text.indexOf(BEGIN);
42
+ if (s < 0)
43
+ return null;
44
+ const e = text.indexOf(END, s);
45
+ if (e < 0)
46
+ return null;
47
+ let end = e + END.length;
48
+ if (text[end] === "\n")
49
+ end++;
50
+ return { outside: text.slice(0, s) + text.slice(end), start: s, end };
51
+ }
52
+ const OTEL_RE = /^\s*(\[\[?\s*otel\s*(\]|\.)|otel\s*\.)/m;
53
+ const GISOOL_TABLE_RE = /^\s*\[\s*mcp_servers\.gisool\s*\]/m;
54
+ /** Returns the body of the [mcp_servers.gisul] table, or null. */
55
+ function gisulTable(text) {
56
+ const m = /^\[\s*mcp_servers\.gisul\s*\][^\n]*\n?/m.exec(text);
57
+ if (!m)
58
+ return null;
59
+ const start = m.index + m[0].length;
60
+ const next = /^\s*\[/m.exec(text.slice(start));
61
+ const end = next ? start + next.index : text.length;
62
+ return { start, end, body: text.slice(start, end) };
63
+ }
64
+ function remoteRegistered(text) {
65
+ if (text === null)
66
+ return false;
67
+ const t = gisulTable(text);
68
+ if (!t)
69
+ return false;
70
+ const url = /^\s*url\s*=\s*"([^"]*)"/m.exec(t.body)?.[1];
71
+ if (url)
72
+ return !/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(url);
73
+ return /mcp-remote/.test(t.body);
74
+ }
75
+ function block(ctx) {
76
+ return readFileSync(join(ctx.packageRoot, "packs", "codex", "config.template.toml"), "utf8")
77
+ .replaceAll("{{PORT}}", String(ctx.port)).replaceAll("{{TOKEN}}", tomlEsc(ctx.token));
78
+ }
79
+ export const codexPack = {
80
+ id: "codex",
81
+ async detect(ctx) {
82
+ const evidence = [];
83
+ const dir = codexDir(ctx);
84
+ if (existsSync(dir))
85
+ evidence.push(dir);
86
+ const bin = onPath("codex", ctx.env);
87
+ if (bin)
88
+ evidence.push(bin);
89
+ return { installed: evidence.length > 0, evidence, remoteGisulRegistered: remoteRegistered(readText(configPath(ctx))) };
90
+ },
91
+ async plan(ctx) {
92
+ const plan = { files: [], commands: [], notes: [], conflicts: [] };
93
+ const path = configPath(ctx);
94
+ const before = readText(path);
95
+ const text = before ?? "";
96
+ const managed = splitManaged(text);
97
+ const outside = managed ? managed.outside : text;
98
+ if (OTEL_RE.test(outside)) {
99
+ plan.conflicts.push({ path, code: "unmanaged_otel", detail: "config.toml already has an unmanaged [otel] table or otel.* keys; refusing to edit" });
100
+ }
101
+ if (GISOOL_TABLE_RE.test(outside)) {
102
+ plan.conflicts.push({ path, code: "unmanaged_gisool_mcp", detail: "config.toml already has an unmanaged [mcp_servers.gisool] table" });
103
+ }
104
+ if (plan.conflicts.length)
105
+ return plan;
106
+ const wanted = block(ctx);
107
+ let after;
108
+ if (managed) {
109
+ after = text.slice(0, managed.start) + wanted + text.slice(managed.end);
110
+ }
111
+ else {
112
+ const sep = text === "" ? "" : text.endsWith("\n") ? "\n" : "\n\n";
113
+ after = text + sep + wanted;
114
+ }
115
+ if (ctx.disableRemote) {
116
+ const t = gisulTable(after);
117
+ if (t && remoteRegistered(after)) {
118
+ const body = /^\s*enabled\s*=.*$/m.test(t.body)
119
+ ? t.body.replace(/^(\s*)enabled\s*=.*$/m, "$1enabled = false")
120
+ : "enabled = false\n" + t.body;
121
+ after = after.slice(0, t.start) + body + after.slice(t.end);
122
+ plan.notes.push("remote gisul MCP entry disabled with enabled = false");
123
+ }
124
+ }
125
+ if (after !== before)
126
+ plan.files.push({ path, before, after, mode: 0o600 });
127
+ plan.notes.push("codex exec exports OTel events only partially; no Codex hooks are installed (they require trust review)");
128
+ return plan;
129
+ },
130
+ async doctorChecks(ctx) {
131
+ const path = configPath(ctx);
132
+ const text = readText(path);
133
+ const managed = text === null ? null : splitManaged(text);
134
+ const inner = managed && text ? text.slice(managed.start, managed.end) : "";
135
+ const endpoint = `http://127.0.0.1:${ctx.port}/ingest/otlp/v1/logs`;
136
+ // A project-local .codex/config.toml counts only when it is a different file from the user-level config (doctor run
137
+ // from $HOME reads the same file), and gisool's own managed block there is not a conflict.
138
+ const localPath = join(process.cwd(), ".codex", "config.toml");
139
+ const local = realOrResolved(localPath) === realOrResolved(path) ? null : readText(localPath);
140
+ const localOutside = local === null ? null : (splitManaged(local)?.outside ?? local);
141
+ return [
142
+ { name: "codex OTel block present", ok: inner.includes("[otel]") && inner.includes(endpoint), detail: managed ? path : `no managed block in ${path}` },
143
+ { name: "codex MCP entry present", ok: inner.includes("[mcp_servers.gisool]") && inner.includes(`http://127.0.0.1:${ctx.port}/mcp`), detail: path },
144
+ { name: "codex [otel] not in project-local config", ok: localOutside === null || !OTEL_RE.test(localOutside),
145
+ detail: local === null ? "no project-local config" : "project-local .codex/config.toml defines otel" },
146
+ ];
147
+ },
148
+ };
@@ -0,0 +1,95 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isObject, isRemoteEntry, noop, planJsonFile, readJsonConfig, refuse } from "./json-config.js";
4
+ // Hook shape confirmed against https://cursor.com/docs/agent/hooks : hooks.json is { "version": 1, "hooks": { <event>: [{ "command": ... }] } }
5
+ // and Cursor reloads hooks config files automatically. MCP servers live in ~/.cursor/mcp.json under mcpServers.
6
+ const HOOK_EVENTS = ["beforeSubmitPrompt", "sessionStart", "sessionEnd"];
7
+ const URL_KEYS = ["url"];
8
+ function localMcpUrl(port) {
9
+ return `http://127.0.0.1:${port}/mcp`;
10
+ }
11
+ function hookCommand(ctx) {
12
+ return `"${ctx.nodePath}" "${join(ctx.packageRoot, "packs", "hook-post.mjs")}" cursor`;
13
+ }
14
+ function isOurHook(entry) {
15
+ return isObject(entry) && typeof entry.command === "string" && entry.command.endsWith('hook-post.mjs" cursor');
16
+ }
17
+ export const cursorPack = {
18
+ id: "cursor",
19
+ async detect({ home }) {
20
+ const dir = join(home, ".cursor");
21
+ const installed = existsSync(dir);
22
+ const servers = readJsonConfig(join(dir, "mcp.json")).obj.mcpServers;
23
+ return {
24
+ installed,
25
+ evidence: installed ? [dir] : [],
26
+ remoteGisulRegistered: isObject(servers) && isRemoteEntry(servers.gisul, URL_KEYS),
27
+ };
28
+ },
29
+ async plan(ctx) {
30
+ const dir = join(ctx.home, ".cursor");
31
+ if (!existsSync(dir))
32
+ return noop("~/.cursor not found; Cursor does not appear to be installed, nothing planned.");
33
+ const mcpPath = join(dir, "mcp.json");
34
+ const hooksPath = join(dir, "hooks.json");
35
+ const mcp = readJsonConfig(mcpPath);
36
+ const hooksFile = readJsonConfig(hooksPath);
37
+ for (const [path, cfg] of [
38
+ [mcpPath, mcp],
39
+ [hooksPath, hooksFile],
40
+ ]) {
41
+ if (cfg.error)
42
+ return refuse(path, "unparseable_config", cfg.error);
43
+ }
44
+ const nextMcp = structuredClone(mcp.obj);
45
+ const servers = nextMcp.mcpServers ?? {};
46
+ if (!isObject(servers))
47
+ return refuse(mcpPath, "invalid_shape", "mcpServers must be a JSON object");
48
+ const notes = [];
49
+ if (ctx.disableRemote && isRemoteEntry(servers.gisul, URL_KEYS)) {
50
+ notes.push("Remote gisul MCP entry left enabled: Cursor's disable key is unverified, so disable it manually.");
51
+ }
52
+ servers.gisool = { url: localMcpUrl(ctx.port), headers: { Authorization: `Bearer ${ctx.token}` } };
53
+ nextMcp.mcpServers = servers;
54
+ if (hooksFile.obj.version !== undefined && hooksFile.obj.version !== 1) {
55
+ return refuse(hooksPath, "invalid_shape", "hooks.json version must be 1");
56
+ }
57
+ const nextHooks = { ...structuredClone(hooksFile.obj), version: 1 };
58
+ const hooks = nextHooks.hooks ?? {};
59
+ if (!isObject(hooks))
60
+ return refuse(hooksPath, "invalid_shape", "hooks must be a JSON object");
61
+ for (const event of HOOK_EVENTS) {
62
+ const entries = hooks[event] ?? [];
63
+ if (!Array.isArray(entries))
64
+ return refuse(hooksPath, "invalid_shape", `hooks.${event} must be an array`);
65
+ hooks[event] = [...entries.filter((entry) => !isOurHook(entry)), { command: hookCommand(ctx) }];
66
+ }
67
+ nextHooks.hooks = hooks;
68
+ const files = [planJsonFile(mcpPath, mcp, nextMcp), planJsonFile(hooksPath, hooksFile, nextHooks)].filter((f) => f !== null);
69
+ notes.push("Restart Cursor or reload MCP servers to load the gisool MCP entry; Cursor reloads hooks.json on change.");
70
+ return { files, commands: [], notes, conflicts: [] };
71
+ },
72
+ async doctorChecks({ home, port }) {
73
+ const mcp = readJsonConfig(join(home, ".cursor", "mcp.json"));
74
+ if (mcp.error)
75
+ return [{ name: "cursor mcp.json", ok: false, detail: mcp.error }];
76
+ const hooksFile = readJsonConfig(join(home, ".cursor", "hooks.json"));
77
+ if (hooksFile.error)
78
+ return [{ name: "cursor hooks.json", ok: false, detail: hooksFile.error }];
79
+ const servers = isObject(mcp.obj.mcpServers) ? mcp.obj.mcpServers : {};
80
+ const entry = isObject(servers.gisool) ? servers.gisool : {};
81
+ const hooks = isObject(hooksFile.obj.hooks) ? hooksFile.obj.hooks : {};
82
+ const hooksPresent = HOOK_EVENTS.every((event) => {
83
+ const entries = hooks[event];
84
+ return Array.isArray(entries) && entries.some(isOurHook);
85
+ });
86
+ return [
87
+ { name: "cursor MCP gisool", ok: entry.url === localMcpUrl(port), detail: `url ${String(entry.url ?? "missing")}` },
88
+ {
89
+ name: "cursor capture hooks",
90
+ ok: hooksPresent,
91
+ detail: hooksPresent ? "beforeSubmitPrompt, sessionStart, sessionEnd present" : "capture hooks missing; rerun gisool init",
92
+ },
93
+ ];
94
+ },
95
+ };
@@ -0,0 +1,7 @@
1
+ import { createPiPack, resolveGajaeAgentDir } from "./omo.js";
2
+ export const gajaePack = createPiPack({
3
+ id: "gajae",
4
+ agentDir: resolveGajaeAgentDir,
5
+ extensionName: "gisool",
6
+ installMarker: ".gjc",
7
+ });