@sdods/cli 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.
Files changed (124) hide show
  1. package/LICENSE +17 -0
  2. package/bin/sdods.js +19 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/bin.d.ts +2 -0
  5. package/dist/bin.js +11 -0
  6. package/dist/commands/agent.d.ts +3 -0
  7. package/dist/commands/agent.js +219 -0
  8. package/dist/commands/analyze.d.ts +20 -0
  9. package/dist/commands/analyze.js +216 -0
  10. package/dist/commands/auth.d.ts +7 -0
  11. package/dist/commands/auth.js +97 -0
  12. package/dist/commands/browsers.d.ts +17 -0
  13. package/dist/commands/browsers.js +103 -0
  14. package/dist/commands/completion.d.ts +17 -0
  15. package/dist/commands/completion.js +143 -0
  16. package/dist/commands/config.d.ts +3 -0
  17. package/dist/commands/config.js +79 -0
  18. package/dist/commands/coverage.d.ts +3 -0
  19. package/dist/commands/coverage.js +65 -0
  20. package/dist/commands/data.d.ts +7 -0
  21. package/dist/commands/data.js +188 -0
  22. package/dist/commands/db.d.ts +3 -0
  23. package/dist/commands/db.js +241 -0
  24. package/dist/commands/doctor.d.ts +3 -0
  25. package/dist/commands/doctor.js +323 -0
  26. package/dist/commands/env.d.ts +3 -0
  27. package/dist/commands/env.js +65 -0
  28. package/dist/commands/features.d.ts +3 -0
  29. package/dist/commands/features.js +57 -0
  30. package/dist/commands/feedback.d.ts +7 -0
  31. package/dist/commands/feedback.js +96 -0
  32. package/dist/commands/har.d.ts +3 -0
  33. package/dist/commands/har.js +124 -0
  34. package/dist/commands/heal.d.ts +4 -0
  35. package/dist/commands/heal.js +63 -0
  36. package/dist/commands/init.d.ts +38 -0
  37. package/dist/commands/init.js +363 -0
  38. package/dist/commands/insights.d.ts +7 -0
  39. package/dist/commands/insights.js +127 -0
  40. package/dist/commands/integrations.d.ts +14 -0
  41. package/dist/commands/integrations.js +306 -0
  42. package/dist/commands/lint.d.ts +3 -0
  43. package/dist/commands/lint.js +74 -0
  44. package/dist/commands/mcp.d.ts +3 -0
  45. package/dist/commands/mcp.js +190 -0
  46. package/dist/commands/project.d.ts +3 -0
  47. package/dist/commands/project.js +133 -0
  48. package/dist/commands/proposals.d.ts +3 -0
  49. package/dist/commands/proposals.js +106 -0
  50. package/dist/commands/record.d.ts +3 -0
  51. package/dist/commands/record.js +174 -0
  52. package/dist/commands/report.d.ts +3 -0
  53. package/dist/commands/report.js +255 -0
  54. package/dist/commands/run.d.ts +41 -0
  55. package/dist/commands/run.js +369 -0
  56. package/dist/commands/schedule.d.ts +3 -0
  57. package/dist/commands/schedule.js +337 -0
  58. package/dist/commands/serve.d.ts +3 -0
  59. package/dist/commands/serve.js +38 -0
  60. package/dist/commands/steps.d.ts +8 -0
  61. package/dist/commands/steps.js +67 -0
  62. package/dist/commands/tokens.d.ts +3 -0
  63. package/dist/commands/tokens.js +110 -0
  64. package/dist/commands/trace.d.ts +10 -0
  65. package/dist/commands/trace.js +104 -0
  66. package/dist/commands/upgrade.d.ts +14 -0
  67. package/dist/commands/upgrade.js +115 -0
  68. package/dist/commands/users.d.ts +3 -0
  69. package/dist/commands/users.js +183 -0
  70. package/dist/commands/watch.d.ts +7 -0
  71. package/dist/commands/watch.js +116 -0
  72. package/dist/commands/workspace.d.ts +8 -0
  73. package/dist/commands/workspace.js +83 -0
  74. package/dist/context.d.ts +17 -0
  75. package/dist/context.js +22 -0
  76. package/dist/index.d.ts +3 -0
  77. package/dist/index.js +3 -0
  78. package/dist/program.d.ts +7 -0
  79. package/dist/program.js +90 -0
  80. package/dist/templates/project.d.ts +10 -0
  81. package/dist/templates/project.js +115 -0
  82. package/dist/ui.d.ts +16 -0
  83. package/dist/ui.js +67 -0
  84. package/package.json +61 -0
  85. package/templates/.claude/skills/sdods-record/SKILL.md +75 -0
  86. package/templates/.claude/skills/sdods-run/SKILL.md +54 -0
  87. package/templates/.env.example +62 -0
  88. package/templates/docker-compose.yml +40 -0
  89. package/templates/projects/demo-shop/.env.example +4 -0
  90. package/templates/projects/demo-shop/data/common/posts.yaml +11 -0
  91. package/templates/projects/demo-shop/data/common/products.json +8 -0
  92. package/templates/projects/demo-shop/data/common/users.csv +7 -0
  93. package/templates/projects/demo-shop/data/factories.ts +16 -0
  94. package/templates/projects/demo-shop/data/staging/users.csv +6 -0
  95. package/templates/projects/demo-shop/envs/local.yaml +17 -0
  96. package/templates/projects/demo-shop/envs/staging.yaml +23 -0
  97. package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--chromium/darwin/inventory.png +0 -0
  98. package/templates/projects/demo-shop/features/api/posts.feature +90 -0
  99. package/templates/projects/demo-shop/features/auth/data-driven-login.feature +17 -0
  100. package/templates/projects/demo-shop/features/auth/login.feature +32 -0
  101. package/templates/projects/demo-shop/features/auth/user-pool.feature +20 -0
  102. package/templates/projects/demo-shop/features/cart/cart.feature +33 -0
  103. package/templates/projects/demo-shop/features/hybrid/seed-then-verify.feature +28 -0
  104. package/templates/projects/demo-shop/features/inventory/inventory.feature +38 -0
  105. package/templates/projects/demo-shop/features/inventory/network-intercept.feature +29 -0
  106. package/templates/projects/demo-shop/features/inventory/visual.feature +10 -0
  107. package/templates/projects/demo-shop/har/staging/cart.har +1 -0
  108. package/templates/projects/demo-shop/har/staging/cart.har.json +11 -0
  109. package/templates/projects/demo-shop/har/staging/login.har +1 -0
  110. package/templates/projects/demo-shop/har/staging/login.har.json +11 -0
  111. package/templates/projects/demo-shop/har/staging/posts.api.har +2061 -0
  112. package/templates/projects/demo-shop/har/staging/posts.har.json +8 -0
  113. package/templates/projects/demo-shop/har/staging/products.har +1 -0
  114. package/templates/projects/demo-shop/har/staging/products.har.json +11 -0
  115. package/templates/projects/demo-shop/har/staging/site.api.har +148 -0
  116. package/templates/projects/demo-shop/har/staging/site.har +1 -0
  117. package/templates/projects/demo-shop/har/staging/site.har.json +11 -0
  118. package/templates/projects/demo-shop/pages/CartPage.ts +20 -0
  119. package/templates/projects/demo-shop/pages/InventoryPage.ts +103 -0
  120. package/templates/projects/demo-shop/pages/LoginPage.ts +42 -0
  121. package/templates/projects/demo-shop/schemas/post.schema.json +13 -0
  122. package/templates/projects/demo-shop/sdods.project.yaml +185 -0
  123. package/templates/projects/demo-shop/steps/auth.ts +7 -0
  124. package/templates/projects/demo-shop/steps/fixtures.ts +39 -0
