@relentlessbuild/decs-mcp 0.2.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/dist/index.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { parseArgs, flagBoolean, flagString } from "./cli/args.js";
4
+ import { logError, logStep } from "./cli/output.js";
5
+ import { runDoctor } from "./commands/doctor.js";
6
+ import { runInit } from "./commands/init.js";
7
+ import { runSetupClaudeDesktop } from "./commands/setup-claude-desktop.js";
8
+ import { runSetupCodex } from "./commands/setup-codex.js";
9
+ import { startMcpServer } from "./server.js";
10
+ function printUsage() {
11
+ logStep("relentless-decs-mcp");
12
+ logStep("");
13
+ logStep("Usage:");
14
+ logStep(" relentless-decs-mcp serve");
15
+ logStep(" relentless-decs-mcp setup codex [--repo <path>] [--yes] [--dry-run] [--no-prompts] [--use-local-server]");
16
+ logStep(" relentless-decs-mcp setup claude-desktop [--platform macos|windows] [--yes] [--dry-run] [--use-local-server]");
17
+ logStep(" relentless-decs-mcp doctor [--repo <path>]");
18
+ logStep(" relentless-decs-mcp init <project-node-id> [project-name] [--repo <path>] [--bootstrap]");
19
+ }
20
+ async function main() {
21
+ const parsed = parseArgs(process.argv.slice(2));
22
+ const command = parsed.positional[0] ?? "serve";
23
+ if (command === "help" || command === "--help" || command === "-h") {
24
+ printUsage();
25
+ return;
26
+ }
27
+ if (command === "serve") {
28
+ await startMcpServer();
29
+ return;
30
+ }
31
+ if (command === "setup") {
32
+ const target = parsed.positional[1];
33
+ const yes = flagBoolean(parsed.flags, "yes", false);
34
+ const dryRun = flagBoolean(parsed.flags, "dry-run", false);
35
+ if (target === "codex") {
36
+ await runSetupCodex({
37
+ repoPath: flagString(parsed.flags, "repo") ?? process.cwd(),
38
+ yes,
39
+ dryRun,
40
+ noPrompts: flagBoolean(parsed.flags, "no-prompts", false),
41
+ useLocalServer: flagBoolean(parsed.flags, "use-local-server", false)
42
+ });
43
+ return;
44
+ }
45
+ if (target === "claude-desktop") {
46
+ const platform = flagString(parsed.flags, "platform");
47
+ if (platform !== undefined &&
48
+ platform !== "macos" &&
49
+ platform !== "windows") {
50
+ throw new Error("--platform must be one of: macos, windows");
51
+ }
52
+ await runSetupClaudeDesktop({
53
+ yes,
54
+ dryRun,
55
+ platform: platform,
56
+ useLocalServer: flagBoolean(parsed.flags, "use-local-server", false)
57
+ });
58
+ return;
59
+ }
60
+ throw new Error("setup requires a target: codex | claude-desktop");
61
+ }
62
+ if (command === "doctor") {
63
+ runDoctor(path.resolve(flagString(parsed.flags, "repo") ?? process.cwd()));
64
+ return;
65
+ }
66
+ if (command === "init") {
67
+ const projectNodeId = parsed.positional[1];
68
+ const projectName = parsed.positional[2];
69
+ if (!projectNodeId) {
70
+ throw new Error("init requires <project-node-id>");
71
+ }
72
+ await runInit({
73
+ projectNodeId,
74
+ projectName,
75
+ repoPath: flagString(parsed.flags, "repo") ?? process.cwd(),
76
+ createBootstrapDecision: flagBoolean(parsed.flags, "bootstrap", false)
77
+ });
78
+ return;
79
+ }
80
+ throw new Error(`Unknown command: ${command}`);
81
+ }
82
+ await main().catch((error) => {
83
+ logError(error instanceof Error ? error.message : String(error));
84
+ process.exitCode = 1;
85
+ });
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureParentDirectory, readTextFileIfExists, writeTextFile } from "./fs-utils.js";
4
+ const AGENTS_START = "<!-- RELENTLESS_DECS:START -->";
5
+ const AGENTS_END = "<!-- RELENTLESS_DECS:END -->";
6
+ const AGENTS_BLOCK = `${AGENTS_START}
7
+ # Relentless DECS Workflow
8
+
9
+ 1. Before architecture-affecting work, call \`decs_get_context\`.
10
+ 2. Draft decisions with: title, what, why, purpose, constraints.
11
+ 3. Ask explicit confirmation before \`decs_create\` or \`decs_update\`.
12
+ 4. If a key decision is contradicted, explain it and ask whether to supersede.
13
+ 5. If \`.decs.json\` is missing, run DECS init flow first.
14
+ ${AGENTS_END}
15
+ `;
16
+ const PROMPTS = [
17
+ {
18
+ fileName: "decs-init.md",
19
+ content: `Initialize Relentless DECS for this repository.
20
+
21
+ Arguments: <project-node-id> [project-name]
22
+
23
+ 1. Validate project node id.
24
+ 2. Call \`decs_init_space\`.
25
+ 3. Ask for confirmation to write \`.decs.json\`.
26
+ 4. Call \`decs_write_repo_config\`.
27
+ 5. Offer bootstrap key decision via \`decs_create\`.
28
+ `
29
+ },
30
+ {
31
+ fileName: "decs-context.md",
32
+ content: `Load current architectural decisions.
33
+
34
+ 1. Call \`decs_get_context\`.
35
+ 2. Render markdown context.
36
+ 3. Highlight key decisions and contradictions for current task.
37
+ `
38
+ },
39
+ {
40
+ fileName: "decs-log.md",
41
+ content: `Log a new architectural decision.
42
+
43
+ 1. Collect: title, what, why, purpose, constraints, isKeyDecision.
44
+ 2. Show payload preview.
45
+ 3. Ask explicit confirmation.
46
+ 4. Call \`decs_create\`.
47
+ `
48
+ },
49
+ {
50
+ fileName: "decs-update.md",
51
+ content: `Update an existing architectural decision.
52
+
53
+ 1. If id missing, list decisions via \`decs_list\`.
54
+ 2. Collect changed fields.
55
+ 3. Show patch preview.
56
+ 4. Ask explicit confirmation.
57
+ 5. Call \`decs_update\`.
58
+ `
59
+ }
60
+ ];
61
+ export function mergeAgentsBlock(repoPath) {
62
+ const agentsPath = path.join(repoPath, "AGENTS.md");
63
+ const current = readTextFileIfExists(agentsPath);
64
+ if (!current) {
65
+ writeTextFile(agentsPath, `${AGENTS_BLOCK}\n`);
66
+ return { path: agentsPath, changed: true };
67
+ }
68
+ if (current.includes(AGENTS_START) && current.includes(AGENTS_END)) {
69
+ const next = current.replace(new RegExp(`${escapeRegExp(AGENTS_START)}[\\s\\S]*${escapeRegExp(AGENTS_END)}`), AGENTS_BLOCK.trimEnd());
70
+ if (next !== current) {
71
+ writeTextFile(agentsPath, `${next.trimEnd()}\n`);
72
+ return { path: agentsPath, changed: true };
73
+ }
74
+ return { path: agentsPath, changed: false };
75
+ }
76
+ const next = `${current.trimEnd()}\n\n${AGENTS_BLOCK}`;
77
+ writeTextFile(agentsPath, next);
78
+ return { path: agentsPath, changed: true };
79
+ }
80
+ function escapeRegExp(input) {
81
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ }
83
+ export function installPrompts(promptDir) {
84
+ ensureParentDirectory(path.join(promptDir, ".keep"));
85
+ const writtenFiles = [];
86
+ let changed = false;
87
+ for (const prompt of PROMPTS) {
88
+ const target = path.join(promptDir, prompt.fileName);
89
+ const current = readTextFileIfExists(target);
90
+ const next = `${prompt.content.trimEnd()}\n`;
91
+ if (current !== next) {
92
+ fs.writeFileSync(target, next, "utf8");
93
+ changed = true;
94
+ }
95
+ writtenFiles.push(target);
96
+ }
97
+ return { files: writtenFiles, changed };
98
+ }
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function ensureParentDirectory(filePath) {
4
+ const directory = path.dirname(filePath);
5
+ fs.mkdirSync(directory, { recursive: true });
6
+ }
7
+ export function writeTextFile(filePath, content, options) {
8
+ ensureParentDirectory(filePath);
9
+ fs.writeFileSync(filePath, content, {
10
+ encoding: "utf8",
11
+ mode: options?.mode
12
+ });
13
+ }
14
+ export function maybeBackupFile(filePath) {
15
+ if (!fs.existsSync(filePath)) {
16
+ return null;
17
+ }
18
+ const backupPath = `${filePath}.bak`;
19
+ if (!fs.existsSync(backupPath)) {
20
+ fs.copyFileSync(filePath, backupPath);
21
+ }
22
+ return backupPath;
23
+ }
24
+ export function readTextFileIfExists(filePath) {
25
+ if (!fs.existsSync(filePath)) {
26
+ return null;
27
+ }
28
+ return fs.readFileSync(filePath, "utf8");
29
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ const CLAUDE_DESKTOP_SCHEMA = z.object({
3
+ mcpServers: z.record(z.any()).optional()
4
+ });
5
+ function deepEqual(left, right) {
6
+ return JSON.stringify(left) === JSON.stringify(right);
7
+ }
8
+ export function upsertClaudeDesktopServer(input) {
9
+ let parsed = {};
10
+ if (input.currentJson) {
11
+ const raw = JSON.parse(input.currentJson);
12
+ const validated = CLAUDE_DESKTOP_SCHEMA.safeParse(raw);
13
+ if (!validated.success) {
14
+ throw new Error(`Invalid Claude Desktop config JSON shape: ${validated.error.message}`);
15
+ }
16
+ parsed = raw;
17
+ }
18
+ const servers = typeof parsed.mcpServers === "object" && parsed.mcpServers
19
+ ? { ...parsed.mcpServers }
20
+ : {};
21
+ const existingServer = servers[input.serverName];
22
+ servers[input.serverName] = input.serverConfig;
23
+ parsed.mcpServers = servers;
24
+ return {
25
+ nextJson: `${JSON.stringify(parsed, null, 2)}\n`,
26
+ changed: !deepEqual(existingServer, input.serverConfig) || input.currentJson === null
27
+ };
28
+ }
@@ -0,0 +1,27 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export function getRelentlessConfigPath() {
4
+ return path.join(os.homedir(), ".relentless", "decs-config.json");
5
+ }
6
+ export function getCodexUserConfigPath() {
7
+ return path.join(os.homedir(), ".codex", "config.toml");
8
+ }
9
+ export function getCodexPromptDirPath() {
10
+ return path.join(os.homedir(), ".codex", "prompts");
11
+ }
12
+ export function resolveClaudeDesktopConfigPath(platformOverride) {
13
+ const platform = platformOverride ??
14
+ (process.platform === "darwin"
15
+ ? "macos"
16
+ : process.platform === "win32"
17
+ ? "windows"
18
+ : null);
19
+ if (!platform) {
20
+ throw new Error("Unsupported platform for Claude Desktop auto-config. Use --platform macos or --platform windows.");
21
+ }
22
+ if (platform === "macos") {
23
+ return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
24
+ }
25
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
26
+ return path.join(appData, "Claude", "claude_desktop_config.json");
27
+ }
@@ -0,0 +1,31 @@
1
+ import TOML from "@iarna/toml";
2
+ function normalizeServerConfig(input) {
3
+ const normalized = {
4
+ command: input.command,
5
+ args: input.args
6
+ };
7
+ if (input.env && Object.keys(input.env).length > 0) {
8
+ normalized.env = input.env;
9
+ }
10
+ return normalized;
11
+ }
12
+ function deepEqual(left, right) {
13
+ return JSON.stringify(left) === JSON.stringify(right);
14
+ }
15
+ export function upsertCodexMcpServer(input) {
16
+ const parsed = input.currentToml
17
+ ? TOML.parse(input.currentToml)
18
+ : {};
19
+ const mcpServers = typeof parsed.mcp_servers === "object" && parsed.mcp_servers
20
+ ? { ...parsed.mcp_servers }
21
+ : {};
22
+ const desiredServer = normalizeServerConfig(input.serverConfig);
23
+ const existingServer = mcpServers[input.serverName];
24
+ mcpServers[input.serverName] = desiredServer;
25
+ parsed.mcp_servers = mcpServers;
26
+ const nextToml = TOML.stringify(parsed).trimEnd() + "\n";
27
+ return {
28
+ nextToml,
29
+ changed: !deepEqual(existingServer, desiredServer) || input.currentToml === null
30
+ };
31
+ }
@@ -0,0 +1,107 @@
1
+ import { DecsError } from "./errors.js";
2
+ function appendQuery(url, query) {
3
+ for (const [key, value] of Object.entries(query)) {
4
+ if (value === undefined) {
5
+ continue;
6
+ }
7
+ url.searchParams.set(key, String(value));
8
+ }
9
+ }
10
+ function mapHttpError(status, message, details) {
11
+ if (status === 401 || status === 403) {
12
+ return new DecsError("AUTH_ERROR", message, details);
13
+ }
14
+ if (status === 404) {
15
+ return new DecsError("NOT_FOUND", message, details);
16
+ }
17
+ if (status === 429) {
18
+ return new DecsError("RATE_LIMITED", message, details);
19
+ }
20
+ return new DecsError("UPSTREAM_ERROR", message, details);
21
+ }
22
+ function sleep(milliseconds) {
23
+ return new Promise((resolve) => {
24
+ setTimeout(resolve, milliseconds);
25
+ });
26
+ }
27
+ export class RelentlessApiClient {
28
+ credentials;
29
+ fetchImpl;
30
+ constructor(credentials, fetchImpl = fetch) {
31
+ this.credentials = credentials;
32
+ this.fetchImpl = fetchImpl;
33
+ }
34
+ buildUrl(pathname, query) {
35
+ const url = new URL(pathname, this.credentials.baseUrl);
36
+ appendQuery(url, { buildspaceId: this.credentials.buildspaceId, ...query });
37
+ return url;
38
+ }
39
+ async request(method, pathname, options = {}) {
40
+ const url = this.buildUrl(pathname, options.query);
41
+ const body = options.body === undefined ? undefined : JSON.stringify(options.body);
42
+ const headers = {
43
+ Authorization: `Bearer ${this.credentials.apiKey}`
44
+ };
45
+ if (body !== undefined) {
46
+ headers["Content-Type"] = "application/json";
47
+ }
48
+ const attempts = 3;
49
+ let lastError;
50
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
51
+ try {
52
+ const response = await this.fetchImpl(url, {
53
+ method,
54
+ headers,
55
+ body
56
+ });
57
+ const raw = await response.text();
58
+ const payload = raw.length > 0 ? safeParseJson(raw) : null;
59
+ if (!response.ok) {
60
+ throw mapHttpError(response.status, `Relentless API request failed with status ${response.status}`, payload ?? raw);
61
+ }
62
+ return payload;
63
+ }
64
+ catch (error) {
65
+ lastError = error;
66
+ if (attempt < attempts) {
67
+ await sleep(attempt * 250);
68
+ continue;
69
+ }
70
+ }
71
+ }
72
+ if (lastError instanceof DecsError) {
73
+ throw lastError;
74
+ }
75
+ throw new DecsError("UPSTREAM_ERROR", "Relentless API request failed", lastError);
76
+ }
77
+ async getNode(nodeId) {
78
+ return this.request("GET", `/api/nodes/${nodeId}`);
79
+ }
80
+ async listDecisions(spaceId) {
81
+ const result = await this.request("GET", "/api/nodes", {
82
+ query: {
83
+ parentId: spaceId,
84
+ kind: "decision"
85
+ }
86
+ });
87
+ return Array.isArray(result) ? result : [];
88
+ }
89
+ async createNode(payload) {
90
+ return this.request("POST", "/api/nodes", {
91
+ body: payload
92
+ });
93
+ }
94
+ async patchNode(nodeId, payload) {
95
+ return this.request("PATCH", `/api/nodes/${nodeId}`, {
96
+ body: payload
97
+ });
98
+ }
99
+ }
100
+ function safeParseJson(input) {
101
+ try {
102
+ return JSON.parse(input);
103
+ }
104
+ catch {
105
+ return input;
106
+ }
107
+ }
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { DecsError } from "./errors.js";
5
+ const REPO_CONFIG_FILENAME = ".decs.json";
6
+ const REPO_CONFIG_SCHEMA = z.object({
7
+ relentlessSpaceId: z.string().trim().min(1),
8
+ version: z.number().int().positive().optional()
9
+ });
10
+ function findAncestorFile(startDirectory, filename) {
11
+ let currentDir = path.resolve(startDirectory);
12
+ while (currentDir !== path.parse(currentDir).root) {
13
+ const candidate = path.join(currentDir, filename);
14
+ if (fs.existsSync(candidate)) {
15
+ return candidate;
16
+ }
17
+ currentDir = path.dirname(currentDir);
18
+ }
19
+ const rootCandidate = path.join(path.parse(currentDir).root, filename);
20
+ if (fs.existsSync(rootCandidate)) {
21
+ return rootCandidate;
22
+ }
23
+ return null;
24
+ }
25
+ function readJsonFile(filePath) {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
28
+ }
29
+ catch (error) {
30
+ throw new DecsError("CONFIG_ERROR", `Could not parse JSON file: ${filePath}`, error);
31
+ }
32
+ }
33
+ function findGitRoot(startDirectory) {
34
+ let currentDir = path.resolve(startDirectory);
35
+ while (currentDir !== path.parse(currentDir).root) {
36
+ const gitDir = path.join(currentDir, ".git");
37
+ if (fs.existsSync(gitDir)) {
38
+ return currentDir;
39
+ }
40
+ currentDir = path.dirname(currentDir);
41
+ }
42
+ return null;
43
+ }
44
+ export function readRepoDecsConfig(cwd = process.cwd()) {
45
+ const configPath = findAncestorFile(cwd, REPO_CONFIG_FILENAME);
46
+ if (!configPath) {
47
+ throw new DecsError("NOT_FOUND", `No ${REPO_CONFIG_FILENAME} found in ${cwd} or parent directories`);
48
+ }
49
+ const parsed = REPO_CONFIG_SCHEMA.safeParse(readJsonFile(configPath));
50
+ if (!parsed.success) {
51
+ throw new DecsError("CONFIG_ERROR", `Invalid ${REPO_CONFIG_FILENAME} at ${configPath}`, parsed.error.flatten());
52
+ }
53
+ return {
54
+ path: configPath,
55
+ repoRoot: path.dirname(configPath),
56
+ config: parsed.data
57
+ };
58
+ }
59
+ export function writeRepoDecsConfig(relentlessSpaceId, cwd = process.cwd()) {
60
+ const repoRoot = findGitRoot(cwd) ?? path.resolve(cwd);
61
+ const configPath = path.join(repoRoot, REPO_CONFIG_FILENAME);
62
+ const payload = {
63
+ relentlessSpaceId,
64
+ version: 1
65
+ };
66
+ fs.writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
67
+ return {
68
+ path: configPath,
69
+ repoRoot
70
+ };
71
+ }
package/dist/server.js ADDED
@@ -0,0 +1,16 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { registerToolHandlers } from "./tools.js";
4
+ export async function startMcpServer() {
5
+ const server = new Server({
6
+ name: "relentless-decs-mcp",
7
+ version: "0.2.0"
8
+ }, {
9
+ capabilities: {
10
+ tools: {}
11
+ }
12
+ });
13
+ registerToolHandlers(server);
14
+ const transport = new StdioServerTransport();
15
+ await server.connect(transport);
16
+ }