opensddrag 0.1.0 → 0.1.2

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 ADDED
@@ -0,0 +1,236 @@
1
+ # opensddrag
2
+
3
+ CLI client to connect any project to the [OpenSddRag](https://github.com/conexaoelite/OpenSddRag) SDD+Harness MCP server.
4
+
5
+ OpenSddRag implements **Spec-Driven Development (SDD)** — a structured workflow where every feature flows through `propose → spec → design → tasks → apply → archive`, backed by a PostgreSQL/pgvector semantic memory store. This CLI wires your project's AI tools to that server in seconds.
6
+
7
+ ## Prerequisites
8
+
9
+ - Node.js ≥ 18
10
+ - A running OpenSddRag MCP server (see [server setup](#server-setup))
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ # one-off, no install needed
16
+ npx opensddrag init
17
+
18
+ # or install globally
19
+ npm install -g opensddrag
20
+ opensddrag init
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```bash
26
+ # 1. Start the OpenSddRag server (Docker)
27
+ docker compose up -d # runs on http://localhost:8000
28
+
29
+ # 2. Inside your project, connect it
30
+ cd my-project
31
+ npx opensddrag init
32
+
33
+ # 3. Open the project in Claude Code — the MCP server is ready
34
+ ```
35
+
36
+ The `init` command registers your project on the server, writes MCP config files, installs SDD slash commands, and appends a section to `CLAUDE.md`.
37
+
38
+ ## Commands
39
+
40
+ ### `opensddrag init`
41
+
42
+ Connect the current directory to an OpenSddRag MCP server.
43
+
44
+ ```
45
+ Options:
46
+ --server <url> Server URL (default: http://localhost:8000)
47
+ --project <slug> Project slug (default: directory name)
48
+ --name <name> Project display name
49
+ --api-key <key> API key for password-protected servers
50
+ --tools <list> AI tools to configure: claude, opencode (default: ask)
51
+ --yes Skip all confirmation prompts
52
+ ```
53
+
54
+ **Examples**
55
+
56
+ ```bash
57
+ # Interactive — prompts for all values
58
+ npx opensddrag init
59
+
60
+ # Non-interactive — CI / scripted setup
61
+ npx opensddrag init --server http://localhost:8000 --project my-app --yes
62
+
63
+ # Remote server with an API key
64
+ npx opensddrag init --server https://sdd.example.com --api-key sk-... --yes
65
+
66
+ # CI — configure via env vars, no flags needed
67
+ OPENSDDRAG_SERVER_URL=http://mcp.internal:8000 OPENSDDRAG_API_KEY=sk-... npx opensddrag init --project my-app --yes
68
+
69
+ # Configure only OpenCode (skip Claude Code)
70
+ npx opensddrag init --tools opencode
71
+ ```
72
+
73
+ **What gets created in your project**
74
+
75
+ | Path | Purpose |
76
+ |---|---|
77
+ | `.mcp.json` | Claude Code MCP server entry (`type: http`) |
78
+ | `opencode.json` | OpenCode MCP server entry (only with `--tools opencode`) |
79
+ | `.claude/skills/opensddrag-*/SKILL.md` | One skill file per SDD command |
80
+ | `.claude/skills/opensddrag-harness/SKILL.md` | Harness rule management skill |
81
+ | `.agents/skills/opensddrag-*/SKILL.md` | Same skills for agent-native tooling |
82
+ | `.agents/skills/opensddrag-harness/SKILL.md` | Same harness skill for agent-native tooling |
83
+ | `.opencode/skills/opensddrag-*/SKILL.md` | OpenCode-native skills (when selected) |
84
+ | `.opencode/skills/opensddrag-harness/SKILL.md` | OpenCode-native harness skill (when selected) |
85
+ | `.claude/commands/opsr/*.md` | Claude Code slash commands |
86
+ | `.opencode/commands/opsr/*.md` | OpenCode slash commands (when selected) |
87
+ | `CLAUDE.md` | OpenSddRag section appended (or file created) |
88
+ | `opensddrag.yaml` | Local project marker (`project` + `server`) |
89
+
90
+ ---
91
+
92
+ ### `opensddrag status`
93
+
94
+ Check whether the current project is correctly wired to the server.
95
+
96
+ ```
97
+ Options:
98
+ --server <url> Override the server URL for this check
99
+ ```
100
+
101
+ ```bash
102
+ npx opensddrag status
103
+ npx opensddrag status --server http://mcp.internal:8000
104
+ ```
105
+
106
+ Reports:
107
+ - `opensddrag.yaml` — local config found
108
+ - Skills — how many of the 13 expected skill files are present
109
+ - `.mcp.json` / `opencode.json` — MCP server entry
110
+ - Slash commands — which `/opsr:*` commands are installed
111
+ - `CLAUDE.md` — whether the OpenSddRag section exists
112
+ - Server — live health check + project registration confirmation
113
+
114
+ ## Environment Variables
115
+
116
+ Both `init` and `status` support configuration via environment variables. Precedence order (first match wins):
117
+
118
+ 1. `--server` / `--api-key` CLI flags
119
+ 2. Environment variables
120
+ 3. `opensddrag.yaml` (`server_url` or `server` field)
121
+ 4. Built-in default (`http://localhost:8000`)
122
+
123
+ | Variable | Purpose |
124
+ |---|---|
125
+ | `OPENSDDRAG_SERVER_URL` | MCP server URL — used when no `--server` flag is passed |
126
+ | `OPENSDDRAG_API_KEY` | API key for authenticated servers — used when no `--api-key` flag is passed |
127
+
128
+ ```bash
129
+ # Connect to a remote server without flags
130
+ export OPENSDDRAG_SERVER_URL=http://mcp.internal:8000
131
+ export OPENSDDRAG_API_KEY=sk-abc123
132
+ npx opensddrag init --project my-app --yes
133
+ npx opensddrag status
134
+ ```
135
+
136
+ `OPENSDDRAG_API_KEY` is honored for any server URL — including `localhost` — so local servers with `AUTH_ENABLED=true` work correctly.
137
+
138
+ ---
139
+
140
+ ## SDD Slash Commands
141
+
142
+ After `init`, the following slash commands are available inside Claude Code (prefix `/opsr:`):
143
+
144
+ | Command | When to use |
145
+ |---|---|
146
+ | `/opsr:propose` | Capture intent and scope — start here |
147
+ | `/opsr:spec` | Formalize requirements (Purpose / SHALL / Scenarios) |
148
+ | `/opsr:design` | Document technical decisions and trade-offs |
149
+ | `/opsr:tasks` | Break a spec into atomic tasks (< 4 h each) |
150
+ | `/opsr:apply` | Implement the next pending task against spec criteria |
151
+ | `/opsr:verify` | Confirm a task is done and acceptance criteria are met |
152
+ | `/opsr:sync` | Merge delta specs after mid-flight design changes |
153
+ | `/opsr:archive` | Mark a completed feature as archived |
154
+ | `/opsr:explore` | Explore and investigate before committing to a plan |
155
+ | `/opsr:continue` | Resume the last in-progress artifact |
156
+ | `/opsr:status` | Show what is in progress and what is done |
157
+ | `/opsr:flow` | Run the full SDD flow end-to-end for a feature |
158
+ | `/opsr:search` | Semantic search over specs and past work |
159
+ | `/opsr:harness` | Add, list, or disable project rules enforced at SDD phase gates |
160
+
161
+ ## Harness
162
+
163
+ The Harness is a rule-gate layer built on top of the SDD workflow. It lets you define persistent, per-project behavioral rules that are automatically injected into every agent session and enforced at specific SDD phase gates.
164
+
165
+ **How it works:**
166
+ - Rules are stored in the MCP server's database, scoped to your project.
167
+ - `always` rules are returned automatically by `get_working_context` at the start of every session.
168
+ - Phase-gate rules (`on_spec`, `on_apply`, `on_verify`, `on_archive`) are surfaced by `get_harness_checklist` when the corresponding SDD command runs its gate step.
169
+ - Agents check the checklist before completing each gate action and must satisfy all `error`-severity rules.
170
+
171
+ ### Triggers
172
+
173
+ | Trigger | When it fires |
174
+ |---|---|
175
+ | `always` | Every agent session — injected via `get_working_context` |
176
+ | `on_spec` | Before saving a spec artifact (`/opsr:spec`) |
177
+ | `on_apply` | Before marking a task complete (`/opsr:apply`) |
178
+ | `on_verify` | Before declaring verification done (`/opsr:verify`) |
179
+ | `on_archive` | Before archiving change artifacts (`/opsr:archive`) |
180
+
181
+ ### MCP Tools
182
+
183
+ | Tool | Description |
184
+ |---|---|
185
+ | `add_rule` | Create or update a project rule (name, trigger, category, severity, instruction) |
186
+ | `list_rules` | List all rules for the project, grouped by trigger |
187
+ | `get_harness_checklist` | Return enabled rules for a specific trigger — called by SDD commands at phase gates |
188
+
189
+ ### Managing rules
190
+
191
+ ```bash
192
+ # Inside Claude Code, after opensddrag init:
193
+
194
+ # Add a rule interactively
195
+ /opsr:harness add
196
+
197
+ # Add a rule directly
198
+ /opsr:harness add name=no-raw-sql trigger=on_apply category=forbidden severity=error instruction="Never write raw SQL strings — use the repository layer"
199
+
200
+ # List all rules
201
+ /opsr:harness list
202
+
203
+ # Disable a rule
204
+ /opsr:harness disable no-raw-sql
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Server Setup
210
+
211
+ The MCP server is a separate Python package (`opensddrag` on PyPI) that requires PostgreSQL with the pgvector extension.
212
+
213
+ **Docker (recommended)**
214
+
215
+ ```bash
216
+ # Clone the repo or copy docker-compose.yml
217
+ git clone https://github.com/conexaoelite/OpenSddRag
218
+ cd OpenSddRag
219
+ docker compose up -d
220
+ ```
221
+
222
+ The server starts on `http://localhost:8000`. The database runs on `localhost:54326`.
223
+
224
+ **Local (development)**
225
+
226
+ ```bash
227
+ cd mcp-server
228
+ cp .env.example .env
229
+ uv pip install -e .
230
+ opensddrag init # run migrations + seed global SDD skills
231
+ opensddrag server start --transport sse --port 8000
232
+ ```
233
+
234
+ ## License
235
+
236
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opensddrag",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI client to connect any project to the OpenSddRag SDD+Harness MCP server",
5
5
  "keywords": [
6
6
  "sdd",
@@ -5,10 +5,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
5
  import { join, basename } from "path";
6
6
 
7
7
  import { checkHealth, createProject } from "../api.js";
8
+ import { DEFAULT_SERVER_URL, resolveServerUrl, resolveApiKey } from "../config.js";
8
9
  import { renderClaudeMdBlock, renderClaudeMdStandalone } from "../templates/claude-md.js";
9
10
  import { getCommands } from "../templates/commands/index.js";
10
11
  import { getOpenCodeCommands } from "../templates/commands/opencode.js";
11
- import { getSkills } from "../templates/skills/index.js";
12
+ import { getSkills, getOpenCodeSkills } from "../templates/skills/index.js";
13
+ import { getHarnessSkill, getOpenCodeHarnessSkill } from "../templates/skill-md.js";
12
14
 
13
15
  // ── Config writers ─────────────────────────────────────────────────────────────
14
16
 
@@ -40,6 +42,9 @@ function writeOpenCode(cwd, serverUrl, _apiKey) {
40
42
  url: `${serverUrl}/mcp`,
41
43
  enabled: true,
42
44
  };
45
+ if (_apiKey) {
46
+ config.mcp.opensddrag.headers = { Authorization: `Bearer ${_apiKey}` };
47
+ }
43
48
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
44
49
  return "opencode.json";
45
50
  }
@@ -55,7 +60,7 @@ export const initCommand = new Command("init")
55
60
  .description("Connect the current project to an OpenSddRag MCP server")
56
61
  .option("--project <slug>", "Project slug (default: current directory name)")
57
62
  .option("--name <name>", "Project display name")
58
- .option("--server <url>", "OpenSddRag server URL", "http://localhost:8000")
63
+ .option("--server <url>", "OpenSddRag server URL")
59
64
  .option("--api-key <key>", "API key for authenticated servers")
60
65
  .option("--tools <list>", "Comma-separated tools to configure: claude,opencode (default: ask)")
61
66
  .option("--yes", "Skip confirmation prompts")
@@ -65,13 +70,16 @@ export const initCommand = new Command("init")
65
70
  console.log(chalk.bold("\n OpenSddRag — Project Init\n"));
66
71
 
67
72
  // ── Inputs ────────────────────────────────────────────────────────────────
68
- const serverUrl = opts.server ||
69
- await input({ message: "OpenSddRag server URL:", default: "http://localhost:8000" });
73
+ const resolvedUrl = resolveServerUrl(opts, cwd);
74
+ let serverUrl;
75
+ if (opts.yes || resolvedUrl !== DEFAULT_SERVER_URL) {
76
+ serverUrl = resolvedUrl;
77
+ } else {
78
+ serverUrl = await input({ message: "OpenSddRag server URL:", default: DEFAULT_SERVER_URL });
79
+ }
70
80
 
71
- // Prompt for API key when connecting to a remote server
72
- const isRemote = !serverUrl.includes("localhost") && !serverUrl.includes("127.0.0.1");
73
- let apiKey = opts.apiKey || null;
74
- if (!apiKey && isRemote && !opts.yes) {
81
+ let apiKey = resolveApiKey(opts);
82
+ if (!apiKey && !opts.yes) {
75
83
  apiKey = await password({
76
84
  message: "API key (leave blank to skip — server must have AUTH_ENABLED=false):",
77
85
  mask: "*",
@@ -114,15 +122,15 @@ export const initCommand = new Command("init")
114
122
  console.log("\n" + chalk.dim(" Will create/update:"));
115
123
  if (selectedTools.includes("Claude Code")) {
116
124
  console.log(chalk.dim(" .mcp.json — MCP server (type: http)"));
125
+ console.log(chalk.dim(" .claude/skills/opensddrag-*/SKILL.md — individual skill per command"));
126
+ console.log(chalk.dim(" .agents/skills/opensddrag-*/SKILL.md — individual skill per command"));
127
+ console.log(chalk.dim(" .claude/commands/opsr/ — slash commands (/opsr:propose, /opsr:apply, /opsr:harness...)"));
117
128
  }
118
- console.log(chalk.dim(" .claude/skills/opensddrag-*/SKILL.md — individual skill per command"));
119
- console.log(chalk.dim(" .agents/skills/opensddrag-*/SKILL.md — individual skill per command"));
120
129
  if (selectedTools.includes("OpenCode")) {
121
- console.log(chalk.dim(" opencode.json — MCP server"));
130
+ console.log(chalk.dim(" opencode.json — MCP server"));
122
131
  console.log(chalk.dim(" .opencode/skills/opensddrag-*/SKILL.md — OpenCode-native skills"));
123
- console.log(chalk.dim(" .opencode/commands/opsr/ — slash commands (/opsr:propose, /opsr:apply...)"));
132
+ console.log(chalk.dim(" .opencode/commands/opsr/ — slash commands (/opsr:propose, /opsr:apply...)"));
124
133
  }
125
- console.log(chalk.dim(" .claude/commands/opsr/ — slash commands (/opsr:propose, /opsr:apply...)"));
126
134
  console.log(chalk.dim(" CLAUDE.md — OpenSddRag section"));
127
135
  console.log(chalk.dim(` Remote: register '${slug}' in central database\n`));
128
136
 
@@ -138,9 +146,13 @@ export const initCommand = new Command("init")
138
146
  console.log(chalk.green("✓"));
139
147
  } catch {
140
148
  console.log(chalk.red("✗"));
149
+ const isLocal = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1");
141
150
  console.error(chalk.red(`\n Cannot reach ${serverUrl}`));
142
- console.error(chalk.dim(" Make sure the OpenSddRag server is running:"));
143
- console.error(chalk.dim(" docker compose up -d"));
151
+ if (isLocal) {
152
+ console.error(chalk.dim(" Make sure the OpenSddRag server is running:"));
153
+ console.error(chalk.dim(" docker compose up -d"));
154
+ }
155
+ console.error(chalk.dim(" Pass --server <url> or set OPENSDDRAG_SERVER_URL."));
144
156
  process.exit(1);
145
157
  }
146
158
 
@@ -165,57 +177,60 @@ export const initCommand = new Command("init")
165
177
  const file = TOOL_WRITERS[tool](cwd, serverUrl, apiKey);
166
178
  configured.push(`${tool} → ${file}`);
167
179
  }
168
- // Individual skill files per command
169
- const skills = getSkills(slug, serverUrl);
170
- const skillRoots = [
171
- join(cwd, ".claude", "skills"),
172
- join(cwd, ".agents", "skills"),
173
- ];
174
- for (const skill of skills) {
175
- for (const root of skillRoots) {
176
- const skillDir = join(root, skill.name);
177
- mkdirSync(skillDir, { recursive: true });
178
- writeFileSync(join(skillDir, "SKILL.md"), skill.content);
180
+ // Individual skill files per command — each tool gets its own generator
181
+ if (selectedTools.includes("Claude Code")) {
182
+ const ccSkills = [
183
+ ...getSkills(slug, serverUrl),
184
+ { name: "opensddrag-harness", content: getHarnessSkill({ slug, serverUrl }) },
185
+ ];
186
+ const ccRoots = [join(cwd, ".claude", "skills"), join(cwd, ".agents", "skills")];
187
+ for (const skill of ccSkills) {
188
+ for (const root of ccRoots) {
189
+ const skillDir = join(root, skill.name);
190
+ mkdirSync(skillDir, { recursive: true });
191
+ writeFileSync(join(skillDir, "SKILL.md"), skill.content);
192
+ }
193
+ configured.push(`skill → ${skill.name}/SKILL.md (${ccRoots.length} root(s))`);
179
194
  }
180
- configured.push(`skill → .claude/skills/${skill.name}/SKILL.md`);
181
195
  }
182
-
183
- // Install OpenCode-native skills if OpenCode is selected
184
196
  if (selectedTools.includes("OpenCode")) {
185
- const opencodeSkillsRoot = join(cwd, ".opencode", "skills");
186
- for (const skill of skills) {
187
- const skillDir = join(opencodeSkillsRoot, skill.name);
197
+ const ocSkills = [
198
+ ...getOpenCodeSkills(slug, serverUrl),
199
+ { name: "opensddrag-harness", content: getOpenCodeHarnessSkill({ slug, serverUrl }) },
200
+ ];
201
+ const ocRoot = join(cwd, ".opencode", "skills");
202
+ for (const skill of ocSkills) {
203
+ const skillDir = join(ocRoot, skill.name);
188
204
  mkdirSync(skillDir, { recursive: true });
189
205
  writeFileSync(join(skillDir, "SKILL.md"), skill.content);
190
- configured.push(`skill → .opencode/skills/${skill.name}/SKILL.md`);
206
+ configured.push(`skill → ${skill.name}/SKILL.md (1 root(s))`);
191
207
  }
192
208
  }
193
209
  console.log(chalk.green("✓"));
194
210
  for (const c of configured) console.log(chalk.dim(` ${c}`));
195
211
 
196
- // ── 4. Slash commands (.claude/commands/opsr/) ───────────────────────────
212
+ // ── 4. Slash commands ────────────────────────────────────────────────────
197
213
  process.stdout.write(chalk.bold(" 4/5 ") + "Writing slash commands... ");
198
- const commands = getCommands(slug, serverUrl);
199
- for (const cmd of commands) {
200
- const cmdDir = join(cwd, ".claude", "commands", cmd.folder);
201
- mkdirSync(cmdDir, { recursive: true });
202
- writeFileSync(join(cmdDir, `${cmd.name}.md`), cmd.content);
203
- }
204
- console.log(chalk.green(`✓ (${commands.length} commands)`));
205
- for (const cmd of commands) {
206
- console.log(chalk.dim(` /${cmd.folder}:${cmd.name}`));
214
+ let totalCommands = 0;
215
+ if (selectedTools.includes("Claude Code")) {
216
+ const commands = getCommands(slug, serverUrl);
217
+ for (const cmd of commands) {
218
+ const cmdDir = join(cwd, ".claude", "commands", cmd.folder);
219
+ mkdirSync(cmdDir, { recursive: true });
220
+ writeFileSync(join(cmdDir, `${cmd.name}.md`), cmd.content);
221
+ }
222
+ totalCommands += commands.length;
207
223
  }
208
-
209
- // Install OpenCode-native commands if OpenCode is selected
210
224
  if (selectedTools.includes("OpenCode")) {
211
225
  const opencodeCommands = getOpenCodeCommands(slug, serverUrl);
212
226
  for (const cmd of opencodeCommands) {
213
227
  const cmdDir = join(cwd, ".opencode", "commands", cmd.folder);
214
228
  mkdirSync(cmdDir, { recursive: true });
215
229
  writeFileSync(join(cmdDir, `${cmd.name}.md`), cmd.content);
216
- configured.push(`command → .opencode/commands/${cmd.folder}/${cmd.name}.md`);
217
230
  }
231
+ totalCommands += opencodeCommands.length;
218
232
  }
233
+ console.log(chalk.green(`✓ (${totalCommands} commands)`));
219
234
 
220
235
  // ── 5. CLAUDE.md ──────────────────────────────────────────────────────────
221
236
  process.stdout.write(chalk.bold(" 5/5 ") + "Updating CLAUDE.md... ");
@@ -4,25 +4,22 @@ import { existsSync, readFileSync } from "fs";
4
4
  import { join } from "path";
5
5
 
6
6
  import { checkHealth, listProjects } from "../api.js";
7
+ import { resolveServerUrl, loadOpensddragYaml } from "../config.js";
7
8
 
8
9
  export const statusCommand = new Command("status")
9
10
  .description("Check OpenSddRag connection status for the current project")
10
- .action(async () => {
11
+ .option("--server <url>", "OpenSddRag server URL")
12
+ .action(async (opts) => {
11
13
  const cwd = process.cwd();
12
14
 
13
15
  console.log(chalk.bold("\n OpenSddRag — Status\n"));
14
16
 
15
- let serverUrl = "http://localhost:8000";
16
- let slug = null;
17
+ const serverUrl = resolveServerUrl(opts, cwd);
18
+ const yaml = loadOpensddragYaml(cwd);
19
+ const slug = yaml?.project ?? null;
17
20
 
18
21
  // ── Local config ──────────────────────────────────────────────────────────
19
- const yamlPath = join(cwd, "opensddrag.yaml");
20
- if (existsSync(yamlPath)) {
21
- const yaml = readFileSync(yamlPath, "utf8");
22
- const slugMatch = yaml.match(/^project:\s*(.+)$/m);
23
- const serverMatch = yaml.match(/^server:\s*(.+)$/m);
24
- if (slugMatch) slug = slugMatch[1].trim();
25
- if (serverMatch) serverUrl = serverMatch[1].trim();
22
+ if (yaml) {
26
23
  console.log(chalk.green(" ✓") + ` opensddrag.yaml → project: ${chalk.cyan(slug)}`);
27
24
  } else {
28
25
  console.log(chalk.red(" ✗") + " opensddrag.yaml not found — run: " + chalk.dim("opensddrag init"));
package/src/config.js ADDED
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ export const DEFAULT_SERVER_URL = "http://localhost:8000";
5
+
6
+ /**
7
+ * Parse opensddrag.yaml from cwd.
8
+ * Reads both `server:` and `server_url:` keys for backward compatibility.
9
+ * Returns { project, server_url } or null on missing/malformed file.
10
+ */
11
+ export function loadOpensddragYaml(cwd) {
12
+ const yamlPath = join(cwd, "opensddrag.yaml");
13
+ if (!existsSync(yamlPath)) return null;
14
+ try {
15
+ const text = readFileSync(yamlPath, "utf8");
16
+ const projectMatch = text.match(/^project:\s*["']?(.+?)["']?\s*$/m);
17
+ const serverMatch = text.match(/^(?:server_url|server):\s*["']?(.+?)["']?\s*$/m);
18
+ return {
19
+ project: projectMatch?.[1]?.trim() ?? null,
20
+ server_url: serverMatch?.[1]?.trim() ?? null,
21
+ };
22
+ } catch {
23
+ process.stderr.write("Warning: Ignoring malformed opensddrag.yaml\n");
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Resolve the MCP server URL with precedence:
30
+ * 1. opts.server (--server CLI flag)
31
+ * 2. OPENSDDRAG_SERVER_URL env var
32
+ * 3. opensddrag.yaml server_url / server field
33
+ * 4. DEFAULT_SERVER_URL
34
+ */
35
+ export function resolveServerUrl(opts, cwd) {
36
+ if (opts?.server) return opts.server;
37
+ if (process.env.OPENSDDRAG_SERVER_URL) return process.env.OPENSDDRAG_SERVER_URL;
38
+ const yaml = loadOpensddragYaml(cwd ?? process.cwd());
39
+ if (yaml?.server_url) return yaml.server_url;
40
+ return DEFAULT_SERVER_URL;
41
+ }
42
+
43
+ /**
44
+ * Resolve the API key with precedence:
45
+ * 1. opts.apiKey (--api-key CLI flag)
46
+ * 2. OPENSDDRAG_API_KEY env var
47
+ * 3. null
48
+ */
49
+ export function resolveApiKey(opts) {
50
+ if (opts?.apiKey) return opts.apiKey;
51
+ if (process.env.OPENSDDRAG_API_KEY) return process.env.OPENSDDRAG_API_KEY;
52
+ return null;
53
+ }