docs(lanes): plan F1 — ccam lanes mcp sync (F)
4 tasks: the lane-mcp.js sync core (relocate + pin + seed profiles, no permission/settings writes per the design spec's scope decision), the route, the CLI, and docs.
This commit is contained in:
@@ -0,0 +1,639 @@
|
|||||||
|
# F1 — `ccam lanes mcp sync` Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** `ccam lanes mcp sync` writes a lane's `.mcp.json` from its source repo's already-configured MCP servers (relocating paths, pinning Playwright's proof output dir) and seeds Chromium browser profiles, so `SKILL.md`'s manual-fallback text stops being the only option.
|
||||||
|
|
||||||
|
**Architecture:** One filesystem/JSON-only core module (`server/lib/lane-mcp.js` — no git calls, unlike E2/E3's cores), one route, one CLI subcommand, a `SKILL.md` edit.
|
||||||
|
|
||||||
|
**Tech Stack:** Plain `node:fs`/`node:path`/`node:os`. No new dependency.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Every applicable source file (`.js`) MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`.
|
||||||
|
- **No permission/settings writes of any kind.** This task never touches `<lane>/.claude/settings.local.json` or any permission/auto-approval surface — that's out of scope by explicit design decision (see the design spec's Scope section), not an oversight to "complete later" in this plan.
|
||||||
|
- **`.mcp.json` is a full overwrite on every call; profile seeding is additive-only.** A profile directory that already exists at the destination is never touched (an existing profile means a session already logged in there).
|
||||||
|
- `server/lib/cc-discovery.js` already reads `~/.claude.json` (its `readMcpServers()`, used by the Claude Config Explorer page) — but it REDACTS secret-like keys and summarizes server defs for display, so it cannot be reused here; `lane-mcp.js` needs the raw, unredacted server config to write a working `.mcp.json`. Do not attempt to reuse `readMcpServers()`; write a small local reader instead (a few lines — see Task 1).
|
||||||
|
- Never use `git add -A`. Stage exactly the files each task names.
|
||||||
|
- Run `npm run test:server` (full suite) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `server/lib/lane-mcp.js` — the sync core
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/lib/lane-mcp.js`
|
||||||
|
- Test: `server/__tests__/lane-mcp.test.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `syncMcp(lane) => {servers: string[], profilesSeeded: string[]}` where `lane` is `{cwd, source_repo}` (only these two fields are read). Throws an error with `.code === "ENOMCPCONFIG"` when the source repo has no `mcpServers` configured in `~/.claude.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests — reading and relocating**
|
||||||
|
|
||||||
|
Create `server/__tests__/lane-mcp.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* @file Tests for server/lib/lane-mcp.js: relocating a source repo's
|
||||||
|
* ~/.claude.json MCP server config into a lane's own .mcp.json, pinning
|
||||||
|
* Playwright's proof output dir, and seeding Chromium profiles. Uses a real
|
||||||
|
* temp $HOME (via process.env.HOME override) so the module's own
|
||||||
|
* os.homedir()-based path resolution is exercised, not mocked around.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-mcp-"));
|
||||||
|
const FAKE_HOME = path.join(SUITE_ROOT, "home");
|
||||||
|
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||||
|
process.env.HOME = FAKE_HOME;
|
||||||
|
|
||||||
|
const laneMcp = require("../lib/lane-mcp");
|
||||||
|
|
||||||
|
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
function writeClaudeJson(projects) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(FAKE_HOME, ".claude.json"),
|
||||||
|
JSON.stringify({ projects }, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let laneSeq = 0;
|
||||||
|
function makeLane(sourceRepo) {
|
||||||
|
laneSeq += 1;
|
||||||
|
const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
return { cwd, source_repo: sourceRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("syncMcp — reading and relocating", () => {
|
||||||
|
it("throws ENOMCPCONFIG when the source repo has no mcpServers", async () => {
|
||||||
|
const sourceRepo = path.join(SUITE_ROOT, "src-none");
|
||||||
|
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||||
|
writeClaudeJson({ [sourceRepo]: {} });
|
||||||
|
await assert.rejects(() => laneMcp.syncMcp(makeLane(sourceRepo)), {
|
||||||
|
code: "ENOMCPCONFIG",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOMCPCONFIG when ~/.claude.json doesn't exist at all", async () => {
|
||||||
|
fs.rmSync(path.join(FAKE_HOME, ".claude.json"), { force: true });
|
||||||
|
await assert.rejects(
|
||||||
|
() => laneMcp.syncMcp(makeLane(path.join(SUITE_ROOT, "src-missing"))),
|
||||||
|
{ code: "ENOMCPCONFIG" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relocates absolute paths under source_repo to the lane's cwd", async () => {
|
||||||
|
const sourceRepo = path.join(SUITE_ROOT, "src-relocate");
|
||||||
|
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||||
|
writeClaudeJson({
|
||||||
|
[sourceRepo]: {
|
||||||
|
mcpServers: {
|
||||||
|
playwright: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@playwright/mcp@latest", "--user-data-dir", `${sourceRepo}/.playwright-mcp/profiles/default`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const lane = makeLane(sourceRepo);
|
||||||
|
const result = await laneMcp.syncMcp(lane);
|
||||||
|
assert.deepEqual(result.servers, ["playwright"]);
|
||||||
|
|
||||||
|
const written = JSON.parse(fs.readFileSync(path.join(lane.cwd, ".mcp.json"), "utf8"));
|
||||||
|
assert.equal(
|
||||||
|
written.mcpServers.playwright.args[2],
|
||||||
|
`${lane.cwd}/.playwright-mcp/profiles/default`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `node --test server/__tests__/lane-mcp.test.js`
|
||||||
|
Expected: FAIL — `require("../lib/lane-mcp")` throws `MODULE_NOT_FOUND`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `lane-mcp.js` — reading, relocating, writing `.mcp.json`**
|
||||||
|
|
||||||
|
Create `server/lib/lane-mcp.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* @file Gives a lane the same MCP servers as its source repo: reads the
|
||||||
|
* source repo's already-configured mcpServers from ~/.claude.json (normal
|
||||||
|
* Claude Code project-scope config — the human sets this up once, the same
|
||||||
|
* way they would for any project), relocates any absolute path under the
|
||||||
|
* source repo to the lane's own directory, pins a Playwright server's proof
|
||||||
|
* output dir, and writes <lane.cwd>/.mcp.json. Also seeds the lane's
|
||||||
|
* Chromium browser profiles from the source repo's own (preserves saved
|
||||||
|
* logins — a QA account only needs to log in once per machine).
|
||||||
|
*
|
||||||
|
* Deliberately does NOT write any permission or settings.local.json content
|
||||||
|
* — see the F1 design spec's Scope section for why. Plain file I/O; no git
|
||||||
|
* calls, unlike lane-sync.js (E2) or lane-agents.js (E3).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
/** Read the source repo's mcpServers from ~/.claude.json. Throws
|
||||||
|
* ENOMCPCONFIG if the file is missing or the project has none declared —
|
||||||
|
* a lane with zero MCP servers synced would silently break Stage 3/6 much
|
||||||
|
* later, at a far less debuggable point, so this fails loud and early. */
|
||||||
|
function readSourceMcpServers(sourceRepo) {
|
||||||
|
const claudeJsonPath = path.join(os.homedir(), ".claude.json");
|
||||||
|
let cfg;
|
||||||
|
try {
|
||||||
|
cfg = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
cfg = null;
|
||||||
|
}
|
||||||
|
const servers = cfg?.projects?.[sourceRepo]?.mcpServers;
|
||||||
|
if (!servers || !Object.keys(servers).length) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
`no mcpServers configured for source repo ${sourceRepo} in ~/.claude.json — ` +
|
||||||
|
`configure them there first (see the ship-feature-lane skill's Setup section)`
|
||||||
|
),
|
||||||
|
{ code: "ENOMCPCONFIG" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-walk a server config, replacing every occurrence of `sourceRepo`
|
||||||
|
* inside a string with `laneDir`. Strings, arrays, and plain objects only
|
||||||
|
* — an MCP server def never contains anything else. */
|
||||||
|
function relocate(value, sourceRepo, laneDir) {
|
||||||
|
if (typeof value === "string") return value.split(sourceRepo).join(laneDir);
|
||||||
|
if (Array.isArray(value)) return value.map((v) => relocate(v, sourceRepo, laneDir));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) out[k] = relocate(v, sourceRepo, laneDir);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin --output-dir for a @playwright/mcp server that doesn't already
|
||||||
|
* declare one, so relative proof filenames (proof/<feature>/...) always
|
||||||
|
* land under <laneDir>/.playwright-mcp — the one place the dashboard's
|
||||||
|
* proof gallery reads. */
|
||||||
|
function pinPlaywrightOutputDir(servers, laneDir) {
|
||||||
|
for (const server of Object.values(servers)) {
|
||||||
|
const args = server.args;
|
||||||
|
if (!Array.isArray(args)) continue;
|
||||||
|
const isPlaywright = args.some((a) => typeof a === "string" && a.startsWith("@playwright/mcp"));
|
||||||
|
if (isPlaywright && !args.includes("--output-dir")) {
|
||||||
|
server.args = [...args, "--output-dir", path.join(laneDir, ".playwright-mcp")];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy each <sourceRepo>/.playwright-mcp/profiles/<name>/ into the lane
|
||||||
|
* ONLY if that name doesn't already exist there — an existing profile
|
||||||
|
* means a session already logged in with it; never clobber that. Strips
|
||||||
|
* Singleton* lock files from the freshly-seeded copy (stale locks from the
|
||||||
|
* source's own last browser process would make the lane's browser refuse
|
||||||
|
* to start, thinking another instance already holds the profile). */
|
||||||
|
function seedProfiles(sourceRepo, laneDir) {
|
||||||
|
const srcProfiles = path.join(sourceRepo, ".playwright-mcp", "profiles");
|
||||||
|
if (!fs.existsSync(srcProfiles)) return [];
|
||||||
|
|
||||||
|
const seeded = [];
|
||||||
|
for (const name of fs.readdirSync(srcProfiles)) {
|
||||||
|
const src = path.join(srcProfiles, name);
|
||||||
|
if (!fs.statSync(src).isDirectory()) continue;
|
||||||
|
const dest = path.join(laneDir, ".playwright-mcp", "profiles", name);
|
||||||
|
if (fs.existsSync(dest)) continue;
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.cpSync(src, dest, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(dest)) {
|
||||||
|
if (entry.startsWith("Singleton")) fs.rmSync(path.join(dest, entry), { force: true });
|
||||||
|
}
|
||||||
|
seeded.push(name);
|
||||||
|
}
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotently append a line to <laneDir>/.git/info/exclude — same
|
||||||
|
* read-existing-then-append-if-missing pattern E2/E3 already use for
|
||||||
|
* .git/info/attributes and .git/info/exclude. .mcp.json sits at the lane
|
||||||
|
* root (not inside .git), so this needs no --git-common-dir resolution —
|
||||||
|
* the exclude file itself is always local to this lane's own working copy. */
|
||||||
|
function excludeFromGit(laneDir, line) {
|
||||||
|
const excludePath = path.join(laneDir, ".git", "info", "exclude");
|
||||||
|
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||||
|
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||||
|
const lines = existing.split("\n").filter(Boolean);
|
||||||
|
if (!lines.includes(line)) {
|
||||||
|
lines.push(line);
|
||||||
|
fs.writeFileSync(excludePath, lines.join("\n") + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{cwd: string, source_repo: string}} lane
|
||||||
|
* @returns {{servers: string[], profilesSeeded: string[]}}
|
||||||
|
*/
|
||||||
|
async function syncMcp(lane) {
|
||||||
|
const sourceServers = readSourceMcpServers(lane.source_repo);
|
||||||
|
const relocated = relocate(sourceServers, lane.source_repo, lane.cwd);
|
||||||
|
pinPlaywrightOutputDir(relocated, lane.cwd);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(lane.cwd, ".mcp.json"),
|
||||||
|
JSON.stringify({ mcpServers: relocated }, null, 2) + "\n"
|
||||||
|
);
|
||||||
|
excludeFromGit(lane.cwd, ".mcp.json");
|
||||||
|
|
||||||
|
const profilesSeeded = seedProfiles(lane.source_repo, lane.cwd);
|
||||||
|
|
||||||
|
return { servers: Object.keys(relocated), profilesSeeded };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { syncMcp };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `node --test server/__tests__/lane-mcp.test.js`
|
||||||
|
Expected: PASS — all 3 tests so far.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write the failing tests — output-dir pinning, profile seeding, exclude idempotency**
|
||||||
|
|
||||||
|
Append to `server/__tests__/lane-mcp.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
describe("syncMcp — Playwright output-dir pinning", () => {
|
||||||
|
it("pins --output-dir only when a @playwright/mcp server doesn't already declare one", async () => {
|
||||||
|
const sourceRepo = path.join(SUITE_ROOT, "src-pin");
|
||||||
|
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||||
|
writeClaudeJson({
|
||||||
|
[sourceRepo]: {
|
||||||
|
mcpServers: {
|
||||||
|
playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] },
|
||||||
|
"playwright-custom": {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@playwright/mcp@latest", "--output-dir", "/already/set"],
|
||||||
|
},
|
||||||
|
"not-playwright": { command: "npx", args: ["-y", "some-other-mcp"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const lane = makeLane(sourceRepo);
|
||||||
|
await laneMcp.syncMcp(lane);
|
||||||
|
const written = JSON.parse(fs.readFileSync(path.join(lane.cwd, ".mcp.json"), "utf8"));
|
||||||
|
|
||||||
|
assert.deepEqual(written.mcpServers.playwright.args.slice(-2), [
|
||||||
|
"--output-dir",
|
||||||
|
path.join(lane.cwd, ".playwright-mcp"),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(written.mcpServers["playwright-custom"].args.slice(-2), [
|
||||||
|
"--output-dir",
|
||||||
|
"/already/set",
|
||||||
|
]);
|
||||||
|
assert.equal(written.mcpServers["not-playwright"].args.includes("--output-dir"), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncMcp — profile seeding", () => {
|
||||||
|
it("seeds a source profile into the lane and strips Singleton* lock files", async () => {
|
||||||
|
const sourceRepo = path.join(SUITE_ROOT, "src-profiles");
|
||||||
|
const srcProfileDir = path.join(sourceRepo, ".playwright-mcp", "profiles", "default");
|
||||||
|
fs.mkdirSync(srcProfileDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(srcProfileDir, "Cookies"), "fake-cookie-db");
|
||||||
|
fs.writeFileSync(path.join(srcProfileDir, "SingletonLock"), "stale-lock");
|
||||||
|
writeClaudeJson({
|
||||||
|
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lane = makeLane(sourceRepo);
|
||||||
|
const result = await laneMcp.syncMcp(lane);
|
||||||
|
assert.deepEqual(result.profilesSeeded, ["default"]);
|
||||||
|
|
||||||
|
const destDir = path.join(lane.cwd, ".playwright-mcp", "profiles", "default");
|
||||||
|
assert.ok(fs.existsSync(path.join(destDir, "Cookies")));
|
||||||
|
assert.ok(!fs.existsSync(path.join(destDir, "SingletonLock")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never overwrites a profile that already exists at the destination", async () => {
|
||||||
|
const sourceRepo = path.join(SUITE_ROOT, "src-profiles-2");
|
||||||
|
const srcProfileDir = path.join(sourceRepo, ".playwright-mcp", "profiles", "default");
|
||||||
|
fs.mkdirSync(srcProfileDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(srcProfileDir, "Cookies"), "new-cookie-db");
|
||||||
|
writeClaudeJson({
|
||||||
|
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lane = makeLane(sourceRepo);
|
||||||
|
const destDir = path.join(lane.cwd, ".playwright-mcp", "profiles", "default");
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(destDir, "Cookies"), "already-logged-in-cookie-db");
|
||||||
|
|
||||||
|
const result = await laneMcp.syncMcp(lane);
|
||||||
|
assert.deepEqual(result.profilesSeeded, []);
|
||||||
|
assert.equal(fs.readFileSync(path.join(destDir, "Cookies"), "utf8"), "already-logged-in-cookie-db");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncMcp — .git/info/exclude idempotency", () => {
|
||||||
|
it("appends .mcp.json once and does not duplicate it on a second call", async () => {
|
||||||
|
const sourceRepo = path.join(SUITE_ROOT, "src-exclude");
|
||||||
|
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||||
|
writeClaudeJson({
|
||||||
|
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
|
||||||
|
});
|
||||||
|
const lane = makeLane(sourceRepo);
|
||||||
|
fs.mkdirSync(path.join(lane.cwd, ".git"), { recursive: true });
|
||||||
|
|
||||||
|
await laneMcp.syncMcp(lane);
|
||||||
|
await laneMcp.syncMcp(lane);
|
||||||
|
|
||||||
|
const exclude = fs.readFileSync(path.join(lane.cwd, ".git", "info", "exclude"), "utf8");
|
||||||
|
const matches = exclude.split("\n").filter((line) => line === ".mcp.json");
|
||||||
|
assert.equal(matches.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `node --test server/__tests__/lane-mcp.test.js`
|
||||||
|
Expected: PASS — all 8 tests.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Header check + full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||||||
|
npm run test:server
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/lib/lane-mcp.js server/__tests__/lane-mcp.test.js
|
||||||
|
git commit -m "feat(lanes): add lane-mcp sync core (F1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `POST /api/lanes/:id/mcp/sync` route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/routes/lanes.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `syncMcp(lane) => Promise<{servers: string[], profilesSeeded: string[]}>` from Task 1 (`require("../lib/lane-mcp")`).
|
||||||
|
- Produces: `POST /api/lanes/:id/mcp/sync` — `200` with `{servers, profilesSeeded}`, `404` for an unknown lane, `400` with `{error: {code: "ENOMCPCONFIG", message}}` when the source repo has nothing configured.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the import**
|
||||||
|
|
||||||
|
In `server/routes/lanes.js`, add near the `lane-agents` import added in E3:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { syncMcp } = require("../lib/lane-mcp");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `sendRuntimeError`'s bad-request code list**
|
||||||
|
|
||||||
|
In `sendRuntimeError` (`server/routes/lanes.js`, the same function E2 extended with `EBADBRANCH`/`EUNRESOLVED`/`EMERGEUNCOMMITTED`), add `ENOMCPCONFIG`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const badRequest = [
|
||||||
|
"ENOPROFILE",
|
||||||
|
"ENOHOOK",
|
||||||
|
"EBADLANEDIR",
|
||||||
|
"EBADSVC",
|
||||||
|
"EBADBRANCH",
|
||||||
|
"EUNRESOLVED",
|
||||||
|
"EMERGEUNCOMMITTED",
|
||||||
|
"ENOMCPCONFIG",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the route**
|
||||||
|
|
||||||
|
Insert directly after the `/:id/agents/install` route (`server/routes/lanes.js:339`, the route E3 added):
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* Give this lane the same MCP servers as its source repo — relocates the
|
||||||
|
* source repo's already-configured mcpServers (from ~/.claude.json) into
|
||||||
|
* <lane>/.mcp.json, pins Playwright's proof output dir, seeds Chromium
|
||||||
|
* profiles. Never automatic, same as proof-link/agents-install: a session
|
||||||
|
* calls this explicitly. Never touches permissions/settings.local.json —
|
||||||
|
* see the F1 design spec for why.
|
||||||
|
*/
|
||||||
|
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
|
||||||
|
const lane = lanesLib.getLane(req.params.id);
|
||||||
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||||
|
try {
|
||||||
|
res.json(await syncMcp(lane));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOMCPCONFIG") {
|
||||||
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev &
|
||||||
|
sleep 3
|
||||||
|
# Replace 1 with a real lane id whose source repo has mcpServers configured
|
||||||
|
# project-scope in ~/.claude.json (or expect a 400 ENOMCPCONFIG if not).
|
||||||
|
curl -s -X POST http://localhost:4820/api/lanes/1/mcp/sync | head -c 300
|
||||||
|
echo
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{"servers":[...],"profilesSeeded":[...]}` or a `400` `ENOMCPCONFIG` body — either is correctly-wired, not a bug. Stop the dev server afterward.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full suite + header check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||||||
|
npm run test:server
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/routes/lanes.js
|
||||||
|
git commit -m "feat(lanes): add POST /:id/mcp/sync route (F1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `ccam lanes mcp sync` CLI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `bin/ccam.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `POST /api/lanes/:id/mcp/sync` (Task 2); `resolveLaneArg(args)`, `post(path, body, options)` (both already defined in `bin/ccam.js`).
|
||||||
|
- Produces: `ccam lanes mcp sync [<id>]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the subcommand function**
|
||||||
|
|
||||||
|
In `bin/ccam.js`, add near `cmdLanesAgentsInstall` (`bin/ccam.js:2161`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
/** `ccam lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||||
|
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||||
|
* profiles. Never run automatically; a session calls it explicitly. */
|
||||||
|
async function cmdLanesMcpSync(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const result = await post(
|
||||||
|
`/api/lanes/${resolved.laneId}/mcp/sync`,
|
||||||
|
{},
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${c.green("✔")} synced: ${result.servers.join(", ")}`);
|
||||||
|
if (result.profilesSeeded.length) {
|
||||||
|
console.log(` seeded profiles: ${result.profilesSeeded.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire the dispatcher**
|
||||||
|
|
||||||
|
In `bin/ccam.js`'s `lanes` case, right after the `agents install` check (`bin/ccam.js:3186-3188`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (rest[0] === "agents" && rest[1] === "install") {
|
||||||
|
return cmdLanesAgentsInstall(rest.slice(2));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add directly below it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (rest[0] === "mcp" && rest[1] === "sync") {
|
||||||
|
return cmdLanesMcpSync(rest.slice(2));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the help-catalog entry**
|
||||||
|
|
||||||
|
Right after the `lanes agents install` catalog entry (`bin/ccam.js:2364` area):
|
||||||
|
|
||||||
|
```js
|
||||||
|
[
|
||||||
|
"lanes mcp sync",
|
||||||
|
"[<id>]",
|
||||||
|
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev &
|
||||||
|
sleep 3
|
||||||
|
node bin/ccam.js lanes mcp sync 1
|
||||||
|
echo "exit: $?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `✔ synced: ...` with exit `0`, or a clear `✖` error with exit `1`. Stop the dev server afterward.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full suite + header check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||||||
|
npm run test:server
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add bin/ccam.js
|
||||||
|
git commit -m "feat(lanes): add ccam lanes mcp sync CLI (F1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude/skills/ship-feature-lane/SKILL.md`
|
||||||
|
- Modify: `docs/LANES.md`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`
|
||||||
|
|
||||||
|
**Interfaces:** none — documentation only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `SKILL.md` — replace the manual-fallback text**
|
||||||
|
|
||||||
|
Find (Setup section, `.claude/skills/ship-feature-lane/SKILL.md:37`):
|
||||||
|
|
||||||
|
```
|
||||||
|
- **MCP preflight (fail fast):** confirm this session actually loaded the lane's required Playwright MCPs (their `browser_*` tools must be available) — `playwright` and a local-QC MCP are always required for Stage 3/6. `ccam lanes mcp sync` (F, not built yet) would normally do this for you; until then, tell the human to configure `.mcp.json` manually and restart the session if a required MCP is missing. Catching this at Stage 0 costs a minute; catching it at Stage 13 strands a merged feature unverified.
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **MCP preflight (fail fast):** if this is the lane's first run, or a required Playwright MCP's `browser_*` tools aren't available in this session, run `ccam lanes mcp sync` — it relocates the source repo's already-configured MCP servers into this lane's `.mcp.json`. If the source repo has none configured (`ENOMCPCONFIG`), tell the human to configure `.mcp.json` manually for the source repo first. Either way, restart the session after syncing so the new config loads — `playwright` and a local-QC MCP are always required for Stage 3/6. Catching this at Stage 0 costs a minute; catching it at Stage 13 strands a merged feature unverified.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `docs/LANES.md` — add an mcp-sync subsection**
|
||||||
|
|
||||||
|
In `docs/LANES.md`, under `## The ship-feature-lane skill (E1)`, insert a new subsection after "### Installing the QC/gate agents: agents install" (added in E3) and before "### Pipeline template: ship-feature (16 node stages)":
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Syncing MCP servers: mcp sync
|
||||||
|
|
||||||
|
A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run Stage 3/6. `ccam lanes mcp sync` gives it those:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccam lanes mcp sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads the source repo's already-configured `mcpServers` from `~/.claude.json` (normal Claude Code project-scope config — set this up for the source repo once, the same way you would for any project), relocates any absolute path under the source repo to the lane's own directory, pins a `@playwright/mcp` server's `--output-dir` to the lane's `.playwright-mcp` (so proof screenshots land where the proof gallery reads them), and writes `<lane>/.mcp.json`. Also seeds the lane's Chromium browser profiles from the source repo's own — preserves saved logins, and never overwrites a profile that already exists at the destination.
|
||||||
|
|
||||||
|
**No permission/settings changes.** Unlike Shipyard's original `lane-mcp-sync.sh`, this command never writes to `<lane>/.claude/settings.local.json` — no auto-approval rules, no `autoMode` bypass entries. A session's `gh`/`git push` commands go through the normal permission prompt like any other command.
|
||||||
|
|
||||||
|
A lane whose source repo has no `mcpServers` configured gets a clear `ENOMCPCONFIG` error, not a silently-empty `.mcp.json` — configure the source repo's MCP servers first, then re-run.
|
||||||
|
|
||||||
|
Restart the lane's Claude session after syncing — MCP config is read at session start.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Roadmap progress line**
|
||||||
|
|
||||||
|
In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, find the `## F` section (search `grep -n "^## F "`). It currently has no `**Progress:**` line (F hasn't started). Add one directly under its `**Goal:**` line:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Progress:** `mcp sync` (F1) done 2026-08-05 — see `docs/superpowers/specs/2026-08-05-mcp-sync-design.md`. Tracker, dev-QC, CI-wait remain.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||||||
|
npm run test:server
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .claude/skills/ship-feature-lane/SKILL.md docs/LANES.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
|
||||||
|
git commit -m "docs(lanes): document ccam lanes mcp sync (F1)"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user