@taprootio/trellis 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // Trellis import CLI — a thin wrapper over the engine in src/import.mjs.
3
+ //
4
+ // node scripts/trellis-import.mjs <source> (--profile <name> | --mapping <file>) [flags]
5
+ //
6
+ // Imports an existing backlog (at <source>) into the target Trellis repo using a
7
+ // declarative mapping — either a built-in named profile (--profile, see
8
+ // src/profiles.mjs) or your own mapping file (--mapping, see src/import.mjs for the
9
+ // shape). DRY-RUN BY DEFAULT — it prints the plan, the id map, and per-field
10
+ // warnings without writing; pass --apply to actually write items and regenerate.
11
+ // The source tree is never modified. Logic lives in src/import.mjs and
12
+ // src/profiles.mjs so the MCP import tool and `init --import` share it; this stays
13
+ // dependency-free.
14
+
15
+ import { resolve } from "node:path";
16
+ import { applyImport } from "../src/import.mjs";
17
+ import { optionToken, requiredValue, resolveRepoRoot, showHelp, usageError } from "../src/cli.mjs";
18
+ import { loadProfile, loadMappingFile, listProfiles } from "../src/profiles.mjs";
19
+
20
+ const HELP = `trellis import — import an existing backlog into Trellis
21
+
22
+ Usage:
23
+ trellis import <source> (--profile <name> | --mapping <file>) [flags]
24
+
25
+ Flags:
26
+ --profile <name> built-in source-mapping profile (see --list-profiles)
27
+ --mapping <file> declarative mapping file (JSON) describing the source schema
28
+ --target <dir> target Trellis repo (default: ".")
29
+ --apply write items and regenerate (default: dry-run, write nothing)
30
+ --dry-run report the plan only (the default)
31
+ --list-profiles list the built-in profiles and exit
32
+ -h, --help show this help
33
+
34
+ Provide exactly one of --profile or --mapping. The source tree is read-only; ids
35
+ are assigned fresh-sequentially from the target's next id, colliding source ids are
36
+ deduped, and depends_on is rewritten accordingly. Relative <source> paths resolve
37
+ against the target repo.
38
+ `;
39
+
40
+ function parseArgs(argv) {
41
+ const opts = {};
42
+ let source;
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ const { key, inline } = optionToken(a);
46
+ switch (key) {
47
+ case "-h": case "--help": opts.help = true; break;
48
+ case "--apply": opts.apply = true; break;
49
+ case "--dry-run": opts.dryRun = true; break;
50
+ case "--list-profiles": opts.listProfiles = true; break;
51
+ case "--profile": {
52
+ const next = requiredValue(argv, i, inline, "--profile");
53
+ opts.profile = next.value;
54
+ i = next.index;
55
+ break;
56
+ }
57
+ case "--mapping": {
58
+ const next = requiredValue(argv, i, inline, "--mapping");
59
+ opts.mapping = next.value;
60
+ i = next.index;
61
+ break;
62
+ }
63
+ case "--target": case "--repo": {
64
+ const next = requiredValue(argv, i, inline, key);
65
+ opts.target = next.value;
66
+ i = next.index;
67
+ break;
68
+ }
69
+ default:
70
+ if (a.startsWith("-")) usageError(`Unknown flag: ${a}`);
71
+ if (source === undefined) source = a;
72
+ else usageError(`Unexpected extra argument: ${a}`);
73
+ }
74
+ }
75
+ return { source, opts };
76
+ }
77
+
78
+ function report(targetRoot, summary, dryRun) {
79
+ if (summary.errors.length) {
80
+ const wrote = summary.created.length || summary.generated.length;
81
+ console.error(wrote ? `Import of ${targetRoot} did not complete:` : `Refused to import into ${targetRoot} — wrote nothing:`);
82
+ for (const e of summary.errors) console.error(` - ${e}`);
83
+ // Even on refusal, the id map + warnings help the user fix the mapping — e.g.
84
+ // see which source ids collided behind an ambiguous depends_on.
85
+ if (summary.idMap.length) {
86
+ console.error(" id map:");
87
+ for (const m of summary.idMap) console.error(` ${m.sourceId} (${m.sourceFile}) → ${m.newId}`);
88
+ }
89
+ for (const w of summary.warnings) console.warn(` warning: ${w}`);
90
+ return;
91
+ }
92
+ const c = summary.counts || { total: 0, active: 0, completed: 0, removed: 0 };
93
+ const verb = dryRun ? "Would import" : "Imported";
94
+ console.log(`${verb} ${c.total} item${c.total === 1 ? "" : "s"} into ${targetRoot}${dryRun ? " (dry run)" : ""}`);
95
+ console.log(` counts: ${c.active} active, ${c.completed} completed, ${c.removed} removed`);
96
+ const pv = summary.provenance;
97
+ if (pv && (pv.gitDated || pv.dateDefaulted || pv.effortEstimated)) {
98
+ const parts = [];
99
+ if (pv.gitDated) parts.push(`${pv.gitDated} git-dated`);
100
+ if (pv.dateDefaulted) parts.push(`${pv.dateDefaulted} date-defaulted`);
101
+ if (pv.effortEstimated) parts.push(`${pv.effortEstimated} effort-estimated`);
102
+ console.log(` estimated: ${parts.join(", ")}`);
103
+ }
104
+ if (summary.idMap.length) {
105
+ console.log(" id map:");
106
+ for (const m of summary.idMap) console.log(` ${m.sourceId} (${m.sourceFile}) → ${m.newId}`);
107
+ }
108
+ if (summary.generated.length) console.log(` ${dryRun ? "regenerate" : "generated"} (${summary.generated.length}): ${summary.generated.join(", ")}`);
109
+ for (const w of summary.warnings) console.log(` warning: ${w}`);
110
+ if (!dryRun) console.log(`Done. Review ${summary.root}/, then commit.`);
111
+ else console.log("Dry run — nothing written. Re-run with --apply to write.");
112
+ }
113
+
114
+ const { source, opts } = parseArgs(process.argv.slice(2));
115
+ if (opts.help) showHelp(HELP);
116
+
117
+ if (opts.listProfiles) {
118
+ const profiles = listProfiles();
119
+ if (!profiles.length) { console.log("No built-in profiles found."); process.exit(0); }
120
+ console.log("Built-in profiles:");
121
+ for (const p of profiles) console.log(` ${p.name}${p.description ? ` — ${p.description}` : ""}`);
122
+ process.exit(0);
123
+ }
124
+
125
+ if (!source) { console.error("error: a <source> path is required\n"); process.stdout.write(HELP); process.exit(2); }
126
+ // Exactly one of --profile / --mapping (XOR via boolean coercion).
127
+ if (!!opts.profile === !!opts.mapping) {
128
+ console.error("error: provide exactly one of --profile <name> or --mapping <file.json>");
129
+ process.exit(2);
130
+ }
131
+
132
+ const { mapping, error } = opts.profile ? loadProfile(opts.profile) : loadMappingFile(opts.mapping);
133
+ if (error) { console.error(`error: ${error}`); process.exit(2); }
134
+
135
+ const targetRoot = resolveRepoRoot(opts.target);
136
+ const sourceRoot = resolve(targetRoot, source); // relative <source> resolves against the target repo
137
+ const dryRun = opts.dryRun || !opts.apply; // dry-run by default; --apply writes, but an explicit --dry-run always wins
138
+
139
+ const { summary } = applyImport(targetRoot, sourceRoot, mapping, { dryRun });
140
+ report(targetRoot, summary, dryRun);
141
+ process.exit(summary.errors.length ? 1 : 0);
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ // Trellis init CLI — a thin wrapper over the scaffolder in src/init.mjs.
3
+ //
4
+ // node scripts/trellis-init.mjs [target] [flags]
5
+ //
6
+ // Onboards the target repo (default ".") to the Trellis layout. Vocabulary comes
7
+ // from flags with sensible defaults; when run interactively with the prefix or
8
+ // milestones omitted, it prompts for them. Idempotent — existing files are never
9
+ // clobbered (use --force to overwrite). With `--import <path>` it then imports an
10
+ // existing backlog via a named profile or a mapping file — the onboard-a-repo-that-
11
+ // already-has-a-backlog on-ramp — reusing src/import.mjs and src/profiles.mjs. All
12
+ // logic lives under src/, so this stays free of third-party dependencies.
13
+
14
+ import { dirname, join, resolve, isAbsolute } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { createInterface } from "node:readline/promises";
17
+ import { DEFAULTS, applyScaffold, resolveOptions, validateOptions, retireSource, shouldPromptVocab } from "../src/init.mjs";
18
+ import { applyImport } from "../src/import.mjs";
19
+ import { loadProfile, loadMappingFile } from "../src/profiles.mjs";
20
+
21
+ const HELP = `trellis init — scaffold the Trellis backlog into a repo
22
+
23
+ Usage:
24
+ trellis init [target] [flags]
25
+
26
+ Flags:
27
+ --prefix <P> id prefix (default: ${DEFAULTS.prefix})
28
+ --id-width <N> zero-padded id digits (default: ${DEFAULTS.idWidth})
29
+ --milestones <a,b,c> ordered maturity milestones (default: ${DEFAULTS.milestones.join(",")})
30
+ --priorities <a,b,c> ordered priorities, highest first (default: ${DEFAULTS.priorities.join(",")})
31
+ --effort <1,2,3> canonical effort values (default: ${DEFAULTS.effort.join(",")})
32
+ --import <path> after scaffolding, import an existing backlog at <path>
33
+ --profile <name> source-mapping profile for --import (trellis import --list-profiles)
34
+ --mapping <file> mapping file (JSON) for --import (alternative to --profile)
35
+ --retire-source <p> history-preservingly git-rm an imported source tree at <p>
36
+ (a separate, later step — see below; cannot combine with --import)
37
+ --force overwrite existing files instead of skipping
38
+ --dry-run report what would change without writing
39
+ -h, --help show this help
40
+
41
+ With --import, provide exactly one of --profile or --mapping; a relative <path>
42
+ resolves against the target repo. --dry-run previews the scaffold without writing;
43
+ preview the import plan itself with "trellis import --dry-run" on the initialized repo.
44
+
45
+ --retire-source removes the old source tree once the import is green and committed:
46
+ it stages a "git rm -r <p>" (history preserved) and leaves the deletion for you to
47
+ review and commit — it does not scaffold, import, or commit. Run it on its own, after
48
+ the import, never in the same command (--dry-run lists the files without touching git).
49
+ `;
50
+
51
+ const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
52
+
53
+ function parseArgs(argv) {
54
+ const opts = {};
55
+ const csv = (s) => (s == null ? [] : s.split(",").map((x) => x.trim()).filter(Boolean));
56
+ let target = ".";
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ const eq = a.indexOf("=");
60
+ const key = a.startsWith("--") && eq !== -1 ? a.slice(0, eq) : a;
61
+ const inline = a.startsWith("--") && eq !== -1 ? a.slice(eq + 1) : null;
62
+ // Read a flag's value. `--flag=value` always uses the inline value; with the space
63
+ // form, a following OPTION-looking token (anything starting with `-`, e.g. `--dry-run`
64
+ // or `-h`) means the value was OMITTED — do not swallow it. So `--retire-source -h` is
65
+ // a missing path (help wins / a clean usage error), never a path literally named "-h"
66
+ // with the real intent silently dropped onto a staged `git rm`. To pass a value that
67
+ // genuinely starts with `-`, use the inline form (`--flag=-x`) or, for a path, `./-x`.
68
+ const next = () => {
69
+ if (inline !== null) return inline;
70
+ const v = argv[i + 1];
71
+ if (v === undefined || v.startsWith("-")) return undefined;
72
+ i++;
73
+ return v;
74
+ };
75
+ switch (key) {
76
+ case "-h": case "--help": opts.help = true; break;
77
+ case "--force": opts.force = true; break;
78
+ case "--dry-run": opts.dryRun = true; break;
79
+ case "--import": opts.import = next(); break;
80
+ case "--profile": opts.profile = next(); break;
81
+ case "--mapping": opts.mapping = next(); break;
82
+ case "--retire-source": opts.retireSource = next(); break;
83
+ case "--prefix": opts.prefix = next(); break;
84
+ case "--id-width": opts.idWidth = Number(next()); break;
85
+ case "--milestones": opts.milestones = csv(next()); break;
86
+ case "--priorities": opts.priorities = csv(next()); break;
87
+ // Keep non-numeric tokens as NaN (don't filter) so a typo like
88
+ // `--effort 1,abc,3` is rejected by validateOptions, not silently dropped.
89
+ case "--effort": opts.effort = csv(next()).map(Number); break;
90
+ default:
91
+ if (a.startsWith("-")) { console.error(`Unknown flag: ${a}`); process.exit(2); }
92
+ target = a;
93
+ }
94
+ }
95
+ return { target, opts };
96
+ }
97
+
98
+ async function promptMissing(opts) {
99
+ // Prompt for the two the task calls out (prefix, milestones) only when one is missing
100
+ // on an interactive, non-dry, non-retire run — see shouldPromptVocab (keyed on flag
101
+ // presence so a valueless --retire-source doesn't prompt before erroring).
102
+ if (!shouldPromptVocab(opts, process.stdin.isTTY)) return;
103
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
104
+ try {
105
+ if (!opts.prefix) {
106
+ const a = (await rl.question(`ID prefix [${DEFAULTS.prefix}]: `)).trim();
107
+ if (a) opts.prefix = a;
108
+ }
109
+ if (!opts.milestones) {
110
+ const a = (await rl.question(`Milestones [${DEFAULTS.milestones.join(",")}]: `)).trim();
111
+ if (a) opts.milestones = a.split(",").map((x) => x.trim()).filter(Boolean);
112
+ }
113
+ } finally {
114
+ rl.close();
115
+ }
116
+ }
117
+
118
+ function report(targetRoot, summary, dryRun) {
119
+ // Fatal errors → never claim success. "Refused" if nothing was written;
120
+ // "did not complete" if some files landed before a fatal generate failure.
121
+ if (summary.errors.length) {
122
+ const wroteSomething = summary.created.length || summary.appended.length || summary.generated.length;
123
+ console.error(wroteSomething
124
+ ? `Scaffold of ${targetRoot} did not complete:`
125
+ : `Refused to scaffold ${targetRoot} — wrote nothing:`);
126
+ for (const e of summary.errors) console.error(` - ${e}`);
127
+ return;
128
+ }
129
+ const verb = dryRun ? "Would scaffold" : "Scaffolded";
130
+ console.log(`${verb} Trellis into ${targetRoot}${dryRun ? " (dry run)" : ""}`);
131
+ const line = (label, items) => { if (items.length) console.log(` ${label} (${items.length}): ${items.join(", ")}`); };
132
+ line(dryRun ? "create" : "created", summary.created);
133
+ line(dryRun ? "append" : "appended", summary.appended);
134
+ line(dryRun ? "regenerate" : "generated", summary.generated);
135
+ line("skipped", summary.skipped);
136
+ // Remaining warnings are benign (e.g. a missing copy source) — the scaffold
137
+ // still completed, so they do not change the exit code.
138
+ for (const w of summary.warnings) console.warn(` warning: ${w}`);
139
+ // Reconciliation checklist (TRL0027): stale backlog guidance for the agent to
140
+ // rewrite. Advisory and report-only — init never edited these — so, like warnings,
141
+ // it does not affect the exit code.
142
+ if (summary.reconcile && summary.reconcile.length) {
143
+ console.log(` reconcile (${summary.reconcile.length}) — pre-Trellis backlog guidance to rewrite by hand (init left these untouched):`);
144
+ for (const r of summary.reconcile) console.log(` - ${r.file}: ${r.note}`);
145
+ }
146
+ if (!dryRun) {
147
+ console.log(`Done. For a fresh backlog, add a task under ${summary.root}/active/, then \`npx @taprootio/trellis generate\`.`);
148
+ console.log(`If you are importing an existing backlog, run \`npx @taprootio/trellis import ...\` before creating any new task so imported ids keep the first available range.`);
149
+ console.log(`Then enable branch protection so the \`backlog\` check gates merges — see trellis/branch-protection.md.`);
150
+ }
151
+ }
152
+
153
+ // Usage errors for the --import on-ramp, validated before any write: --profile /
154
+ // --mapping only make sense with --import, and --import needs exactly one of them.
155
+ function importFlagErrors(opts) {
156
+ const errors = [];
157
+ const hasProfile = !!opts.profile, hasMapping = !!opts.mapping;
158
+ if (!opts.import) {
159
+ if (hasProfile || hasMapping) errors.push("--profile/--mapping only apply with --import <path>");
160
+ return errors;
161
+ }
162
+ if (hasProfile === hasMapping) errors.push("--import requires exactly one of --profile <name> or --mapping <file>");
163
+ return errors;
164
+ }
165
+
166
+ // --retire-source is a separate, later step — never automatic mid-import (TRL0027), so
167
+ // it cannot share a run with --import. Keyed on the flag's PRESENCE (not a truthy
168
+ // value): a valueless `--retire-source` (trailing flag, or `--retire-source=`) must be
169
+ // a usage error, not a silent fall-through to scaffolding.
170
+ function retireFlagErrors(opts) {
171
+ if (!("retireSource" in opts)) return [];
172
+ const errors = [];
173
+ if (!opts.retireSource || !String(opts.retireSource).trim()) errors.push("--retire-source requires a path");
174
+ if (opts.import) errors.push("--retire-source cannot be combined with --import — retire the source in a separate run after the import is committed");
175
+ return errors;
176
+ }
177
+
178
+ // Concise report for the follow-on import (mirrors the trellis import CLI). Fatal
179
+ // errors never claim success; otherwise echo counts + the id map so the user can
180
+ // review before committing.
181
+ function reportImport(targetRoot, summary) {
182
+ if (summary.errors.length) {
183
+ const wrote = summary.created.length || summary.generated.length;
184
+ console.error(wrote ? `Import into ${targetRoot} did not complete:` : `Refused to import into ${targetRoot} — wrote nothing:`);
185
+ for (const e of summary.errors) console.error(` - ${e}`);
186
+ for (const w of summary.warnings) console.warn(` warning: ${w}`);
187
+ return;
188
+ }
189
+ const c = summary.counts || { total: 0, active: 0, completed: 0, removed: 0 };
190
+ console.log(`Imported ${c.total} item${c.total === 1 ? "" : "s"} (${c.active} active, ${c.completed} completed, ${c.removed} removed).`);
191
+ const pv = summary.provenance;
192
+ if (pv && (pv.gitDated || pv.dateDefaulted || pv.effortEstimated)) {
193
+ const parts = [];
194
+ if (pv.gitDated) parts.push(`${pv.gitDated} git-dated`);
195
+ if (pv.dateDefaulted) parts.push(`${pv.dateDefaulted} date-defaulted`);
196
+ if (pv.effortEstimated) parts.push(`${pv.effortEstimated} effort-estimated`);
197
+ console.log(` estimated: ${parts.join(", ")}`);
198
+ }
199
+ if (summary.idMap.length) {
200
+ console.log(" id map:");
201
+ for (const m of summary.idMap) console.log(` ${m.sourceId} (${m.sourceFile}) → ${m.newId}`);
202
+ }
203
+ for (const w of summary.warnings) console.warn(` warning: ${w}`);
204
+ console.log(`Done. Review ${summary.root}/, then commit.`);
205
+ }
206
+
207
+ // Report for --retire-source. A refusal (errors) never claims success; otherwise echo
208
+ // the staged-but-uncommitted removal and steer the user to review + commit.
209
+ function reportRetire(summary, dryRun) {
210
+ if (summary.errors.length) {
211
+ console.error(`Refused to retire ${summary.path ? `"${summary.path}"` : "the source"} — nothing changed:`);
212
+ for (const e of summary.errors) console.error(` - ${e}`);
213
+ return;
214
+ }
215
+ const n = summary.removed.length;
216
+ const files = `${n} tracked file${n === 1 ? "" : "s"}`;
217
+ if (dryRun) {
218
+ // List the files: a dry run stages nothing, so there is no `git status` to inspect —
219
+ // the report is the only place the user sees exactly what would be removed.
220
+ console.log(`Would retire "${summary.path}" — git rm ${files} (dry run, nothing changed):`);
221
+ for (const f of summary.removed) console.log(` ${f}`);
222
+ return;
223
+ }
224
+ console.log(`Retired "${summary.path}" — staged the removal of ${files} with git rm.`);
225
+ console.log("Review with `git status`, then commit. Git preserves the history.");
226
+ }
227
+
228
+ const { target, opts } = parseArgs(process.argv.slice(2));
229
+ if (opts.help) { process.stdout.write(HELP); process.exit(0); }
230
+
231
+ await promptMissing(opts);
232
+
233
+ // Scaffold vocabulary is irrelevant to a retire-only run (it never scaffolds), so don't
234
+ // validate it there — mirrors promptMissing, which also skips on retire.
235
+ if (!("retireSource" in opts)) {
236
+ const optErrors = validateOptions(resolveOptions(opts));
237
+ if (optErrors.length) {
238
+ for (const e of optErrors) console.error(`error: ${e}`);
239
+ process.exit(2);
240
+ }
241
+ }
242
+
243
+ const flagErrors = [...importFlagErrors(opts), ...retireFlagErrors(opts)];
244
+ if (flagErrors.length) {
245
+ for (const e of flagErrors) console.error(`error: ${e}`);
246
+ process.exit(2);
247
+ }
248
+
249
+ const targetRoot = resolve(target);
250
+ const dryRun = !!opts.dryRun;
251
+
252
+ // --retire-source: a deliberate, standalone step run after the import is committed. It
253
+ // does not scaffold or import — it stages a history-preserving `git rm` of the source.
254
+ // Keyed on presence (a valueless flag was already rejected by retireFlagErrors).
255
+ if ("retireSource" in opts) {
256
+ const { summary: ret } = retireSource(targetRoot, opts.retireSource, { dryRun });
257
+ reportRetire(ret, dryRun);
258
+ process.exit(ret.errors.length ? 1 : 0);
259
+ }
260
+
261
+ const { summary } = applyScaffold(targetRoot, opts, { dryRun }, sourceRoot);
262
+ report(targetRoot, summary, dryRun);
263
+ // A failed scaffold (refusal or generate failure) is fatal and blocks any import.
264
+ if (summary.errors.length) process.exit(1);
265
+
266
+ // --import on-ramp: scaffold, then import an existing backlog in one command.
267
+ if (opts.import) {
268
+ const { mapping, error } = opts.profile ? loadProfile(opts.profile) : loadMappingFile(opts.mapping);
269
+ if (error) { console.error(`error: ${error}`); process.exit(2); }
270
+ const importSource = isAbsolute(opts.import) ? opts.import : resolve(targetRoot, opts.import);
271
+ if (dryRun) {
272
+ // A dry run scaffolds nothing, so there is no initialized target to plan the
273
+ // import against — report intent and point at `trellis import --dry-run` (which
274
+ // previews the full plan against an initialized repo) rather than computing a
275
+ // misleading plan here against a non-existent backlog.
276
+ const via = opts.profile ? `profile ${opts.profile}` : `mapping ${opts.mapping}`;
277
+ console.log(`Would then import from ${importSource} using ${via}.`);
278
+ console.log("Re-run without --dry-run to scaffold and import, or run `npx @taprootio/trellis import --dry-run` on the initialized repo to preview the import plan.");
279
+ process.exit(0);
280
+ }
281
+ const { summary: imp } = applyImport(targetRoot, importSource, mapping, { dryRun: false });
282
+ reportImport(targetRoot, imp);
283
+ process.exit(imp.errors.length ? 1 : 0);
284
+ }
285
+
286
+ // A benign warning (e.g. a missing copy source) on a completed scaffold exits 0.
287
+ process.exit(0);
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ // Trellis MCP server — exposes the backlog operations as MCP tools over stdio.
3
+ //
4
+ // node scripts/trellis-mcp.mjs [--repo <path>]
5
+ //
6
+ // The tools are thin adapters over src/mcp.mjs (which is dependency-free and
7
+ // unit-tested); the @modelcontextprotocol SDK and the transport live only in this
8
+ // entry point. Each tool resolves a repo root — the per-call `repoRoot` arg, else
9
+ // the server's default (`--repo`, else cwd) — so one server can serve any repo the
10
+ // client points at.
11
+ //
12
+ // stdout is the JSON-RPC channel: all diagnostics go to stderr.
13
+
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { z } from "zod";
17
+ import { pathToFileURL } from "node:url";
18
+ import { realpathSync } from "node:fs";
19
+ import { optionToken, requiredValue, resolveRepoRoot, usageError } from "../src/cli.mjs";
20
+ import { OPS, TrellisError } from "../src/mcp.mjs";
21
+ import { RESOURCES, PROMPTS, listResources, readResource, buildPrompt } from "../src/prompts.mjs";
22
+
23
+ // Server implementation version (distinct from the spec version; packaging and
24
+ // real versioning are TRL0010).
25
+ const SERVER_VERSION = "0.1.0";
26
+
27
+ function parseArgs(argv) {
28
+ let repo;
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ const { key, inline } = optionToken(a);
32
+ if (key === "--repo" || key === "--target") {
33
+ const next = requiredValue(argv, i, inline, key);
34
+ repo = next.value;
35
+ i = next.index;
36
+ } else if (a === "-h" || a === "--help") {
37
+ return { help: true };
38
+ } else {
39
+ usageError(`Unknown argument: ${a}`);
40
+ }
41
+ }
42
+ return { repo: resolveRepoRoot(repo) };
43
+ }
44
+
45
+ const HELP = `trellis mcp — serve the Trellis backlog operations over MCP (stdio)
46
+
47
+ Usage:
48
+ trellis mcp [--repo <path>]
49
+
50
+ Options:
51
+ --repo <path> default repo root for tools that omit \`repoRoot\`, and the repo
52
+ served for prompts and resources (default: cwd)
53
+ -h, --help show this help
54
+
55
+ Tools: ${Object.keys(OPS).join(", ")}
56
+ Prompts: ${PROMPTS.map((p) => p.name).join(", ")}
57
+ Resources: ${RESOURCES.map((r) => r.uri).join(", ")}
58
+ `;
59
+
60
+ const repoRootArg = { repoRoot: z.string().optional().describe("repo root to operate on; defaults to the server's --repo / cwd") };
61
+
62
+ // name → { description, inputSchema (a zod raw shape) }. The handler for each is
63
+ // OPS[name]; every tool also accepts the shared optional `repoRoot`. Exported so a
64
+ // test can assert the served metadata stays repo-agnostic without booting the server.
65
+ export const TOOLS = {
66
+ list_tasks: {
67
+ description: "List backlog tasks (the backlog.json shape), optionally filtered by status or milestone.",
68
+ inputSchema: {
69
+ ...repoRootArg,
70
+ status: z.enum(["active", "completed", "removed"]).optional().describe("only tasks with this status"),
71
+ milestone: z.string().optional().describe("only tasks in this milestone"),
72
+ },
73
+ },
74
+ get_task: {
75
+ description: "Get one task by id: its structured entry plus the raw Markdown body and file path.",
76
+ inputSchema: { ...repoRootArg, id: z.string().describe("task id using this repo's configured id prefix and width") },
77
+ },
78
+ next_id: {
79
+ description: "The id a newly created task would receive.",
80
+ inputSchema: { ...repoRootArg },
81
+ },
82
+ create_task: {
83
+ description: "Create an active task: assigns the next id, writes the item file, then regenerates and validates.",
84
+ inputSchema: {
85
+ ...repoRootArg,
86
+ title: z.string().describe("one-line title"),
87
+ summary: z.string().describe("one-sentence summary for the index"),
88
+ milestone: z.string().describe("a configured milestone"),
89
+ priority: z.string().describe("a configured priority"),
90
+ effort: z.union([z.number(), z.string()]).describe("a canonical effort number, or a label from the active effort scale"),
91
+ depends_on: z.array(z.string()).optional().describe("ids this task depends on"),
92
+ owner: z.string().optional().describe("owner handle; must be an active member of the team roster (team.json)"),
93
+ collaborators: z.array(z.string()).optional().describe("collaborator handles; each must be an active roster member"),
94
+ body: z.string().optional().describe("Markdown body; Scope/Notes/Risks are scaffolded if omitted"),
95
+ },
96
+ },
97
+ move_task: {
98
+ description: "Move an active task to completed or removed: updates front-matter, prepends a closeout note, regenerates and validates.",
99
+ inputSchema: {
100
+ ...repoRootArg,
101
+ id: z.string().describe("active task id to move"),
102
+ to: z.enum(["completed", "removed"]).describe("target status"),
103
+ reason: z.string().optional().describe("required when removing: why, and any trigger to revisit"),
104
+ note: z.string().optional().describe("closeout note prepended to the body"),
105
+ date: z.string().optional().describe("ISO close date (YYYY-MM-DD); defaults to today"),
106
+ owner: z.string().optional().describe("override the owner on close (historical; pass empty to clear)"),
107
+ collaborators: z.array(z.string()).optional().describe("override collaborators on close (historical)"),
108
+ },
109
+ },
110
+ validate: {
111
+ description: "Validate the backlog (config, items, markers); read-only. Returns { ok, errors, warnings }.",
112
+ inputSchema: { ...repoRootArg },
113
+ },
114
+ regenerate: {
115
+ description: "Rewrite any stale generated artifact. Returns { changed, nextId, counts }.",
116
+ inputSchema: { ...repoRootArg },
117
+ },
118
+ import: {
119
+ description: "Import an existing backlog into this repo via a named profile or an inline mapping. Dry-run by default; pass apply:true to write items and regenerate (rolls back on any failure). Returns the import summary (counts, idMap, created, generated).",
120
+ inputSchema: {
121
+ ...repoRootArg,
122
+ source: z.string().describe("path to the source backlog to import; a relative path resolves against the target repo"),
123
+ profile: z.string().optional().describe("name of a built-in source-mapping profile (alternative to `mapping`)"),
124
+ mapping: z.record(z.string(), z.any()).optional().describe("inline mapping object describing the source schema (alternative to `profile`)"),
125
+ apply: z.boolean().optional().describe("write items and regenerate; omit or false for a dry-run (the default)"),
126
+ },
127
+ },
128
+ history: {
129
+ description: "Read-only git-derived task history (SPEC §8.4). With an id → { id, entries }; omit id → { generated, tasks } keyed by id. Each entry is { id, commit, date, author, subject, reason } newest-first, where reason is the Trellis-Reason commit trailer when present, else the subject. Derived from git (requires a work tree); volatile and non-authoritative — never part of backlog:check, never writes.",
130
+ inputSchema: {
131
+ ...repoRootArg,
132
+ id: z.string().optional().describe("a task id; omit to derive the whole repo"),
133
+ },
134
+ },
135
+ };
136
+
137
+ export function registerTools(server, defaultRoot) {
138
+ for (const [name, def] of Object.entries(TOOLS)) {
139
+ server.registerTool(name, { description: def.description, inputSchema: def.inputSchema }, (args = {}) => {
140
+ try {
141
+ const root = args.repoRoot ? resolveRepoRoot(args.repoRoot) : defaultRoot;
142
+ const result = OPS[name](root, args);
143
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: result };
144
+ } catch (e) {
145
+ const msg = e instanceof TrellisError ? e.message : `unexpected error: ${e.message}`;
146
+ return { content: [{ type: "text", text: msg }], isError: true };
147
+ }
148
+ });
149
+ }
150
+ }
151
+
152
+ // Resources serve the server's default repo (a static uri carries no per-call
153
+ // repoRoot). Only the resources whose backing file exists at boot are advertised,
154
+ // so the list never offers what this repo can't serve (e.g. SPEC.md is absent in
155
+ // onboarded repos until TRL0010). Returns the count registered.
156
+ function registerResources(server, defaultRoot) {
157
+ const available = new Set(listResources(defaultRoot).filter((r) => r.available).map((r) => r.uri));
158
+ const byUri = new Map(RESOURCES.map((r) => [r.uri, r]));
159
+ for (const uri of available) {
160
+ const r = byUri.get(uri);
161
+ server.registerResource(
162
+ r.name,
163
+ r.uri,
164
+ { title: r.title, description: r.description, mimeType: r.mimeType },
165
+ () => {
166
+ const { uri: u, mimeType, text } = readResource(defaultRoot, r.uri);
167
+ return { contents: [{ uri: u, mimeType, text }] };
168
+ },
169
+ );
170
+ }
171
+ return available.size;
172
+ }
173
+
174
+ // Prompts are bound to the server's repo, exactly like resources — deliberately
175
+ // NOT given a per-call `repoRoot` override. A prompt's text points at the
176
+ // `trellis://…` resources, which resolve to `defaultRoot`; letting a prompt build
177
+ // against a different repo would make those pointers reference the wrong repo's
178
+ // conventions. Per-repo addressing across the whole surface (tools, prompts, AND
179
+ // resources, with root scoping) is TRL0019. A failed build (bad id, missing
180
+ // playbook) throws a TrellisError, which the SDK surfaces as the prompt's get error.
181
+ function registerPrompts(server, defaultRoot) {
182
+ for (const p of PROMPTS) {
183
+ const argsSchema = {};
184
+ for (const a of p.arguments) {
185
+ argsSchema[a.name] = a.required ? z.string().describe(a.description) : z.string().optional().describe(a.description);
186
+ }
187
+ server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema }, (args = {}) =>
188
+ buildPrompt(defaultRoot, p.name, args),
189
+ );
190
+ }
191
+ }
192
+
193
+ // True when this module is the process entry point. Resolve argv[1] through
194
+ // realpath first: `import.meta.url` is already symlink-resolved, so a bin-style /
195
+ // symlinked launch (npx, node_modules/.bin) would otherwise miss and the server
196
+ // would silently never boot. Guarded so a plain import (e.g. a test inspecting
197
+ // TOOLS) neither parses argv nor opens the stdio transport.
198
+ function isEntryPoint() {
199
+ const argv1 = process.argv[1];
200
+ if (!argv1) return false;
201
+ try {
202
+ return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ if (isEntryPoint()) {
209
+ const opts = parseArgs(process.argv.slice(2));
210
+ if (opts.help) {
211
+ process.stdout.write(HELP);
212
+ process.exit(0);
213
+ }
214
+
215
+ const server = new McpServer({ name: "trellis", version: SERVER_VERSION });
216
+ registerTools(server, opts.repo);
217
+ const resourceCount = registerResources(server, opts.repo);
218
+ registerPrompts(server, opts.repo);
219
+
220
+ await server.connect(new StdioServerTransport());
221
+ console.error(`trellis-mcp ready (repo: ${opts.repo}; ${Object.keys(OPS).length} tools, ${PROMPTS.length} prompts, ${resourceCount} resources)`);
222
+ }