@@ -0,0 +1,306 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import pc from 'picocolors';
4
+ import { SdodsError, DEFAULT_ARTIFACTS_DIR, Logger } from '@sdods/core';
5
+ import { createContext } from '../context.js';
6
+ import { json, ok, out, table, warn } from '../ui.js';
7
+ const LINKS_FILE = ['.sdods', 'issue-links.json'];
8
+ /** `sdods integrations test|notify|sync|links` — GitHub and Jira without the server or a database. */
9
+ export function register(program) {
10
+ const cmd = program
11
+ .command('integrations')
12
+ .description('GitHub and Jira: test connectivity, notify runs, sync issue links');
13
+ cmd
14
+ .command('test')
15
+ .description('Check credentials and reachability for each enabled provider')
16
+ .requiredOption('-p, --project <slug>', 'project slug')
17
+ .option('--provider <name>', 'only this provider (github|jira)')
18
+ .action(async (opts, c) => {
19
+ const ctx = createContext(c);
20
+ const entry = ctx.registry.entry(opts.project);
21
+ const { getProviders } = await import('@sdods/integrations');
22
+ const providers = await getProviders(entry.config, {
23
+ only: opts.provider ? [opts.provider] : undefined,
24
+ projectRoot: entry.root,
25
+ });
26
+ const rows = [];
27
+ for (const p of providers) {
28
+ if (!p.enabled) {
29
+ rows.push({
30
+ provider: p.name,
31
+ enabled: false,
32
+ ok: '-',
33
+ detail: 'disabled in sdods.project.yaml',
34
+ secrets: fmtSecrets(p.secrets),
35
+ });
36
+ continue;
37
+ }
38
+ if (p.initError) {
39
+ rows.push({
40
+ provider: p.name,
41
+ enabled: true,
42
+ ok: false,
43
+ detail: p.initError,
44
+ secrets: fmtSecrets(p.secrets),
45
+ });
46
+ continue;
47
+ }
48
+ const res = await p.provider.test();
49
+ rows.push({
50
+ provider: p.name,
51
+ enabled: true,
52
+ ok: res.ok,
53
+ detail: res.detail,
54
+ secrets: fmtSecrets(p.secrets),
55
+ });
56
+ }
57
+ if (ctx.opts.json)
58
+ return json(rows);
59
+ if (!rows.length)
60
+ return warn('No integrations configured for this project.');
61
+ table(rows.map((r) => ({
62
+ ...r,
63
+ ok: r.ok === true ? pc.green('ok') : r.ok === false ? pc.red('FAIL') : pc.dim('-'),
64
+ })));
65
+ if (rows.some((r) => r.ok === false))
66
+ process.exitCode = 1;
67
+ });
68
+ cmd
69
+ .command('notify')
70
+ .description('Publish a run to enabled providers: check runs, PR comment, issues (deduplicated)')
71
+ .requiredOption('--run-id <id>', 'run id (directory under the artifacts dir)')
72
+ .option('-p, --project <slug>', 'project slug (default: from run.json)')
73
+ .option('--from-files', 'build the summary from the run directory (default until DB ingest lands)')
74
+ .option('--provider <name>', 'only this provider')
75
+ .option('--dry-run', 'print what would be created without calling any API')
76
+ .option('--report-url <url>', 'public report URL to include in comments and issues')
77
+ .option('--artifacts-dir <dir>', 'artifacts root', DEFAULT_ARTIFACTS_DIR)
78
+ .action(async (opts, c) => {
79
+ const ctx = createContext(c);
80
+ const { buildRunSummaryFromFiles, getProviders, createIntegrationContext, FileIssueLinkStore, } = await import('@sdods/integrations');
81
+ const artifactsRoot = join(ctx.rootDir, opts.artifactsDir);
82
+ const runDir = join(artifactsRoot, opts.runId);
83
+ if (!existsSync(runDir)) {
84
+ throw new SdodsError('RUN_FAILED', `Run directory not found: ${runDir}`, {
85
+ hint: 'Pass --artifacts-dir or check the run id (sdods report --last).',
86
+ exitCode: 2,
87
+ });
88
+ }
89
+ const summary = buildRunSummaryFromFiles(runDir, {
90
+ projectSlug: opts.project,
91
+ reportUrl: opts.reportUrl,
92
+ });
93
+ const slug = summary.run.projectSlug;
94
+ const entry = ctx.registry.entry(slug);
95
+ const providers = await getProviders(entry.config, {
96
+ only: opts.provider ? [opts.provider] : undefined,
97
+ projectRoot: entry.root,
98
+ });
99
+ const ictx = createIntegrationContext({
100
+ store: new FileIssueLinkStore(join(ctx.rootDir, ...LINKS_FILE)),
101
+ logger: new Logger('integrations'),
102
+ dryRun: Boolean(opts.dryRun),
103
+ artifactsRoot,
104
+ });
105
+ const results = [];
106
+ for (const p of providers) {
107
+ if (!p.enabled)
108
+ continue;
109
+ if (p.initError) {
110
+ results.push({ provider: p.name, kind: 'skipped', detail: p.initError });
111
+ continue;
112
+ }
113
+ try {
114
+ const res = await p.provider.onRunFinished(summary, ictx);
115
+ results.push(...res.actions);
116
+ }
117
+ catch (e) {
118
+ results.push({ provider: p.name, kind: 'error', detail: e.message });
119
+ }
120
+ }
121
+ if (ctx.opts.json)
122
+ return json({ run: summary.run, totals: summary.totals, actions: results });
123
+ const t = summary.totals;
124
+ out(pc.bold(`Run ${summary.run.id} · ${slug} · ${summary.run.env} · ${t.passed}/${t.total} passed, ${t.failed} failed, ${t.flaky} flaky`) + (opts.dryRun ? pc.yellow(' [dry-run]') : ''));
125
+ if (!results.length)
126
+ return warn('No enabled providers produced actions.');
127
+ table(results.map((r) => ({
128
+ provider: r.provider,
129
+ action: r.kind,
130
+ target: r.target ?? '',
131
+ url: r.url ?? '',
132
+ detail: r.detail ?? '',
133
+ })));
134
+ if (results.some((r) => r.kind === 'error'))
135
+ process.exitCode = 1;
136
+ });
137
+ cmd
138
+ .command('sync')
139
+ .description('Scan features for @jira:KEY / @github:N tags, create links, and refresh statuses')
140
+ .requiredOption('-p, --project <slug>', 'project slug')
141
+ .option('--provider <name>', 'only this provider')
142
+ .action(async (opts, c) => {
143
+ const ctx = createContext(c);
144
+ const entry = ctx.registry.entry(opts.project);
145
+ const { getProviders, FileIssueLinkStore } = await import('@sdods/integrations');
146
+ const { fingerprint } = await import('@sdods/contracts');
147
+ const store = new FileIssueLinkStore(join(ctx.rootDir, ...LINKS_FILE));
148
+ const providers = await getProviders(entry.config, {
149
+ only: opts.provider ? [opts.provider] : undefined,
150
+ projectRoot: entry.root,
151
+ });
152
+ const tagged = scanFeatureTags(join(entry.root, 'features'), entry.root);
153
+ const rows = [];
154
+ for (const p of providers) {
155
+ if (!p.enabled)
156
+ continue;
157
+ if (p.initError) {
158
+ warn(`${p.name}: ${p.initError}`);
159
+ continue;
160
+ }
161
+ const tagName = p.name === 'jira' || p.name === 'github' ? p.name : null;
162
+ if (tagName) {
163
+ for (const t of tagged.filter((x) => x.tag === tagName)) {
164
+ const fp = fingerprint({
165
+ project: entry.slug,
166
+ featureUri: t.featureUri,
167
+ scenarioName: t.scenarioName,
168
+ exampleIndex: null,
169
+ layer: t.layer,
170
+ });
171
+ if (await store.findByKey(entry.slug, p.name, t.key))
172
+ continue;
173
+ try {
174
+ const ref = await p.provider.linkIssue(fp, t.key);
175
+ await store.save({
176
+ projectSlug: entry.slug,
177
+ provider: p.name,
178
+ fingerprint: fp,
179
+ scenarioName: t.scenarioName,
180
+ externalKey: ref.key,
181
+ externalUrl: ref.url,
182
+ status: ref.status,
183
+ source: 'tag',
184
+ lastSyncedAt: new Date().toISOString(),
185
+ });
186
+ }
187
+ catch (e) {
188
+ warn(`${p.name}: cannot link ${t.key}: ${e.message}`);
189
+ }
190
+ }
191
+ }
192
+ const links = await store.list(entry.slug, p.name);
193
+ const refs = await p.provider.syncStatuses(links);
194
+ for (const ref of refs) {
195
+ const link = links.find((l) => l.externalKey === ref.key);
196
+ if (!link)
197
+ continue;
198
+ await store.save({
199
+ ...link,
200
+ status: ref.status,
201
+ closedAt: ref.status === 'closed' && link.status !== 'closed'
202
+ ? new Date().toISOString()
203
+ : link.closedAt,
204
+ lastSyncedAt: new Date().toISOString(),
205
+ });
206
+ rows.push({
207
+ provider: p.name,
208
+ key: ref.key,
209
+ status: ref.status,
210
+ scenario: link.scenarioName,
211
+ source: link.source,
212
+ url: ref.url,
213
+ });
214
+ }
215
+ }
216
+ if (ctx.opts.json)
217
+ return json(rows);
218
+ if (!rows.length)
219
+ return ok('Nothing to sync (no enabled providers or no links).');
220
+ table(rows);
221
+ });
222
+ cmd
223
+ .command('links')
224
+ .description('List known issue links for a project')
225
+ .requiredOption('-p, --project <slug>', 'project slug')
226
+ .option('--provider <name>', 'only this provider')
227
+ .action(async (opts, c) => {
228
+ const ctx = createContext(c);
229
+ ctx.registry.entry(opts.project);
230
+ const { FileIssueLinkStore } = await import('@sdods/integrations');
231
+ const store = new FileIssueLinkStore(join(ctx.rootDir, ...LINKS_FILE));
232
+ const links = await store.list(opts.project, opts.provider);
233
+ if (ctx.opts.json)
234
+ return json(links);
235
+ table(links.map((l) => ({
236
+ provider: l.provider,
237
+ key: l.externalKey,
238
+ status: l.status,
239
+ source: l.source,
240
+ scenario: l.scenarioName,
241
+ lastRun: l.lastRunId ?? '',
242
+ synced: l.lastSyncedAt ?? '',
243
+ })));
244
+ });
245
+ }
246
+ function fmtSecrets(s) {
247
+ return Object.entries(s)
248
+ .map(([k, v]) => `${k}=${v ? pc.green('set') : pc.red('missing')}`)
249
+ .join(' ');
250
+ }
251
+ /** Lightweight feature scan for issue tags (feature-level tags inherit to scenarios). */
252
+ export function scanFeatureTags(featuresDir, projectRoot) {
253
+ const out = [];
254
+ if (!existsSync(featuresDir))
255
+ return out;
256
+ const files = [];
257
+ const walk = (dir) => {
258
+ for (const name of readdirSync(dir).sort()) {
259
+ const p = join(dir, name);
260
+ if (statSync(p).isDirectory())
261
+ walk(p);
262
+ else if (name.endsWith('.feature'))
263
+ files.push(p);
264
+ }
265
+ };
266
+ walk(featuresDir);
267
+ for (const file of files) {
268
+ const featureUri = file.slice(projectRoot.length + 1).replace(/\\/g, '/');
269
+ let featureTags = [];
270
+ let pending = [];
271
+ for (const raw of readFileSync(file, 'utf8').split('\n')) {
272
+ const line = raw.trim();
273
+ if (line.startsWith('@')) {
274
+ pending.push(...line.split(/\s+/).filter((t) => t.startsWith('@')));
275
+ continue;
276
+ }
277
+ if (/^Feature:/.test(line)) {
278
+ featureTags = pending;
279
+ pending = [];
280
+ continue;
281
+ }
282
+ const m = /^(?:Scenario(?: Outline| Template)?|Example):\s*(.+)$/.exec(line);
283
+ if (!m)
284
+ continue;
285
+ const tags = [...featureTags, ...pending];
286
+ pending = [];
287
+ const layer = tags.includes('@api') ? 'api' : tags.includes('@hybrid') ? 'hybrid' : 'ui';
288
+ for (const t of tags) {
289
+ const jira = /^@jira:([A-Z][A-Z0-9]+-\d+)$/i.exec(t);
290
+ const gh = /^@github:(\d+)$/.exec(t);
291
+ if (jira)
292
+ out.push({
293
+ tag: 'jira',
294
+ key: jira[1].toUpperCase(),
295
+ featureUri,
296
+ scenarioName: m[1].trim(),
297
+ layer,
298
+ });
299
+ if (gh)
300
+ out.push({ tag: 'github', key: gh[1], featureUri, scenarioName: m[1].trim(), layer });
301
+ }
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+ //# sourceMappingURL=integrations.js.map
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function register(program: Command): void;
3
+ //# sourceMappingURL=lint.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve as resolvePath } from 'node:path';
3
+ import { formatFindings, lintProject } from '@sdods/core';
4
+ import { createContext } from '../context.js';
5
+ import { collect, json, ok, out } from '../ui.js';
6
+ export function register(program) {
7
+ program
8
+ .command('lint [paths...]')
9
+ .description('Validate feature files: Gherkin syntax, tag taxonomy, modules, undefined steps')
10
+ .option('-p, --project <slug>', 'project slug (default: all projects)')
11
+ .option('--undefined-steps', 'also detect undefined steps via bddgen (slower)')
12
+ .option('--fix-tags', 'insert the layer/module tag suggested by a finding')
13
+ .option('--strict', 'treat warnings as errors')
14
+ .option('--file <path>', 'lint only this feature file (repeatable)', collect, [])
15
+ .action(async (paths, opts, cmd) => {
16
+ const ctx = createContext(cmd);
17
+ const entries = opts.project
18
+ ? [ctx.registry.entry(opts.project)]
19
+ : ctx.registry.entriesList();
20
+ const all = { errors: [], warnings: [], filesChecked: 0 };
21
+ for (const e of entries) {
22
+ const cfg = ctx.registry.resolve(e.slug);
23
+ const files = [...paths, ...opts.file].map((f) => resolvePath(f));
24
+ const result = await lintProject({
25
+ project: cfg.project,
26
+ files: files.length ? files : undefined,
27
+ undefinedSteps: opts.undefinedSteps,
28
+ repoRoot: ctx.rootDir,
29
+ });
30
+ if (opts.fixTags)
31
+ applyTagFixes(cfg.project.root, result);
32
+ all.errors.push(...result.errors.map((x) => ({ ...x, project: e.slug })));
33
+ all.warnings.push(...result.warnings.map((x) => ({ ...x, project: e.slug })));
34
+ all.filesChecked += result.filesChecked;
35
+ }
36
+ const failed = all.errors.length > 0 || (opts.strict && all.warnings.length > 0);
37
+ if (ctx.opts.json)
38
+ json({ ok: !failed, ...all });
39
+ else if (all.errors.length || all.warnings.length)
40
+ out(formatFindings(all));
41
+ else
42
+ ok(`${all.filesChecked} feature file(s) linted, no findings`);
43
+ if (failed)
44
+ process.exitCode = 3;
45
+ });
46
+ }
47
+ function applyTagFixes(projectRoot, result) {
48
+ const byFile = new Map();
49
+ for (const f of [...result.errors, ...result.warnings]) {
50
+ if (!f.fix?.insertTag || !f.line)
51
+ continue;
52
+ const list = byFile.get(f.file) ?? [];
53
+ list.push({ line: f.line, tag: f.fix.insertTag });
54
+ byFile.set(f.file, list);
55
+ }
56
+ for (const [rel, fixes] of byFile) {
57
+ const file = join(projectRoot, rel);
58
+ const lines = readFileSync(file, 'utf8').split('\n');
59
+ for (const { line, tag } of fixes.sort((a, b) => b.line - a.line)) {
60
+ // tags live on the line above the Scenario keyword
61
+ const idx = line - 2;
62
+ if (idx >= 0 && lines[idx].trim().startsWith('@')) {
63
+ if (!lines[idx].includes(tag))
64
+ lines[idx] = `${lines[idx]} ${tag}`;
65
+ }
66
+ else {
67
+ const indent = /^\s*/.exec(lines[line - 1] ?? '')?.[0] ?? '';
68
+ lines.splice(line - 1, 0, `${indent}${tag}`);
69
+ }
70
+ }
71
+ writeFileSync(file, lines.join('\n'));
72
+ }
73
+ }
74
+ //# sourceMappingURL=lint.js.map
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function register(program: Command): void;
3
+ //# sourceMappingURL=mcp.d.ts.map
@@ -0,0 +1,190 @@
1
+ import pc from 'picocolors';
2
+ import { SdodsError } from '@sdods/core';
3
+ import { createContext } from '../context.js';
4
+ import { json, ok, out } from '../ui.js';
5
+ const CLIENTS = ['claude', 'codex', 'cursor', 'vscode', 'windsurf'];
6
+ function capsOf(raw) {
7
+ if (!raw || raw === 'all')
8
+ return raw === 'all' ? 'all' : ['core', 'analyze', 'run', 'data', 'schedules'];
9
+ return raw
10
+ .split(',')
11
+ .map((s) => s.trim())
12
+ .filter(Boolean);
13
+ }
14
+ export function register(program) {
15
+ const mcp = program
16
+ .command('mcp')
17
+ .description('Start the SDODS MCP server (stdio by default) or install client configuration')
18
+ .option('-p, --project <slug>', 'default project for tools and prompts')
19
+ .option('-e, --env <name>', 'default environment')
20
+ .option('--caps <list>', 'capabilities: core,analyze,run,data,agents,issues,schedules or "all"', 'core,analyze,run,data,schedules')
21
+ .option('--http', 'serve streamable HTTP instead of stdio')
22
+ .option('--port <n>', 'HTTP port', '4001')
23
+ .option('--host <host>', 'HTTP host', '127.0.0.1')
24
+ .option('--token <token>', 'HTTP: accept this single bearer token (dev only; production uses `sdods tokens`)')
25
+ .option('--list-tools', 'print the tool catalogue and exit')
26
+ .action(async (opts, cmd) => {
27
+ const ctx = createContext(cmd);
28
+ const { serveSdodsStdio, serveSdodsHttp, createRegistry, buildToolContext, ALL_CAPABILITIES, } = await import('@sdods/mcp');
29
+ const caps = capsOf(opts.caps);
30
+ if (opts.listTools) {
31
+ const registry = createRegistry();
32
+ const tctx = buildToolContext({ rootDir: ctx.rootDir, caps });
33
+ const rows = registry.list(tctx).map((t) => ({
34
+ name: t.name,
35
+ access: t.access,
36
+ capability: t.capability,
37
+ description: t.description,
38
+ }));
39
+ if (ctx.opts.json)
40
+ return json(rows);
41
+ for (const r of rows)
42
+ out(`${pc.bold(r.name.padEnd(28))} ${pc.dim(r.capability.padEnd(9))} ${pc.dim(r.access.padEnd(5))} ${r.description}`);
43
+ out(pc.dim(`\n${rows.length} tools · capabilities: ${ALL_CAPABILITIES.join(', ')}`));
44
+ return;
45
+ }
46
+ if (opts.http) {
47
+ const token = opts.token ?? process.env.SDODS_MCP_TOKEN;
48
+ if (!token) {
49
+ throw new SdodsError('AUTH_FAILED', 'HTTP mode needs a bearer token.', {
50
+ hint: 'Pass --token <secret> (dev) or set SDODS_MCP_TOKEN. Production deployments use scoped tokens from `sdods tokens create` via the web server.',
51
+ exitCode: 2,
52
+ });
53
+ }
54
+ const srv = await serveSdodsHttp({
55
+ rootDir: ctx.rootDir,
56
+ project: opts.project,
57
+ env: opts.env,
58
+ caps,
59
+ port: Number(opts.port),
60
+ host: opts.host,
61
+ authenticate: (t) => (t === token ? { name: 'token', scopes: ['*'], via: 'http' } : null),
62
+ });
63
+ ok(`SDODS MCP over HTTP at http://${opts.host}:${srv.port}/mcp (Ctrl+C to stop)`);
64
+ await new Promise((resolve) => {
65
+ process.once('SIGINT', () => srv.close().then(resolve));
66
+ process.once('SIGTERM', () => srv.close().then(resolve));
67
+ });
68
+ return;
69
+ }
70
+ // stdio: stdout carries protocol messages only; logs go to stderr
71
+ serveSdodsStdio({ rootDir: ctx.rootDir, project: opts.project, env: opts.env, caps });
72
+ await new Promise(() => undefined);
73
+ });
74
+ mcp
75
+ .command('install <client>')
76
+ .description('Register the SDODS MCP server with a client: claude | codex | cursor | vscode | windsurf (merges, never clobbers)')
77
+ .option('-p, --project <slug>')
78
+ .option('-e, --env <name>')
79
+ .option('--caps <list>')
80
+ .option('--http-url <url>', 'configure the HTTP transport instead of stdio')
81
+ .option('--token-placeholder <text>', 'placeholder written for the bearer token')
82
+ .option('--no-playwright', 'do not add the bundled Playwright MCP server')
83
+ .option('--file', 'always write the config file instead of calling `claude mcp add` / `codex mcp add`')
84
+ .option('--print', 'print the snippet instead of writing files')
85
+ .action(async (client, opts, cmd) => {
86
+ const ctx = createContext(cmd);
87
+ const { installClientConfig, snippets, cliCommands } = await import('@sdods/mcp');
88
+ if (!CLIENTS.includes(client)) {
89
+ throw new SdodsError('NOT_SUPPORTED', `Unknown client "${client}".`, {
90
+ hint: `Use one of ${CLIENTS.join(', ')}.`,
91
+ exitCode: 2,
92
+ });
93
+ }
94
+ // commander also accepts `-p/-e/--caps` on the parent `mcp` command; honour both
95
+ const parent = mcp.opts();
96
+ const o = {
97
+ project: opts.project ?? parent.project,
98
+ env: opts.env ?? parent.env,
99
+ caps: opts.caps ??
100
+ (parent.caps !== 'core,analyze,run,data,schedules' ? parent.caps : undefined),
101
+ httpUrl: opts.httpUrl,
102
+ tokenPlaceholder: opts.tokenPlaceholder,
103
+ withPlaywright: opts.playwright !== false,
104
+ };
105
+ const s = snippets(o)[client];
106
+ if (opts.print || ctx.opts.json) {
107
+ if (ctx.opts.json)
108
+ return json({ file: s.file, config: s.json ?? s.toml, cli: s.cli });
109
+ out(pc.bold(s.file));
110
+ out(s.json ? JSON.stringify(s.json, null, 2) : (s.toml ?? ''));
111
+ if (s.cli)
112
+ out(`\n${pc.dim('or:')} ${s.cli}`);
113
+ return;
114
+ }
115
+ // Prefer the client's own CLI so its config format stays authoritative.
116
+ if (!opts.file && (client === 'claude' || client === 'codex')) {
117
+ const done = await registerViaClientCli(client, o, ctx.rootDir);
118
+ if (done) {
119
+ ok(`Registered the sdods MCP server with ${client} (${cliCommands(o)[client]})`);
120
+ if (o.withPlaywright && client === 'codex')
121
+ out(pc.dim('Also registered the bundled Playwright MCP server as "playwright".'));
122
+ return;
123
+ }
124
+ }
125
+ const r = installClientConfig(ctx.rootDir, client, o);
126
+ ok(`${r.created ? 'Created' : 'Updated'} ${r.file}`);
127
+ if (s.cli)
128
+ out(pc.dim(`Alternative: ${s.cli}`));
129
+ });
130
+ }
131
+ /** `claude mcp add` (project scope) or `codex mcp add`; false when the CLI is unavailable. */
132
+ async function registerViaClientCli(client, o, cwd) {
133
+ const { execa } = await import('execa');
134
+ const stdio = ['npx', 'sdods', 'mcp'];
135
+ if (o.project)
136
+ stdio.push('--project', o.project);
137
+ if (o.env)
138
+ stdio.push('--env', o.env);
139
+ if (o.caps)
140
+ stdio.push('--caps', o.caps);
141
+ const run = async (args) => {
142
+ try {
143
+ await execa(client, args, { cwd, timeout: 30_000 });
144
+ return true;
145
+ }
146
+ catch (e) {
147
+ const msg = String(e.stderr ?? e.message);
148
+ if (/ENOENT|not found/i.test(msg))
149
+ return false;
150
+ // already registered → remove and re-add so the args are current
151
+ if (/already exists/i.test(msg)) {
152
+ await execa(client, ['mcp', 'remove', args[2] ?? 'sdods'], { cwd, reject: false });
153
+ await execa(client, args, { cwd, timeout: 30_000 });
154
+ return true;
155
+ }
156
+ throw new SdodsError('NOT_SUPPORTED', `${client} mcp add failed: ${msg.trim()}`, {
157
+ hint: 'Re-run with --file to write the config file directly.',
158
+ });
159
+ }
160
+ };
161
+ const addArgs = client === 'claude'
162
+ ? o.httpUrl
163
+ ? ['mcp', 'add', '-s', 'project', '--transport', 'http', 'sdods', o.httpUrl]
164
+ : ['mcp', 'add', '-s', 'project', 'sdods', '--', ...stdio]
165
+ : o.httpUrl
166
+ ? ['mcp', 'add', 'sdods', '--url', o.httpUrl]
167
+ : ['mcp', 'add', 'sdods', '--', ...stdio];
168
+ const ok1 = await run(addArgs);
169
+ if (!ok1)
170
+ return false;
171
+ if (o.withPlaywright !== false) {
172
+ const pw = client === 'claude'
173
+ ? [
174
+ 'mcp',
175
+ 'add',
176
+ '-s',
177
+ 'project',
178
+ 'playwright',
179
+ '--',
180
+ 'npx',
181
+ 'playwright',
182
+ 'mcp',
183
+ '--headless',
184
+ ]
185
+ : ['mcp', 'add', 'playwright', '--', 'npx', 'playwright', 'mcp', '--headless'];
186
+ await run(pw).catch(() => false);
187
+ }
188
+ return true;
189
+ }
190
+ //# sourceMappingURL=mcp.js.map
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerProjectCommands(program: Command): void;
3
+ //# sourceMappingURL=project.d.ts.map