@sdods/cli 0.18.0 → 0.20.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.
@@ -36,7 +36,7 @@ export function register(program) {
36
36
  .option('--run-id <id>', 'run id (heal), default last', 'last')
37
37
  .option('--diff <range>', 'git range (upgrade), e.g. main..HEAD')
38
38
  .option('--files <list>', 'feature files (review)', collect, [])
39
- .option('--adapter <name>', 'claude (API key) | claude-code (logged-in Claude Code CLI) | codex (logged-in Codex CLI) | openai | ollama (a model on this machine) | fake; default: auto-detect')
39
+ .option('--adapter <name>', 'claude (API key) | claude-code (logged-in Claude Code CLI) | codex (logged-in Codex CLI) | copilot (logged-in GitHub Copilot CLI) | openai | ollama (a model on this machine) | fake; default: auto-detect')
40
40
  .option('--model <id>', 'model override')
41
41
  .option('--profile <name>', 'auto | full | small — how many tools and how much prompt the model is given')
42
42
  .option('--browser', 'attach the browser MCP server even under the small profile (which omits it)')
@@ -162,13 +162,13 @@ export function register(program) {
162
162
  const installAction = async (opts, cmd, forced) => {
163
163
  const ctx = createContext(cmd);
164
164
  const target = (forced ?? opts.for ?? 'all');
165
- if (!['claude', 'codex', 'all'].includes(target)) {
165
+ if (!['claude', 'codex', 'copilot', 'all'].includes(target)) {
166
166
  throw new SdodsError('NOT_SUPPORTED', `Unknown target "${target}".`, {
167
- hint: 'Use --for claude, --for codex or --for all.',
167
+ hint: 'Use --for claude, --for codex, --for copilot or --for all.',
168
168
  exitCode: 2,
169
169
  });
170
170
  }
171
- const { installCodingAgents } = await import('@sdods/agents');
171
+ const { installCodingAgents, findOnPath } = await import('@sdods/agents');
172
172
  const written = installCodingAgents(ctx.rootDir, {
173
173
  for: target,
174
174
  project: opts.project,
@@ -177,7 +177,11 @@ export function register(program) {
177
177
  });
178
178
  if (opts.mcp !== false) {
179
179
  const { execa } = await import('execa');
180
- const clients = target === 'all' ? ['claude', 'codex'] : [target];
180
+ // `all` registers with Copilot only when it is installed: its config lives in the home
181
+ // directory, and writing one for someone who does not use Copilot would be clutter.
182
+ const clients = target === 'all'
183
+ ? ['claude', 'codex', ...(findOnPath('copilot') ? ['copilot'] : [])]
184
+ : [target];
181
185
  for (const client of clients) {
182
186
  const args = ['mcp', 'install', client];
183
187
  if (opts.project)
@@ -200,8 +204,8 @@ export function register(program) {
200
204
  };
201
205
  agent
202
206
  .command('install')
203
- .description('Set up coding-agent CLIs: --for claude writes .claude/agents/sdods-*.md + CLAUDE.md, --for codex writes AGENTS.md; both get AGENT.md/SKILL.md and the MCP registration')
204
- .option('--for <client>', 'claude | codex | all', 'all')
207
+ .description('Set up coding-agent CLIs: --for claude writes .claude/agents/sdods-*.md + CLAUDE.md, --for codex writes AGENTS.md, --for copilot writes .github/agents/sdods-*.agent.md + AGENTS.md; all get AGENT.md/SKILL.md and the MCP registration')
208
+ .option('--for <client>', 'claude | codex | copilot | all', 'all')
205
209
  .option('-p, --project <slug>')
206
210
  .option('-e, --env <name>')
207
211
  .option('--force', 'overwrite existing files')
@@ -173,6 +173,7 @@ function tokenMatrix(cliStatus, localLlm) {
173
173
  const has = (k) => Boolean(process.env[k]);
174
174
  const claudeOk = cliStatus.find((c) => c.name === 'cli:claude')?.ok ?? false;
175
175
  const codexOk = cliStatus.find((c) => c.name === 'cli:codex')?.ok ?? false;
176
+ const copilotOk = cliStatus.find((c) => c.name === 'cli:copilot')?.ok ?? false;
176
177
  const agentsGroup = [
177
178
  {
178
179
  name: 'ANTHROPIC_API_KEY',
@@ -192,6 +193,12 @@ function tokenMatrix(cliStatus, localLlm) {
192
193
  detail: 'adapter codex: uses your Codex login (ChatGPT account)',
193
194
  hint: '`npm i -g @openai/codex && codex login`',
194
195
  },
196
+ {
197
+ name: 'copilot CLI login',
198
+ present: copilotOk,
199
+ detail: 'adapter copilot: uses your GitHub Copilot subscription',
200
+ hint: '`npm i -g @github/copilot && copilot login` (or COPILOT_GITHUB_TOKEN)',
201
+ },
195
202
  {
196
203
  name: 'OPENAI_API_KEY (+OPENAI_BASE_URL)',
197
204
  present: has('OPENAI_API_KEY'),
@@ -296,11 +303,11 @@ async function localModelStatus() {
296
303
  return { name, ok: false, optional: true, detail: 'agents package unavailable' };
297
304
  }
298
305
  }
299
- /** `cli:claude` / `cli:codex` rows: installed + logged in. Missing CLIs are not failures. */
306
+ /** `cli:claude` / `cli:codex` / `cli:copilot` rows: installed + logged in. Missing CLIs are not failures. */
300
307
  async function codingCliStatus() {
301
308
  const rows = [];
302
309
  try {
303
- const { ClaudeCodeCliAdapter, CodexCliAdapter } = await import('@sdods/agents');
310
+ const { ClaudeCodeCliAdapter, CodexCliAdapter, CopilotCliAdapter } = await import('@sdods/agents');
304
311
  const c = ClaudeCodeCliAdapter.loginStatus();
305
312
  rows.push({
306
313
  name: 'cli:claude',
@@ -317,6 +324,15 @@ async function codingCliStatus() {
317
324
  detail: x.detail,
318
325
  fix: x.installed ? 'codex login' : 'npm i -g @openai/codex (optional)',
319
326
  });
327
+ const g = CopilotCliAdapter.loginStatus();
328
+ rows.push({
329
+ name: 'cli:copilot',
330
+ // Copilot cannot report its login without a prompt; installed counts until a run says otherwise
331
+ ok: g.installed && g.loggedIn !== false,
332
+ optional: true,
333
+ detail: g.detail,
334
+ fix: g.installed ? 'copilot login' : 'npm i -g @github/copilot (optional)',
335
+ });
320
336
  }
321
337
  catch {
322
338
  rows.push({
@@ -331,6 +347,12 @@ async function codingCliStatus() {
331
347
  optional: true,
332
348
  detail: 'agents package unavailable',
333
349
  });
350
+ rows.push({
351
+ name: 'cli:copilot',
352
+ ok: false,
353
+ optional: true,
354
+ detail: 'agents package unavailable',
355
+ });
334
356
  }
335
357
  return rows;
336
358
  }
@@ -0,0 +1,7 @@
1
+ import type { Command } from 'commander';
2
+ import { planCucumberImport } from '@sdods/core';
3
+ import { type CliContext } from '../context.js';
4
+ /** Every file of the proposed project, keyed by its path from the repository root. */
5
+ export declare function cucumberProjectFiles(ctx: Pick<CliContext, 'rootDir' | 'registry'>, plan: ReturnType<typeof planCucumberImport>, envName: string): Record<string, string>;
6
+ export declare function register(program: Command): void;
7
+ //# sourceMappingURL=import.d.ts.map
@@ -0,0 +1,147 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import pc from 'picocolors';
4
+ import { stringify as toYaml } from 'yaml';
5
+ import { ProjectConfigSchema, SlugSchema } from '@sdods/contracts';
6
+ import { PROJECT_FILE, SdodsError, analyzePlaywrightSuite, planCucumberImport } from '@sdods/core';
7
+ import { createContext } from '../context.js';
8
+ import { projectTemplateFiles } from '../templates/project.js';
9
+ import { json, ok, out } from '../ui.js';
10
+ /**
11
+ * `sdods import cucumber|playwright <dir>`: map an existing suite into an SDODS project, as a
12
+ * proposal a person accepts (docs/adr/0001-import-existing-suites.md). Never writes the tree.
13
+ */
14
+ function sourceDir(dir) {
15
+ const abs = resolve(dir);
16
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
17
+ throw new SdodsError('CONFIG_INVALID', `${dir} is not a directory.`, { exitCode: 2 });
18
+ }
19
+ return abs;
20
+ }
21
+ function checkSlug(ctx, slug) {
22
+ SlugSchema.parse(slug);
23
+ if (existsSync(join(ctx.registry.projectsDir, slug, PROJECT_FILE))) {
24
+ throw new SdodsError('CONFIG_INVALID', `Project "${slug}" already exists.`, {
25
+ hint: 'Pick another --project slug; an import proposes a new project.',
26
+ exitCode: 2,
27
+ });
28
+ }
29
+ return slug;
30
+ }
31
+ /** Every file of the proposed project, keyed by its path from the repository root. */
32
+ export function cucumberProjectFiles(ctx, plan, envName) {
33
+ const config = ProjectConfigSchema.parse({
34
+ slug: plan.slug,
35
+ name: plan.slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
36
+ description: `Imported from a Cucumber-JS suite by sdods import cucumber.`,
37
+ layers: plan.layers,
38
+ browsers: ['chromium'],
39
+ envs: { default: envName, available: [envName] },
40
+ tags: { extra: plan.extraTags },
41
+ });
42
+ const scaffold = projectTemplateFiles({
43
+ config,
44
+ envName,
45
+ uiUrl: 'http://localhost:3000',
46
+ apiUrl: 'http://localhost:3000/api',
47
+ });
48
+ for (const key of Object.keys(scaffold))
49
+ if (key.startsWith('features/'))
50
+ delete scaffold[key];
51
+ const project = {
52
+ ...scaffold,
53
+ [PROJECT_FILE]: `# SDODS project, imported by sdods import cucumber. Reference: https://docs.sdods.com/docs/reference/project-yaml\n${toYaml({
54
+ slug: config.slug,
55
+ name: config.name,
56
+ description: config.description,
57
+ layers: config.layers,
58
+ browsers: config.browsers,
59
+ testIdAttribute: config.testIdAttribute,
60
+ tags: { suites: config.tags.suites, extra: config.tags.extra, roles: config.tags.roles },
61
+ envs: config.envs,
62
+ heal: { enabled: true },
63
+ retries: { ci: 2, local: 0 },
64
+ }, { lineWidth: 100 })}`,
65
+ 'IMPORT-REPORT.md': plan.report,
66
+ };
67
+ for (const f of plan.features)
68
+ project[f.path] = f.content;
69
+ if (plan.stepsFile)
70
+ project['steps/imported.steps.ts'] = plan.stepsFile;
71
+ const base = relative(ctx.rootDir, join(ctx.registry.projectsDir, plan.slug));
72
+ return Object.fromEntries(Object.entries(project).map(([k, v]) => [join(base, k), v]));
73
+ }
74
+ export function register(program) {
75
+ const cmd = program
76
+ .command('import')
77
+ .description('Map an existing Cucumber-JS or Playwright suite into an SDODS project (as a proposal)');
78
+ cmd
79
+ .command('cucumber <dir>')
80
+ .description('Read a Cucumber-JS suite (features, step definitions, cucumber.js) and propose an SDODS project')
81
+ .requiredOption('-p, --project <slug>', 'slug of the new project')
82
+ .option('--env <name>', 'first environment name', 'local')
83
+ .option('--module <name>', 'folder under features/ for the imported features', 'imported')
84
+ .option('--dry-run', 'print the report and write nothing, not even a proposal')
85
+ .action(async (dir, opts, command) => {
86
+ const ctx = createContext(command);
87
+ const slug = checkSlug(ctx, opts.project);
88
+ const plan = planCucumberImport(sourceDir(dir), { slug, module: opts.module });
89
+ if (plan.features.length === 0) {
90
+ throw new SdodsError('CONFIG_INVALID', `No .feature files under ${dir}.`, { exitCode: 2 });
91
+ }
92
+ const files = cucumberProjectFiles(ctx, plan, opts.env);
93
+ if (opts.dryRun) {
94
+ if (ctx.opts.json)
95
+ return json({ plan, files: Object.keys(files) });
96
+ out(plan.report);
97
+ return;
98
+ }
99
+ const { ProposalStore } = await import('@sdods/mcp');
100
+ const manifest = new ProposalStore(ctx.rootDir).create({
101
+ role: 'importer',
102
+ project: slug,
103
+ env: opts.env,
104
+ summary: `Import ${plan.mappings.length} scenario(s) from ${plan.features.length} Cucumber-JS feature file(s) into ${slug}: ${plan.steps.core.length} step(s) reuse the SDODS library, ${plan.steps.stubbed.length} stubbed to port.`,
105
+ files: Object.entries(files).map(([path, content]) => ({
106
+ path,
107
+ content,
108
+ op: 'add',
109
+ })),
110
+ });
111
+ if (ctx.opts.json)
112
+ return json({ proposal: manifest, plan });
113
+ ok(`proposal ${manifest.id}: ${manifest.files.length} file(s) for projects/${slug}`);
114
+ out(pc.dim(` ${plan.mappings.length} scenario(s), ${plan.steps.stubbed.length} step stub(s), ${plan.manual.length} item(s) to port by hand`));
115
+ out(` review: ${pc.cyan(`sdods proposals show ${manifest.id}`)}`);
116
+ out(` accept: ${pc.cyan(`sdods proposals accept ${manifest.id}`)}`);
117
+ });
118
+ cmd
119
+ .command('playwright <dir>')
120
+ .description('Analyse a Playwright Test suite and propose the report (converts nothing yet)')
121
+ .requiredOption('-p, --project <slug>', 'slug the suite would be imported as')
122
+ .option('--dry-run', 'print the report and write nothing')
123
+ .action(async (dir, opts, command) => {
124
+ const ctx = createContext(command);
125
+ const slug = checkSlug(ctx, opts.project);
126
+ const analysis = analyzePlaywrightSuite(sourceDir(dir), slug);
127
+ if (opts.dryRun) {
128
+ if (ctx.opts.json)
129
+ return json(analysis);
130
+ out(analysis.report);
131
+ return;
132
+ }
133
+ const { ProposalStore } = await import('@sdods/mcp');
134
+ const base = relative(ctx.rootDir, join(ctx.registry.projectsDir, slug));
135
+ const manifest = new ProposalStore(ctx.rootDir).create({
136
+ role: 'importer',
137
+ project: slug,
138
+ summary: `Analysis of a Playwright suite (${analysis.specFiles.length} spec file(s), ${analysis.tests} test(s)); converts nothing yet.`,
139
+ files: [{ path: join(base, 'IMPORT-REPORT.md'), content: analysis.report, op: 'add' }],
140
+ });
141
+ if (ctx.opts.json)
142
+ return json({ proposal: manifest, analysis });
143
+ ok(`proposal ${manifest.id}: the analysis report for ${slug}`);
144
+ out(` review: ${pc.cyan(`sdods proposals show ${manifest.id}`)}`);
145
+ });
146
+ }
147
+ //# sourceMappingURL=import.js.map
@@ -81,14 +81,14 @@ export function register(program) {
81
81
  });
82
82
  mcp
83
83
  .command('install <client>')
84
- .description('Register the SDODS MCP server with a client: claude | codex | cursor | vscode | windsurf | gemini (merges, never clobbers)')
84
+ .description('Register the SDODS MCP server with a client: claude | codex | copilot | cursor | vscode | windsurf | gemini (merges, never clobbers)')
85
85
  .option('-p, --project <slug>')
86
86
  .option('-e, --env <name>')
87
87
  .option('--caps <list>')
88
88
  .option('--http-url <url>', 'configure the HTTP transport instead of stdio')
89
89
  .option('--token-placeholder <text>', 'placeholder written for the bearer token')
90
90
  .option('--with-playwright', 'also register the raw upstream Playwright MCP server (ungoverned; the sdods server already wraps it as browser_*)')
91
- .option('--file', 'always write the config file instead of calling `claude mcp add` / `codex mcp add`')
91
+ .option('--file', 'always write the config file instead of calling `claude` / `codex` / `copilot mcp add`')
92
92
  .option('--print', 'print the snippet instead of writing files')
93
93
  .action(async (client, opts, cmd) => {
94
94
  const ctx = createContext(cmd);
@@ -120,11 +120,11 @@ export function register(program) {
120
120
  return;
121
121
  }
122
122
  // Prefer the client's own CLI so its config format stays authoritative.
123
- if (!opts.file && (client === 'claude' || client === 'codex')) {
123
+ if (!opts.file && (client === 'claude' || client === 'codex' || client === 'copilot')) {
124
124
  const done = await registerViaClientCli(client, o, ctx.rootDir);
125
125
  if (done) {
126
126
  ok(`Registered the sdods MCP server with ${client} (${cliCommands(o)[client]})`);
127
- if (o.withPlaywright === true && client === 'codex')
127
+ if (o.withPlaywright === true && client !== 'claude')
128
128
  out(pc.dim('Also registered the bundled Playwright MCP server as "playwright".'));
129
129
  return;
130
130
  }
@@ -135,7 +135,7 @@ export function register(program) {
135
135
  out(pc.dim(`Alternative: ${s.cli}`));
136
136
  });
137
137
  }
138
- /** `claude mcp add` (project scope) or `codex mcp add`; false when the CLI is unavailable. */
138
+ /** `claude mcp add` (project scope), `codex mcp add` or `copilot mcp add` (user config); false when the CLI is unavailable. */
139
139
  async function registerViaClientCli(client, o, cwd) {
140
140
  const { execa } = await import('execa');
141
141
  // `npx sdods` resolves only when the cwd happens to sit inside a checkout that has the binary in
@@ -148,7 +148,7 @@ async function registerViaClientCli(client, o, cwd) {
148
148
  stdio.push('--env', o.env);
149
149
  if (o.caps)
150
150
  stdio.push('--caps', o.caps);
151
- const run = async (args) => {
151
+ const run = async (args, name = 'sdods') => {
152
152
  try {
153
153
  await execa(client, args, { cwd, timeout: 30_000 });
154
154
  return true;
@@ -159,7 +159,7 @@ async function registerViaClientCli(client, o, cwd) {
159
159
  return false;
160
160
  // already registered → remove and re-add so the args are current
161
161
  if (/already exists/i.test(msg)) {
162
- await execa(client, ['mcp', 'remove', args[2] ?? 'sdods'], { cwd, reject: false });
162
+ await execa(client, ['mcp', 'remove', name], { cwd, reject: false });
163
163
  await execa(client, args, { cwd, timeout: 30_000 });
164
164
  return true;
165
165
  }
@@ -170,26 +170,39 @@ async function registerViaClientCli(client, o, cwd) {
170
170
  };
171
171
  // A remote server without credentials answers 401 to every call, so the token is part of the
172
172
  // registration. Neither form writes the secret: Claude Code expands ${SDODS_TOKEN} from the
173
- // environment when it reads .mcp.json, and Codex reads the variable named by
174
- // --bearer-token-env-var at connect time.
175
- const addArgs = client === 'claude'
173
+ // environment when it reads .mcp.json, Copilot keeps the ${SDODS_TOKEN} reference as written,
174
+ // and Codex reads the variable named by --bearer-token-env-var at connect time.
175
+ const addArgs = client === 'copilot'
176
176
  ? o.httpUrl
177
177
  ? [
178
178
  'mcp',
179
179
  'add',
180
- '-s',
181
- 'project',
182
180
  '--transport',
183
181
  'http',
184
- 'sdods',
185
- o.httpUrl,
186
182
  '--header',
187
183
  'Authorization: Bearer ${SDODS_TOKEN}',
184
+ 'sdods',
185
+ o.httpUrl,
188
186
  ]
189
- : ['mcp', 'add', '-s', 'project', 'sdods', '--', ...stdio]
190
- : o.httpUrl
191
- ? ['mcp', 'add', 'sdods', '--url', o.httpUrl, '--bearer-token-env-var', 'SDODS_TOKEN']
192
- : ['mcp', 'add', 'sdods', '--', ...stdio];
187
+ : ['mcp', 'add', 'sdods', '--', ...stdio]
188
+ : client === 'claude'
189
+ ? o.httpUrl
190
+ ? [
191
+ 'mcp',
192
+ 'add',
193
+ '-s',
194
+ 'project',
195
+ '--transport',
196
+ 'http',
197
+ 'sdods',
198
+ o.httpUrl,
199
+ '--header',
200
+ 'Authorization: Bearer ${SDODS_TOKEN}',
201
+ ]
202
+ : ['mcp', 'add', '-s', 'project', 'sdods', '--', ...stdio]
203
+ : o.httpUrl
204
+ ? ['mcp', 'add', 'sdods', '--url', o.httpUrl, '--bearer-token-env-var', 'SDODS_TOKEN']
205
+ : ['mcp', 'add', 'sdods', '--', ...stdio];
193
206
  const ok1 = await run(addArgs);
194
207
  if (!ok1)
195
208
  return false;
@@ -208,7 +221,7 @@ async function registerViaClientCli(client, o, cwd) {
208
221
  '--headless',
209
222
  ]
210
223
  : ['mcp', 'add', 'playwright', '--', 'npx', 'playwright', 'mcp', '--headless'];
211
- await run(pw).catch(() => false);
224
+ await run(pw, 'playwright').catch(() => false);
212
225
  }
213
226
  return true;
214
227
  }
@@ -132,7 +132,7 @@ export function register(program) {
132
132
  .description('Ask the generator agent to turn a recorded spec into a feature + steps proposal (reviewed, never auto-applied)')
133
133
  .option('-p, --project <slug>', 'project slug (default: inferred from the spec path)')
134
134
  .option('--dry-run', 'print the prompt and plan without calling a model')
135
- .option('--adapter <name>', 'claude | claude-code | codex | openai | ollama | fake; default: auto-detect')
135
+ .option('--adapter <name>', 'claude | claude-code | codex | copilot | openai | ollama | fake; default: auto-detect')
136
136
  .option('--model <id>', 'model override')
137
137
  .option('--profile <name>', 'auto | full | small — how much of the platform the model is shown')
138
138
  .action(async (spec, opts, cmd) => {
@@ -1,9 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
2
  import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import pc from 'picocolors';
5
5
  import { RecordingModeSchema, isRunId, newRunId, runFiles, } from '@sdods/contracts';
6
- import { SdodsError, CLI_OVERRIDES_ENV, VERSION, checkPoolCapacity, collectGateEvidence, effectiveWorkers, evaluateGates, formatFindings, hasGates, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, parseTagExpr, poolAccountsFor, poolDemandByRole, poolTooSmallError, redactRunTraces, serializeCliOverrides, setupTierOf, } from '@sdods/core';
6
+ import { SdodsError, CLI_OVERRIDES_ENV, VERSION, checkPoolCapacity, ciHealSummary, collectGateEvidence, collectHealEvents, effectiveWorkers, evaluateGates, formatFindings, hasGates, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, parseTagExpr, poolAccountsFor, poolDemandByRole, poolTooSmallError, redactRunTraces, serializeCliOverrides, setupTierOf, } from '@sdods/core';
7
7
  import { analyzeChangeImpact } from '@sdods/mcp';
8
8
  import { createContext } from '../context.js';
9
9
  import { toolCommand, workspaceBin } from '../workspace-bin.js';
@@ -68,7 +68,7 @@ function addRunOptions(cmd) {
68
68
  .option('--fail-on-flaky', 'exit 1 when any test is flaky')
69
69
  .option('--max-failures <n>', 'stop after n failures', parseIntFlag('max-failures'))
70
70
  .option('--timeout <ms>', 'per-test timeout override', parseIntFlag('timeout'))
71
- .option('--reporter <name>', 'reporter override (repeatable, name=outputFile)', collect, [])
71
+ .option('--reporter <name>', 'add a reporter: junit (XML), allure (allure-results), json, blob, or any Playwright reporter; name=path sets the output (repeatable)', collect, [])
72
72
  .option('--reporter-mode <mode>', 'default | server | quiet')
73
73
  .option('--trigger <kind>', 'cli | ui | ci | agent | mcp | schedule', 'cli')
74
74
  .option('--allow-empty', 'exit 0 when the selection matches no scenario (by default that is exit 2)')
@@ -476,6 +476,26 @@ export async function runCommand(flags, cmd) {
476
476
  warn(`trace redaction failed for ${relative(ctx.rootDir, e.file)}: ${e.error}`);
477
477
  }
478
478
  const summary = readSummary(runDir, runId, exitCode, Date.now() - started);
479
+ // A heal in CI means the page changed under the suite. It must never pass silently
480
+ // (heal.ciMode, default warn), so every one is listed as an annotation and in the step summary.
481
+ if (cfg.runtime.ci) {
482
+ const heals = ciHealSummary(collectHealEvents(runDir));
483
+ if (heals) {
484
+ if (process.env.GITHUB_ACTIONS === 'true' && !ctx.opts.json)
485
+ for (const a of heals.annotations)
486
+ console.log(a);
487
+ else
488
+ warn(heals.line);
489
+ if (process.env.GITHUB_STEP_SUMMARY) {
490
+ try {
491
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${heals.markdown}\n`);
492
+ }
493
+ catch {
494
+ // a read-only or missing summary file must not fail the run
495
+ }
496
+ }
497
+ }
498
+ }
479
499
  // Process gates are judged here, from the run that just finished, before anything publishes it.
480
500
  // One shard of several holds only part of the run, so its pass rate and its a11y/perf evidence
481
501
  // are not the run's; a sharded process is gated where the shards are merged
package/dist/program.js CHANGED
@@ -40,6 +40,7 @@ import { register as registerUpgrade } from './commands/upgrade.js';
40
40
  import { register as registerFeedback } from './commands/feedback.js';
41
41
  import { register as registerLoad } from './commands/load.js';
42
42
  import { register as registerMatrix } from './commands/matrix.js';
43
+ import { register as registerImport } from './commands/import.js';
43
44
  /**
44
45
  * All commands are registered eagerly (cheap) but each module keeps heavy imports
45
46
  * (server, agents, DB, browser runner) inside its action via dynamic import.
@@ -101,6 +102,7 @@ export function buildProgram() {
101
102
  registerFeedback(program);
102
103
  registerLoad(program);
103
104
  registerMatrix(program);
105
+ registerImport(program);
104
106
  return program;
105
107
  }
106
108
  //# sourceMappingURL=program.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "SDODS command line: run, record, analyze, agents, MCP server, scheduler and the web server.",
5
5
  "license": "Apache-2.0",
6
6
  "funding": "https://sdods.com/sponsor/",
@@ -26,8 +26,8 @@
26
26
  "node": ">=22"
27
27
  },
28
28
  "dependencies": {
29
- "@sdods/contracts": "0.18.0",
30
- "@sdods/core": "0.18.0",
29
+ "@sdods/contracts": "0.20.0",
30
+ "@sdods/core": "0.20.0",
31
31
  "commander": "^15.0.0",
32
32
  "execa": "^10.2.1",
33
33
  "picocolors": "^1.1.1",
@@ -36,12 +36,12 @@
36
36
  "zod": "^4.6.7",
37
37
  "@playwright/test": "^1.64.0",
38
38
  "playwright-core": "^1.64.0",
39
- "@sdods/integrations": "0.18.0",
40
- "@sdods/mcp": "0.18.0",
41
- "@sdods/agents": "0.18.0",
42
- "@sdods/db": "0.18.0",
39
+ "@sdods/integrations": "0.20.0",
40
+ "@sdods/mcp": "0.20.0",
41
+ "@sdods/agents": "0.20.0",
42
+ "@sdods/db": "0.20.0",
43
43
  "csv-parse": "^7.0.3",
44
- "@sdods/server": "0.18.0"
44
+ "@sdods/server": "0.20.0"
45
45
  },
46
46
  "homepage": "https://sdods.com",
47
47
  "bugs": {
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "repository": {
51
51
  "type": "git",
52
- "url": "git+https://github.com/YarlisAISolutions/SDODS.git",
52
+ "url": "git+https://github.com/YarlisAISolutions/sdods.git",
53
53
  "directory": "packages/cli"
54
54
  },
55
55
  "keywords": [