@sksoftofficial/mindroot 1.0.6 → 1.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.
- package/INSTRUCTIONS.md +41 -0
- package/README.md +80 -13
- package/bin/mindroot.js +4 -0
- package/package.json +6 -2
- package/src/commands/install.js +210 -0
- package/src/core.js +9 -2
- package/src/dashboard/graph.js +175 -0
- package/src/dashboard/index.html +246 -80
- package/src/mcp.js +62 -1
- package/src/paths.js +15 -3
- package/src/search.js +28 -0
- package/src/server.js +25 -4
- package/src/skills.js +133 -0
- package/src/store/db.js +50 -0
- package/test/install-clients.test.js +187 -0
- package/test/install.test.js +127 -0
- package/test/mcp.test.js +4 -0
- package/test/paths.test.js +53 -0
- package/test/skills.test.js +253 -0
package/src/server.js
CHANGED
|
@@ -12,6 +12,8 @@ const DASHBOARD = path.join(DASHBOARD_DIR, "index.html");
|
|
|
12
12
|
const DASHBOARD_ASSETS = new Map([
|
|
13
13
|
["/favicon.png", ["favicon.png", "image/png"]],
|
|
14
14
|
["/logo.png", ["logo.png", "image/png"]],
|
|
15
|
+
["/graph.js", ["graph.js", "text/javascript; charset=utf-8"]],
|
|
16
|
+
["/vendor/force-graph.min.js", [fileURLToPath(new URL("./force-graph.min.js", import.meta.resolve("force-graph"))), "text/javascript; charset=utf-8"]],
|
|
15
17
|
]);
|
|
16
18
|
|
|
17
19
|
export function createServer(db, cfg) {
|
|
@@ -29,10 +31,10 @@ export function createServer(db, cfg) {
|
|
|
29
31
|
|
|
30
32
|
const asset = DASHBOARD_ASSETS.get(url.pathname);
|
|
31
33
|
if (req.method === "GET" && asset) {
|
|
32
|
-
const body = await fs.readFile(path.
|
|
34
|
+
const body = await fs.readFile(path.resolve(DASHBOARD_DIR, asset[0]));
|
|
33
35
|
res.writeHead(200, {
|
|
34
36
|
"content-type": asset[1],
|
|
35
|
-
"cache-control": "public, max-age=86400",
|
|
37
|
+
"cache-control": asset[0] === "graph.js" ? "no-cache" : "public, max-age=86400",
|
|
36
38
|
});
|
|
37
39
|
res.end(body);
|
|
38
40
|
return;
|
|
@@ -97,13 +99,30 @@ async function handleApi(req, res, url, db) {
|
|
|
97
99
|
if (parts.length === 3) {
|
|
98
100
|
if (req.method === "GET") {
|
|
99
101
|
const project = getProjectRow(db, slug);
|
|
100
|
-
const [docs, memories] = await Promise.all([
|
|
101
|
-
|
|
102
|
+
const [docs, memories, skills] = await Promise.all([
|
|
103
|
+
core.listDocs(db, slug), core.listMemories(db, slug), core.listSkills(db, slug),
|
|
104
|
+
]);
|
|
105
|
+
return sendJson(res, 200, { slug, id: project.id, docs, memories, skills });
|
|
102
106
|
}
|
|
103
107
|
if (req.method === "DELETE") return sendJson(res, 200, await core.deleteProject(db, slug));
|
|
104
108
|
throw methodNotAllowed();
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
if (parts[3] === "skills") {
|
|
112
|
+
if (parts.length === 4 && req.method === "GET") return sendJson(res, 200, core.listSkills(db, slug));
|
|
113
|
+
if (parts.length !== 5) throw notFound();
|
|
114
|
+
const name = decodeURIComponent(parts[4]);
|
|
115
|
+
if (req.method === "GET") {
|
|
116
|
+
const revision = url.searchParams.get("revision");
|
|
117
|
+
return sendJson(res, 200, core.readSkill(db, slug, name, revision === null ? undefined : Number(revision)));
|
|
118
|
+
}
|
|
119
|
+
if (req.method === "PUT") {
|
|
120
|
+
const body = await readJson(req);
|
|
121
|
+
return sendJson(res, 200, await core.saveSkill(db, slug, name, body.description, body.content, body.expected_revision));
|
|
122
|
+
}
|
|
123
|
+
if (req.method === "DELETE") return sendJson(res, 200, core.deleteSkill(db, slug, name));
|
|
124
|
+
throw methodNotAllowed();
|
|
125
|
+
}
|
|
107
126
|
if (parts[3] === "docs") {
|
|
108
127
|
if (req.method === "GET") return sendJson(res, 200, await core.listDocs(db, slug));
|
|
109
128
|
throw methodNotAllowed();
|
|
@@ -153,6 +172,8 @@ async function handleApi(req, res, url, db) {
|
|
|
153
172
|
hits = await core.searchMemories(db, body.project, body.query, limit);
|
|
154
173
|
} else if (body.kind === "notes") {
|
|
155
174
|
hits = await core.searchNotes(db, body.project, body.query, limit);
|
|
175
|
+
} else if (body.kind === "skills") {
|
|
176
|
+
hits = await core.searchSkills(db, body.project, body.query, limit);
|
|
156
177
|
} else {
|
|
157
178
|
const [memories, notes] = await Promise.all([
|
|
158
179
|
core.searchMemories(db, body.project, body.query, limit),
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { validSlug, ensureProject, getProjectRow, httpError } from "./indexer.js";
|
|
2
|
+
import { embedDoc, embedQuery } from "./embedder.js";
|
|
3
|
+
import { ONNX_MODEL_ID } from "./paths.js";
|
|
4
|
+
import { rankSkills } from "./search.js";
|
|
5
|
+
|
|
6
|
+
function validateProject(slug) {
|
|
7
|
+
if (!slug) throw httpError(400, "project is required");
|
|
8
|
+
if (!validSlug(slug)) throw httpError(400, `invalid project slug: ${slug}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validateName(name) {
|
|
12
|
+
if (typeof name !== "string" || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
13
|
+
throw httpError(400, "skill name must be 1-64 lowercase letters/digits separated by single hyphens");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateRevision(revision, minimum) {
|
|
18
|
+
if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < minimum)) {
|
|
19
|
+
throw httpError(400, `revision must be an integer >= ${minimum}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findSkill(db, projectId, name) {
|
|
24
|
+
return db.prepare("SELECT * FROM skills WHERE project_id = ? AND name = ?").get(projectId, name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summary(row) {
|
|
28
|
+
return {
|
|
29
|
+
name: row.name,
|
|
30
|
+
description: row.description,
|
|
31
|
+
revision: row.revision,
|
|
32
|
+
created_at: row.created_at,
|
|
33
|
+
updated_at: row.updated_at,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function listSkills(db, slug) {
|
|
38
|
+
validateProject(slug);
|
|
39
|
+
const project = getProjectRow(db, slug);
|
|
40
|
+
return db.prepare(
|
|
41
|
+
"SELECT name, description, revision, created_at, updated_at FROM skills WHERE project_id = ? ORDER BY name",
|
|
42
|
+
).all(project.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readSkill(db, slug, name, revision) {
|
|
46
|
+
validateProject(slug);
|
|
47
|
+
validateName(name);
|
|
48
|
+
validateRevision(revision, 1);
|
|
49
|
+
const project = getProjectRow(db, slug);
|
|
50
|
+
const current = findSkill(db, project.id, name);
|
|
51
|
+
if (!current) throw httpError(404, `skill not found: ${name}`);
|
|
52
|
+
const row = revision === undefined ? current : db.prepare(
|
|
53
|
+
"SELECT description, content, revision, saved_at AS updated_at FROM skill_revisions WHERE skill_id = ? AND revision = ?",
|
|
54
|
+
).get(current.id, revision);
|
|
55
|
+
if (!row) throw httpError(404, `skill revision not found: ${name}@${revision}`);
|
|
56
|
+
return { ...summary({ ...current, ...row }), content: row.content, current_revision: current.revision };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Embed before opening the transaction; never hold a write lock during model inference.
|
|
60
|
+
export async function saveSkill(db, slug, name, description, content, expectedRevision, embed = embedDoc) {
|
|
61
|
+
validateProject(slug);
|
|
62
|
+
validateName(name);
|
|
63
|
+
validateRevision(expectedRevision, 0);
|
|
64
|
+
if (typeof description !== "string" || !description.trim() || description.length > 2000) {
|
|
65
|
+
throw httpError(400, "skill description is required (maximum 2000 characters)");
|
|
66
|
+
}
|
|
67
|
+
if (typeof content !== "string" || !content.trim()) throw httpError(400, "skill content is required");
|
|
68
|
+
description = description.trim();
|
|
69
|
+
const project = db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
70
|
+
const previous = project && findSkill(db, project.id, name);
|
|
71
|
+
const previousRevision = previous?.revision ?? 0;
|
|
72
|
+
if (expectedRevision !== undefined && expectedRevision !== previousRevision) {
|
|
73
|
+
throw httpError(409, "skill changed; read the latest revision before saving");
|
|
74
|
+
}
|
|
75
|
+
if (previous?.description === description && previous.content === content) {
|
|
76
|
+
return { ...summary(previous), unchanged: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const vec = await embed(`${name}\n${description}`);
|
|
80
|
+
db.exec("BEGIN IMMEDIATE");
|
|
81
|
+
try {
|
|
82
|
+
const currentProject = db.prepare("SELECT id FROM projects WHERE slug = ?").get(slug);
|
|
83
|
+
if (project && currentProject?.id !== project.id) {
|
|
84
|
+
throw httpError(409, "project changed while saving skill; resolve the project again");
|
|
85
|
+
}
|
|
86
|
+
const target = currentProject ?? ensureProject(db, slug);
|
|
87
|
+
const current = findSkill(db, target.id, name);
|
|
88
|
+
if ((current?.revision ?? 0) !== previousRevision || current?.id !== previous?.id) {
|
|
89
|
+
throw httpError(409, "skill changed; read the latest revision before saving");
|
|
90
|
+
}
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const revision = previousRevision + 1;
|
|
93
|
+
const row = db.prepare(
|
|
94
|
+
`INSERT INTO skills (project_id, name, description, content, revision, created_at, updated_at)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
ON CONFLICT(project_id, name) DO UPDATE SET
|
|
97
|
+
description = excluded.description, content = excluded.content,
|
|
98
|
+
revision = excluded.revision, updated_at = excluded.updated_at
|
|
99
|
+
RETURNING *`,
|
|
100
|
+
).get(target.id, name, description, content, revision, now, now);
|
|
101
|
+
db.prepare(
|
|
102
|
+
"INSERT INTO skill_revisions (skill_id, revision, description, content, saved_at) VALUES (?, ?, ?, ?, ?)",
|
|
103
|
+
).run(row.id, revision, description, content, now);
|
|
104
|
+
db.prepare(
|
|
105
|
+
`INSERT INTO skill_embeddings (skill_id, dim, model, vec) VALUES (?, ?, ?, ?)
|
|
106
|
+
ON CONFLICT(skill_id) DO UPDATE SET dim = excluded.dim, model = excluded.model, vec = excluded.vec`,
|
|
107
|
+
).run(row.id, vec.length, ONNX_MODEL_ID, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength));
|
|
108
|
+
db.exec("COMMIT");
|
|
109
|
+
return { ...summary(row), unchanged: false };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
db.exec("ROLLBACK");
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function deleteSkill(db, slug, name) {
|
|
117
|
+
validateProject(slug);
|
|
118
|
+
validateName(name);
|
|
119
|
+
const project = getProjectRow(db, slug);
|
|
120
|
+
const result = db.prepare("DELETE FROM skills WHERE project_id = ? AND name = ?").run(project.id, name);
|
|
121
|
+
if (!result.changes) throw httpError(404, `skill not found: ${name}`);
|
|
122
|
+
return { deleted: name };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function searchSkills(db, slug, query, limit = 8) {
|
|
126
|
+
validateProject(slug);
|
|
127
|
+
if (typeof query !== "string" || !query.trim()) throw httpError(400, "query is required");
|
|
128
|
+
const project = getProjectRow(db, slug);
|
|
129
|
+
const n = Number(limit);
|
|
130
|
+
const count = Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), 1), 100) : 8;
|
|
131
|
+
if (!db.prepare("SELECT 1 FROM skills WHERE project_id = ? LIMIT 1").get(project.id)) return [];
|
|
132
|
+
return rankSkills(db, project.id, await embedQuery(query), query, count);
|
|
133
|
+
}
|
package/src/store/db.js
CHANGED
|
@@ -55,6 +55,56 @@ CREATE TABLE IF NOT EXISTS project_embeddings (
|
|
|
55
55
|
vec BLOB NOT NULL
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
61
|
+
name TEXT NOT NULL,
|
|
62
|
+
description TEXT NOT NULL,
|
|
63
|
+
content TEXT NOT NULL,
|
|
64
|
+
revision INTEGER NOT NULL CHECK (revision > 0),
|
|
65
|
+
created_at INTEGER NOT NULL,
|
|
66
|
+
updated_at INTEGER NOT NULL,
|
|
67
|
+
UNIQUE(project_id, name)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS skill_revisions (
|
|
71
|
+
skill_id INTEGER NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
|
|
72
|
+
revision INTEGER NOT NULL,
|
|
73
|
+
description TEXT NOT NULL,
|
|
74
|
+
content TEXT NOT NULL,
|
|
75
|
+
saved_at INTEGER NOT NULL,
|
|
76
|
+
PRIMARY KEY(skill_id, revision)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS skill_embeddings (
|
|
80
|
+
skill_id INTEGER PRIMARY KEY REFERENCES skills(id) ON DELETE CASCADE,
|
|
81
|
+
dim INTEGER NOT NULL,
|
|
82
|
+
model TEXT NOT NULL,
|
|
83
|
+
vec BLOB NOT NULL
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
|
|
87
|
+
name,
|
|
88
|
+
description,
|
|
89
|
+
content='skills',
|
|
90
|
+
content_rowid='id'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE TRIGGER IF NOT EXISTS skills_ai AFTER INSERT ON skills BEGIN
|
|
94
|
+
INSERT INTO skills_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
|
95
|
+
END;
|
|
96
|
+
|
|
97
|
+
CREATE TRIGGER IF NOT EXISTS skills_ad AFTER DELETE ON skills BEGIN
|
|
98
|
+
INSERT INTO skills_fts(skills_fts, rowid, name, description)
|
|
99
|
+
VALUES ('delete', old.id, old.name, old.description);
|
|
100
|
+
END;
|
|
101
|
+
|
|
102
|
+
CREATE TRIGGER IF NOT EXISTS skills_au AFTER UPDATE OF name, description ON skills BEGIN
|
|
103
|
+
INSERT INTO skills_fts(skills_fts, rowid, name, description)
|
|
104
|
+
VALUES ('delete', old.id, old.name, old.description);
|
|
105
|
+
INSERT INTO skills_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
|
106
|
+
END;
|
|
107
|
+
|
|
58
108
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
59
109
|
text,
|
|
60
110
|
content='memories',
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { parse as parseToml } from "@decimalturn/toml-patch";
|
|
7
|
+
import { configureClaude, configureCodex } from "../src/commands/install.js";
|
|
8
|
+
|
|
9
|
+
let dir;
|
|
10
|
+
const cfg = { port: 8765, apiKey: "test-only-key" };
|
|
11
|
+
const policy = (await fs.readFile(new URL("../INSTRUCTIONS.md", import.meta.url), "utf8")).trim();
|
|
12
|
+
const savedEnv = {};
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), "mindroot-install-clients-"));
|
|
16
|
+
for (const name of ["CLAUDE_CONFIG_DIR", "CODEX_HOME"]) {
|
|
17
|
+
savedEnv[name] = process.env[name];
|
|
18
|
+
delete process.env[name];
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
for (const [name, value] of Object.entries(savedEnv)) {
|
|
24
|
+
if (value === undefined) delete process.env[name];
|
|
25
|
+
else process.env[name] = value;
|
|
26
|
+
}
|
|
27
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("claude: defaults keep .claude.json outside ~/.claude; a rerun is byte-identical", async (t) => {
|
|
31
|
+
const home = path.join(dir, "home");
|
|
32
|
+
t.mock.method(os, "homedir", () => home);
|
|
33
|
+
const { configPath, agentsPath } = configureClaude(cfg);
|
|
34
|
+
assert.equal(configPath, path.join(home, ".claude.json"));
|
|
35
|
+
assert.equal(agentsPath, path.join(home, ".claude", "CLAUDE.md"));
|
|
36
|
+
const configText = await fs.readFile(configPath, "utf8");
|
|
37
|
+
assert.deepEqual(JSON.parse(configText), {
|
|
38
|
+
mcpServers: { mindroot: {
|
|
39
|
+
type: "http", url: "http://127.0.0.1:8765/mcp",
|
|
40
|
+
headers: { Authorization: "Bearer test-only-key" },
|
|
41
|
+
} },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), policy + "\n");
|
|
44
|
+
configureClaude(cfg);
|
|
45
|
+
assert.equal(await fs.readFile(configPath, "utf8"), configText);
|
|
46
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), policy + "\n");
|
|
47
|
+
if (process.platform !== "win32") assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("claude: explicit directories and CLAUDE_CONFIG_DIR keep both files inside", async () => {
|
|
51
|
+
const explicit = configureClaude(cfg, path.join(dir, "explicit"));
|
|
52
|
+
assert.equal(explicit.configPath, path.join(dir, "explicit", ".claude.json"));
|
|
53
|
+
assert.equal(explicit.agentsPath, path.join(dir, "explicit", "CLAUDE.md"));
|
|
54
|
+
process.env.CLAUDE_CONFIG_DIR = path.join(dir, "envdir");
|
|
55
|
+
const env = configureClaude(cfg);
|
|
56
|
+
assert.equal(env.configPath, path.join(dir, "envdir", ".claude.json"));
|
|
57
|
+
assert.equal(env.agentsPath, path.join(dir, "envdir", "CLAUDE.md"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("claude: preserves unrelated settings and refreshes the owned entry", async () => {
|
|
61
|
+
const configPath = path.join(dir, ".claude.json");
|
|
62
|
+
await fs.writeFile(configPath, JSON.stringify({
|
|
63
|
+
model: "claude-opus",
|
|
64
|
+
mcpServers: { other: { type: "stdio", command: ["x"] }, mindroot: { type: "sse", url: "stale" } },
|
|
65
|
+
projects: { "/work": { allowed: true } },
|
|
66
|
+
}));
|
|
67
|
+
configureClaude(cfg, dir);
|
|
68
|
+
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
69
|
+
assert.equal(config.model, "claude-opus");
|
|
70
|
+
assert.deepEqual(config.projects, { "/work": { allowed: true } });
|
|
71
|
+
assert.deepEqual(config.mcpServers.other, { type: "stdio", command: ["x"] });
|
|
72
|
+
assert.deepEqual(config.mcpServers.mindroot, {
|
|
73
|
+
type: "http", url: "http://127.0.0.1:8765/mcp",
|
|
74
|
+
headers: { Authorization: "Bearer test-only-key" },
|
|
75
|
+
});
|
|
76
|
+
configureClaude({ port: 9876, apiKey: "replacement-test-key" }, dir);
|
|
77
|
+
const updated = JSON.parse(await fs.readFile(configPath, "utf8")).mcpServers.mindroot;
|
|
78
|
+
assert.equal(updated.url, "http://127.0.0.1:9876/mcp");
|
|
79
|
+
assert.equal(updated.headers.Authorization, "Bearer replacement-test-key");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("claude: malformed config or markers leave both files untouched", async () => {
|
|
83
|
+
const configPath = path.join(dir, ".claude.json");
|
|
84
|
+
const agentsPath = path.join(dir, "CLAUDE.md");
|
|
85
|
+
const cases = [
|
|
86
|
+
["{ broken", "Keep me", /invalid Claude configuration/],
|
|
87
|
+
["[1]", "Keep me", /invalid Claude configuration/],
|
|
88
|
+
['{"mcpServers": []}', "Keep me", /expected an mcpServers object/],
|
|
89
|
+
["{}", "<!-- mindroot:start -->\nunfinished", /unbalanced mindroot markers/],
|
|
90
|
+
];
|
|
91
|
+
for (const [config, agents, error] of cases) {
|
|
92
|
+
await fs.writeFile(configPath, config);
|
|
93
|
+
await fs.writeFile(agentsPath, agents);
|
|
94
|
+
assert.throws(() => configureClaude(cfg, dir), error);
|
|
95
|
+
assert.equal(await fs.readFile(configPath, "utf8"), config);
|
|
96
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), agents);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("codex: creates config and policy, preserves TOML comments; a rerun is byte-identical", async () => {
|
|
101
|
+
const configPath = path.join(dir, "config.toml");
|
|
102
|
+
await fs.writeFile(configPath, `# Codex config
|
|
103
|
+
model = "gpt-5-codex"
|
|
104
|
+
|
|
105
|
+
[mcp_servers.other]
|
|
106
|
+
url = "https://example.com/mcp" # keep me
|
|
107
|
+
`);
|
|
108
|
+
const { configPath: returned, agentsPath } = configureCodex(cfg, dir);
|
|
109
|
+
assert.equal(returned, configPath);
|
|
110
|
+
assert.equal(agentsPath, path.join(dir, "AGENTS.md"));
|
|
111
|
+
const first = await fs.readFile(configPath, "utf8");
|
|
112
|
+
assert.ok(first.includes("# Codex config"));
|
|
113
|
+
assert.ok(first.includes('model = "gpt-5-codex"'));
|
|
114
|
+
assert.ok(first.includes('url = "https://example.com/mcp" # keep me'));
|
|
115
|
+
const config = parseToml(first);
|
|
116
|
+
assert.deepEqual(config.mcp_servers.mindroot, {
|
|
117
|
+
url: "http://127.0.0.1:8765/mcp",
|
|
118
|
+
http_headers: { Authorization: "Bearer test-only-key" },
|
|
119
|
+
});
|
|
120
|
+
assert.equal(config.model, "gpt-5-codex");
|
|
121
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), policy + "\n");
|
|
122
|
+
configureCodex(cfg, dir);
|
|
123
|
+
assert.equal(await fs.readFile(configPath, "utf8"), first);
|
|
124
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), policy + "\n");
|
|
125
|
+
if (process.platform !== "win32") assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("codex: honors CODEX_HOME or ~/.codex by default and refreshes the owned entry", async (t) => {
|
|
129
|
+
const home = path.join(dir, "home");
|
|
130
|
+
t.mock.method(os, "homedir", () => home);
|
|
131
|
+
const defaulted = configureCodex(cfg);
|
|
132
|
+
assert.equal(defaulted.configPath, path.join(home, ".codex", "config.toml"));
|
|
133
|
+
assert.equal(defaulted.agentsPath, path.join(home, ".codex", "AGENTS.md"));
|
|
134
|
+
process.env.CODEX_HOME = path.join(dir, "custom");
|
|
135
|
+
const env = configureCodex({ port: 9876, apiKey: "replacement-test-key" });
|
|
136
|
+
assert.equal(env.configPath, path.join(dir, "custom", "config.toml"));
|
|
137
|
+
const config = parseToml(await fs.readFile(env.configPath, "utf8"));
|
|
138
|
+
assert.equal(config.mcp_servers.mindroot.url, "http://127.0.0.1:9876/mcp");
|
|
139
|
+
assert.equal(config.mcp_servers.mindroot.http_headers.Authorization, "Bearer replacement-test-key");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("codex: syncs a non-empty AGENTS.override.md; an empty override falls back to AGENTS.md", async () => {
|
|
143
|
+
const basePath = path.join(dir, "AGENTS.md");
|
|
144
|
+
const overridePath = path.join(dir, "AGENTS.override.md");
|
|
145
|
+
await fs.writeFile(basePath, "# Base\nKeep me.\n");
|
|
146
|
+
await fs.writeFile(overridePath, "Override content.\n");
|
|
147
|
+
const active = configureCodex(cfg, dir);
|
|
148
|
+
assert.equal(active.agentsPath, overridePath);
|
|
149
|
+
assert.equal(await fs.readFile(overridePath, "utf8"), "Override content.\n\n" + policy + "\n");
|
|
150
|
+
assert.equal(await fs.readFile(basePath, "utf8"), "# Base\nKeep me.\n");
|
|
151
|
+
|
|
152
|
+
await fs.writeFile(overridePath, "\n");
|
|
153
|
+
const fallback = configureCodex(cfg, dir);
|
|
154
|
+
assert.equal(fallback.agentsPath, basePath);
|
|
155
|
+
assert.equal(await fs.readFile(overridePath, "utf8"), "\n");
|
|
156
|
+
assert.equal(await fs.readFile(basePath, "utf8"), "# Base\nKeep me.\n\n" + policy + "\n");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("codex: malformed TOML, bad shapes, or markers leave both files untouched", async () => {
|
|
160
|
+
const configPath = path.join(dir, "config.toml");
|
|
161
|
+
const agentsPath = path.join(dir, "AGENTS.md");
|
|
162
|
+
const cases = [
|
|
163
|
+
["model = ", "Keep me", /invalid Codex configuration/],
|
|
164
|
+
["mcp_servers = 3", "Keep me", /expected an mcp_servers table/],
|
|
165
|
+
["mcp_servers.mindroot = 5", "Keep me", /expected mcp_servers\.mindroot to be a table/],
|
|
166
|
+
["", "<!-- mindroot:end -->", /unbalanced mindroot markers/],
|
|
167
|
+
];
|
|
168
|
+
for (const [config, agents, error] of cases) {
|
|
169
|
+
await fs.writeFile(configPath, config);
|
|
170
|
+
await fs.writeFile(agentsPath, agents);
|
|
171
|
+
assert.throws(() => configureCodex(cfg, dir), error);
|
|
172
|
+
assert.equal(await fs.readFile(configPath, "utf8"), config);
|
|
173
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), agents);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("both clients replace marked policies and preserve surrounding instructions", async () => {
|
|
178
|
+
const prefix = "# User instructions\nKeep these.\n\n";
|
|
179
|
+
const suffix = "\nCustom footer without a trailing newline";
|
|
180
|
+
const old = "<!-- mindroot:start -->\nOld policy\n<!-- mindroot:end -->";
|
|
181
|
+
for (const configure of [(d) => configureClaude(cfg, d), (d) => configureCodex(cfg, d)]) {
|
|
182
|
+
const agentsPath = path.join(dir, "AGENTS.md");
|
|
183
|
+
await fs.writeFile(agentsPath, prefix + old + suffix);
|
|
184
|
+
configure(dir);
|
|
185
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), prefix + policy + suffix);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { parse } from "jsonc-parser";
|
|
7
|
+
import { configureOpencode, install } from "../src/commands/install.js";
|
|
8
|
+
|
|
9
|
+
let dir;
|
|
10
|
+
const cfg = { port: 8765, apiKey: "test-only-key" };
|
|
11
|
+
const policy = (await fs.readFile(new URL("../INSTRUCTIONS.md", import.meta.url), "utf8")).trim();
|
|
12
|
+
beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), "mindroot-install-")); });
|
|
13
|
+
afterEach(() => fs.rm(dir, { recursive: true, force: true }));
|
|
14
|
+
|
|
15
|
+
test("creates global config and policy; a rerun is byte-identical", async () => {
|
|
16
|
+
const configDir = path.join(dir, "opencode");
|
|
17
|
+
const { configPath, agentsPath } = configureOpencode(cfg, configDir);
|
|
18
|
+
const configText = await fs.readFile(configPath, "utf8");
|
|
19
|
+
assert.deepEqual(JSON.parse(configText), {
|
|
20
|
+
$schema: "https://opencode.ai/config.json",
|
|
21
|
+
mcp: { mindroot: {
|
|
22
|
+
type: "remote", url: "http://127.0.0.1:8765/mcp", enabled: true,
|
|
23
|
+
headers: { Authorization: "Bearer test-only-key" },
|
|
24
|
+
} },
|
|
25
|
+
});
|
|
26
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), policy + "\n");
|
|
27
|
+
configureOpencode(cfg, configDir);
|
|
28
|
+
assert.equal(await fs.readFile(configPath, "utf8"), configText);
|
|
29
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), policy + "\n");
|
|
30
|
+
if (process.platform !== "win32") assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("refreshes the owned MCP entry while preserving JSONC and unrelated settings", async () => {
|
|
34
|
+
const jsonPath = path.join(dir, "opencode.json");
|
|
35
|
+
const jsoncPath = path.join(dir, "opencode.jsonc");
|
|
36
|
+
const json = '{"model":"keep/this"}\n';
|
|
37
|
+
await fs.writeFile(jsonPath, json);
|
|
38
|
+
await fs.writeFile(jsoncPath, `{
|
|
39
|
+
// Keep this comment and setting.
|
|
40
|
+
"model": "provider/model",
|
|
41
|
+
"mcp": {
|
|
42
|
+
// Another server stays untouched.
|
|
43
|
+
"other": { "type": "local", "command": ["other-mcp"] },
|
|
44
|
+
"mindroot": { "type": "local", "command": ["old"], "enabled": false },
|
|
45
|
+
},
|
|
46
|
+
}\n`);
|
|
47
|
+
const { configPath } = configureOpencode(cfg, dir);
|
|
48
|
+
assert.equal(configPath, jsoncPath);
|
|
49
|
+
assert.equal(await fs.readFile(jsonPath, "utf8"), json);
|
|
50
|
+
const text = await fs.readFile(jsoncPath, "utf8");
|
|
51
|
+
assert.ok(text.includes("// Keep this comment and setting."));
|
|
52
|
+
assert.ok(text.includes('// Another server stays untouched.\n "other": { "type": "local", "command": ["other-mcp"] },'));
|
|
53
|
+
const config = parse(text);
|
|
54
|
+
assert.equal(config.model, "provider/model");
|
|
55
|
+
assert.equal(config.mcp.mindroot.command, undefined);
|
|
56
|
+
assert.equal(config.mcp.mindroot.enabled, true);
|
|
57
|
+
configureOpencode({ port: 9876, apiKey: "replacement-test-key" }, dir);
|
|
58
|
+
const updated = parse(await fs.readFile(jsoncPath, "utf8")).mcp.mindroot;
|
|
59
|
+
assert.equal(updated.url, "http://127.0.0.1:9876/mcp");
|
|
60
|
+
assert.equal(updated.headers.Authorization, "Bearer replacement-test-key");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("replaces marked policies, removes duplicates, and preserves surrounding instructions", async () => {
|
|
64
|
+
const agentsPath = path.join(dir, "AGENTS.md");
|
|
65
|
+
const old = "<!-- mindroot:start -->\nOld policy\n<!-- mindroot:end -->";
|
|
66
|
+
const prefix = "# User instructions\nKeep these.\n\n";
|
|
67
|
+
const middle = "\n\n<!-- another:start -->\nOther policy\n<!-- another:end -->\n";
|
|
68
|
+
const suffix = "\nCustom footer without a trailing newline";
|
|
69
|
+
await fs.writeFile(agentsPath, prefix + old + middle + old + suffix);
|
|
70
|
+
configureOpencode(cfg, dir);
|
|
71
|
+
const expected = prefix + policy + middle + suffix;
|
|
72
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), expected);
|
|
73
|
+
configureOpencode(cfg, dir);
|
|
74
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), expected);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("appends a separated policy when no marked block exists", async () => {
|
|
78
|
+
const agentsPath = path.join(dir, "AGENTS.md");
|
|
79
|
+
for (const existing of ["", "Keep me", "Keep me\n", "Keep me\n\n"]) {
|
|
80
|
+
await fs.writeFile(agentsPath, existing);
|
|
81
|
+
configureOpencode(cfg, dir);
|
|
82
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), (existing ? "Keep me\n\n" : "") + policy + "\n");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("malformed config or markers leave both files untouched", async () => {
|
|
87
|
+
const configPath = path.join(dir, "opencode.json");
|
|
88
|
+
const agentsPath = path.join(dir, "AGENTS.md");
|
|
89
|
+
const cases = [
|
|
90
|
+
["{ broken", "Keep me", /invalid OpenCode configuration/],
|
|
91
|
+
["[]", "Keep me", /invalid OpenCode configuration/],
|
|
92
|
+
['{"mcp": []}', "Keep me", /expected an mcp object/],
|
|
93
|
+
['{"mcp": null}', "Keep me", /expected an mcp object/],
|
|
94
|
+
["{}", "<!-- mindroot:start -->\nunfinished", /unbalanced mindroot markers/],
|
|
95
|
+
["{}", "<!-- mindroot:end -->", /unbalanced mindroot markers/],
|
|
96
|
+
["{}", "<!-- mindroot:start --><!-- mindroot:start --><!-- mindroot:end --><!-- mindroot:end -->", /unbalanced mindroot markers/],
|
|
97
|
+
];
|
|
98
|
+
for (const [config, agents, error] of cases) {
|
|
99
|
+
await fs.writeFile(configPath, config);
|
|
100
|
+
await fs.writeFile(agentsPath, agents);
|
|
101
|
+
assert.throws(() => configureOpencode(cfg, dir), error);
|
|
102
|
+
assert.equal(await fs.readFile(configPath, "utf8"), config);
|
|
103
|
+
assert.equal(await fs.readFile(agentsPath, "utf8"), agents);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("respects XDG_CONFIG_HOME and the explicit OpenCode directory override", (t) => {
|
|
108
|
+
const previous = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR };
|
|
109
|
+
t.after(() => {
|
|
110
|
+
for (const [name, value] of Object.entries(previous)) {
|
|
111
|
+
if (value === undefined) delete process.env[name];
|
|
112
|
+
else process.env[name] = value;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
process.env.XDG_CONFIG_HOME = dir;
|
|
116
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
117
|
+
assert.equal(configureOpencode(cfg).configPath, path.join(dir, "opencode", "opencode.json"));
|
|
118
|
+
const custom = path.join(dir, "custom");
|
|
119
|
+
process.env.OPENCODE_CONFIG_DIR = custom;
|
|
120
|
+
assert.equal(configureOpencode(cfg).agentsPath, path.join(custom, "AGENTS.md"));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("rejects missing or unsupported install targets before initialization", async () => {
|
|
124
|
+
for (const args of [[], ["unknown"], ["opencode", "extra"], ["claude", "codex"]]) {
|
|
125
|
+
await assert.rejects(install(args), /usage: mindroot install <opencode\|claude\|codex>/);
|
|
126
|
+
}
|
|
127
|
+
});
|
package/test/mcp.test.js
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
let dir;
|
|
8
|
+
let paths;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), "mindroot-paths-"));
|
|
11
|
+
process.env.MINDROOT_DIR = dir;
|
|
12
|
+
paths = await import("../src/paths.js");
|
|
13
|
+
});
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
delete process.env.MINDROOT_DIR;
|
|
16
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("ensureConfig generates the key once and keeps it stable across runs", async () => {
|
|
20
|
+
const first = paths.ensureConfig();
|
|
21
|
+
assert.equal(first.created, true);
|
|
22
|
+
assert.ok(first.cfg.apiKey);
|
|
23
|
+
const second = paths.ensureConfig();
|
|
24
|
+
assert.equal(second.created, false);
|
|
25
|
+
assert.equal(second.cfg.apiKey, first.cfg.apiKey);
|
|
26
|
+
assert.equal(second.cfg.port, paths.DEFAULT_PORT);
|
|
27
|
+
if (process.platform !== "win32") {
|
|
28
|
+
assert.equal((await fs.stat(path.join(dir, "config.json"))).mode & 0o777, 0o600);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ensureConfig keeps a stored key and port", async () => {
|
|
33
|
+
await fs.writeFile(path.join(dir, "config.json"), JSON.stringify({ apiKey: "keep-me", port: 9999 }));
|
|
34
|
+
const { cfg, created } = paths.ensureConfig();
|
|
35
|
+
assert.equal(created, false);
|
|
36
|
+
assert.equal(cfg.apiKey, "keep-me");
|
|
37
|
+
assert.equal(cfg.port, 9999);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("missing config reads as empty without creating one", () => {
|
|
41
|
+
const { cfg, created } = paths.ensureConfig();
|
|
42
|
+
assert.equal(created, true);
|
|
43
|
+
assert.ok(cfg.apiKey);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("corrupt or non-object configs fail loudly instead of rotating the key", async () => {
|
|
47
|
+
const configPath = path.join(dir, "config.json");
|
|
48
|
+
for (const content of ['{ broken', "", "[]", '"x"']) {
|
|
49
|
+
await fs.writeFile(configPath, content);
|
|
50
|
+
assert.throws(() => paths.ensureConfig(), /invalid mindroot configuration|expected a JSON object/);
|
|
51
|
+
assert.equal(await fs.readFile(configPath, "utf8"), content);
|
|
52
|
+
}
|
|
53
|
+
});
|