@dzhechkov/harness-cli 0.3.232 → 0.3.234

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -111,8 +111,18 @@ import {
111
111
  isInsideTree,
112
112
  signManifest,
113
113
  verifyManifest,
114
+ listSignablePackFiles,
114
115
  assertKeyOutsideTree,
115
116
  decidePublishGate,
117
+ collectPackageFacts,
118
+ planReleaseGates,
119
+ selectAffectedPackages,
120
+ classifyGateExecutions,
121
+ buildFailureIssue,
122
+ buildReleaseNotes,
123
+ releaseTagName,
124
+ firstOutputLine,
125
+ formatPublishError,
116
126
  MANIFEST_NAME,
117
127
  SBOM_NAME,
118
128
  buildArchitectureMap,
@@ -169,7 +179,7 @@ import {
169
179
  } from '@dzhechkov/harness-core';
170
180
  import type { Family, ModelRung, Candidate as BtoCandidate, DimScores } from '@dzhechkov/harness-core';
171
181
  import type { SetupSpec } from '@dzhechkov/harness-core';
172
- import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow } from '@dzhechkov/harness-core';
182
+ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep } from '@dzhechkov/harness-core';
173
183
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
174
184
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
175
185
 
@@ -193,6 +203,7 @@ Usage:
193
203
  dz sign --pack <dir> --key <path-outside-repo> (Ed25519 manifest + CycloneDX SBOM for a pack)
194
204
  dz verify-pack --pack <dir> [--pubkey <path>] (signature check; fail-closed; key from the repo, never the pack)
195
205
  dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
206
+ dz release [--filter <name>] [--tag] [--publish] [--json] [--dry-run] [--no-issue] (VERIFIED release: 4 HARD gates in FRONT of dz publish — full package test suites, audit >=high, node --check of every dist/bin file, bin smoke-boot via "node <bin> --help" — any red gate STOPS the release (exit 1) + best-effort gh issue; all green ⇒ re-sign reminder, then prints the ready dz publish command (or chains with --publish); never duplicates publish's own gates)
196
207
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--install-driver] [--force] [--enrich]
197
208
  dz teach "<pattern>" [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (--project pins the learned store to <dir>/.dz, not the cwd — pin to a canonical brain)
198
209
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
@@ -253,8 +264,21 @@ export interface CliIo {
253
264
  * command that needs it (`brain ground`), and never when stdin is a TTY (nothing piped).
254
265
  */
255
266
  readonly stdin?: string;
267
+ /**
268
+ * Test seam for `dz release`: overrides subprocess execution for gate steps and the
269
+ * gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
270
+ * A scripted runner makes failing gates, gh outages, and git-tag failures testable
271
+ * without spawning anything.
272
+ */
273
+ readonly releaseRunner?: ReleaseExecRunner;
256
274
  }
257
275
 
276
+ /** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
277
+ export type ReleaseExecRunner = (
278
+ cmd: string,
279
+ opts: { readonly cwd: string; readonly timeoutMs: number },
280
+ ) => { exitCode: number; stdout: string; stderr: string; timedOut?: boolean };
281
+
258
282
  interface ParsedArgs {
259
283
  readonly command: string;
260
284
  readonly options: Map<string, string>;
@@ -3092,21 +3116,9 @@ async function cmdAutoCanonicalize(options: Map<string, string>, cwd: string, wr
3092
3116
  /** The pinned trust root. A key inside the artifact under verification is data, not a key (ADR-001). */
3093
3117
  const TRUST_ROOT_REL = 'keys/dz.pub';
3094
3118
 
3095
- function packFiles(dir: string): string[] {
3096
- const out: string[] = [];
3097
- const walk = (d: string, rel: string): void => {
3098
- for (const e of readdirSync(d, { withFileTypes: true })) {
3099
- if (e.name === 'node_modules' || e.name === '.git') continue;
3100
- if (e.name === MANIFEST_NAME || e.name === SBOM_NAME) continue;
3101
- const abs = join(d, e.name);
3102
- const r = rel ? rel + '/' + e.name : e.name;
3103
- if (e.isDirectory()) walk(abs, r);
3104
- else if (e.isFile()) out.push(r);
3105
- }
3106
- };
3107
- walk(dir, '');
3108
- return out.sort();
3109
- }
3119
+ // One walk to rule both: the SIGN file list is core's listPackFiles — the SAME function verify uses for
3120
+ // its added-file sweep, so the two can never drift apart again (the 10-false-TAMPERED lesson, task #36).
3121
+ const packFiles = (dir: string): string[] => listSignablePackFiles(dir);
3110
3122
 
3111
3123
 
3112
3124
  /**
@@ -3559,6 +3571,294 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
3559
3571
  return report.errors > 0 ? 1 : 0;
3560
3572
  }
3561
3573
 
3574
+ /* ------------------------------------------------------------------ */
3575
+ /* dz release — verified-release conveyor (feature release-verified, */
3576
+ /* ADR-001): 4 HARD gates in FRONT of the untouched dz publish. */
3577
+ /* ------------------------------------------------------------------ */
3578
+
3579
+ /**
3580
+ * The single executor of the pure engine's {@link GateStep} plan. ALL subprocess side effects
3581
+ * of the release live here (engine purity, NFR-1). Contract highlights:
3582
+ *
3583
+ * - ALL four gates execute even after an earlier gate fails (AM-9/FR-6 — no fail-fast hiding);
3584
+ * one red gate still reddens the release.
3585
+ * - Smoke steps run in a THROWAWAY cwd (AM-4): skills bins are installers that mutate
3586
+ * `.claude/` on default action.
3587
+ * - gh issue + git tag are best-effort periphery: loud on failure, NEVER verdict- or
3588
+ * exit-code-affecting (FR-7/FR-8, AM-6).
3589
+ * - On green it never injects `--yes/--confirm/--no-dry-run` into the printed/chained publish
3590
+ * (AM-5): live publish stays the operator's explicit act, and `--publish` chains into
3591
+ * publish's own default (dry-run) protocol with ALL its gates untouched.
3592
+ */
3593
+ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, runner?: ReleaseExecRunner): number {
3594
+ const json = flags.has('json');
3595
+ const warnings: string[] = [];
3596
+ const say = (line: string): void => {
3597
+ if (!json) write(line);
3598
+ };
3599
+ const loud = (line: string): void => {
3600
+ warnings.push(line);
3601
+ if (!json) write(line);
3602
+ };
3603
+ const emitJson = (obj: Record<string, unknown>): void => {
3604
+ if (json) write(JSON.stringify({ ...obj, warnings }, null, 2));
3605
+ };
3606
+ // G9 reuse-never-copy: the engine's firstOutputLine, not a byte-parallel local copy.
3607
+ const oneLine = firstOutputLine;
3608
+
3609
+ // Strict allowlist (cmdPublish's typo-rejection pattern) — a mistyped flag is NEVER
3610
+ // swallowed, and the rejection honors the --json contract too (AC-7).
3611
+ // `--verified` is accepted but not read: verified is the DEFAULT and only mode of
3612
+ // `dz release` in this slice — the flag exists so the branded invocation is not a typo error.
3613
+ const allowedFlags = new Set(['verified', 'tag', 'publish', 'json', 'dry-run', 'no-issue', 'affected', 'audit-dev', 'help']);
3614
+ const allowedOptions = new Set(['filter']);
3615
+ const allowedHelp = ' allowed: --verified, --filter <substr>, --affected, --audit-dev, --tag, --publish, --json, --dry-run, --no-issue';
3616
+ const rejectUnknown = (key: string): number => {
3617
+ if (json) write(JSON.stringify({ error: `unknown option --${key}`, allowed: allowedHelp.trim(), publishAction: 'blocked', exitCode: 1 }, null, 2));
3618
+ else {
3619
+ write(`dz release: unknown option --${key}`);
3620
+ write(allowedHelp);
3621
+ }
3622
+ return 1;
3623
+ };
3624
+ for (const flag of flags) if (!allowedFlags.has(flag)) return rejectUnknown(flag);
3625
+ for (const key of options.keys()) {
3626
+ if (key.startsWith('_positional_')) continue;
3627
+ if (!allowedOptions.has(key)) return rejectUnknown(key);
3628
+ }
3629
+
3630
+ // --filter mirrors publish semantics: trim + drop empties; empty result is an ERROR,
3631
+ // never "match all" (the publish P0 regression this deliberately copies).
3632
+ const filterStr = options.get('filter');
3633
+ let filter: string[] | undefined;
3634
+ if (filterStr !== undefined) {
3635
+ filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
3636
+ if (filter.length === 0) {
3637
+ if (json) write(JSON.stringify({ error: '--filter requires a non-empty comma-separated list of package-name substrings', publishAction: 'blocked', exitCode: 1 }, null, 2));
3638
+ else write('dz release: --filter requires a non-empty comma-separated list of package-name substrings');
3639
+ return 1;
3640
+ }
3641
+ }
3642
+
3643
+ // DETECT — real facts via the engine's one fs seam (discoverPackages underneath).
3644
+ let factsList: ReturnType<typeof collectPackageFacts>;
3645
+ try {
3646
+ factsList = collectPackageFacts(cwd, filter);
3647
+ } catch (err) {
3648
+ const msg = err instanceof Error ? err.message : String(err);
3649
+ if (json) write(JSON.stringify({ error: msg, publishAction: 'blocked', exitCode: 1 }, null, 2));
3650
+ else write(`dz release: ${msg}`);
3651
+ return 1;
3652
+ }
3653
+ if (factsList.length === 0) {
3654
+ // C-6: outside a workspace (or an over-narrow filter) — explain and exit non-zero, never throw.
3655
+ const msg = 'no publishable packages found under packages/@dzhechkov — run from the monorepo root (or widen --filter)';
3656
+ if (json) write(JSON.stringify({ error: msg, packages: [], publishAction: 'blocked', exitCode: 1 }, null, 2));
3657
+ else write(`dz release: ${msg}`);
3658
+ return 1;
3659
+ }
3660
+
3661
+ // Executor: default = real execSync with piped stdio + timeout; injectable for tests.
3662
+ // Defined BEFORE planning because --affected needs read-only git diffs at DETECT time.
3663
+ const run: ReleaseExecRunner =
3664
+ runner ??
3665
+ ((cmd, opts) => {
3666
+ try {
3667
+ const stdout = execSync(cmd, { cwd: opts.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: opts.timeoutMs });
3668
+ return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
3669
+ } catch (err) {
3670
+ const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
3671
+ // execSync's timeout kills via signal and leaves status null — that IS the timeout shape.
3672
+ const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
3673
+ return {
3674
+ exitCode: typeof e.status === 'number' ? e.status : 1,
3675
+ stdout: e.stdout == null ? '' : String(e.stdout),
3676
+ stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
3677
+ timedOut,
3678
+ };
3679
+ }
3680
+ });
3681
+
3682
+ // AM-8/--affected: narrow the release set to packages touched by the working tree + last
3683
+ // commit. Selection is the PURE selectAffectedPackages; the two read-only git diffs are the
3684
+ // injected fact source. Any git failure ⇒ null ⇒ FAIL-OPEN to the full set (never zero).
3685
+ if (flags.has('affected')) {
3686
+ const dWork = run('git diff --name-only HEAD', { cwd, timeoutMs: 10_000 });
3687
+ const dLast = run('git diff --name-only HEAD~1..HEAD', { cwd, timeoutMs: 10_000 });
3688
+ const anyOk = dWork.exitCode === 0 || dLast.exitCode === 0;
3689
+ const changed = anyOk
3690
+ ? [dWork, dLast]
3691
+ .filter((r) => r.exitCode === 0)
3692
+ .flatMap((r) => r.stdout.split('\n'))
3693
+ .map((s) => s.trim())
3694
+ .filter((s) => s.length > 0)
3695
+ : null;
3696
+ const selected = selectAffectedPackages(changed, factsList);
3697
+ if (changed === null) loud('dz release --affected: ⚠ git diff unavailable — FAIL-OPEN to the full package set');
3698
+ else say(`dz release --affected: ${selected.length}/${factsList.length} package(s) selected from ${changed.length} changed file(s)${selected.length === factsList.length ? ' (no narrowing — full set)' : ''}`);
3699
+ factsList = selected;
3700
+ }
3701
+
3702
+ const plan = planReleaseGates(factsList, {
3703
+ monorepoRoot: cwd,
3704
+ pnpmLockPresent: existsSync(join(cwd, 'pnpm-lock.yaml')),
3705
+ includeDevDeps: flags.has('audit-dev'),
3706
+ });
3707
+
3708
+ // --dry-run: print the full plan, execute NOTHING (deterministic, byte-testable preview).
3709
+ if (flags.has('dry-run')) {
3710
+ if (json) {
3711
+ write(JSON.stringify({ dryRun: true, packages: plan.packages, steps: plan.steps, skips: plan.skips, warnings }, null, 2));
3712
+ return 0;
3713
+ }
3714
+ write(`\ndz release --dry-run — plan only, zero commands executed (${plan.packages.length} package(s))`);
3715
+ for (const gate of ['tests', 'audit', 'syntax', 'smoke'] as const) {
3716
+ const steps = plan.steps.filter((s) => s.gate === gate);
3717
+ write(` ${gate} (${steps.length} step(s)):`);
3718
+ for (const s of steps) {
3719
+ if (s.kind === 'synthetic-fail') write(` ✗ [planned ${s.failClass ?? 'FAIL'}] ${s.pkg ?? ''}: ${s.reason}`);
3720
+ else write(` · ${s.cmd} (cwd=${s.tempCwd === true ? '<temp>' : s.cwd}, timeout=${s.timeoutMs}ms)`);
3721
+ }
3722
+ if (gate === 'tests') for (const sk of plan.skips) write(` ○ [${sk.class}] ${sk.pkg}: ${sk.reason}`);
3723
+ }
3724
+ write(' → run without --dry-run to execute the gates');
3725
+ return 0;
3726
+ }
3727
+
3728
+ // VERIFY — execute EVERY exec step (AM-9: all 4 gates run and report even after a failure).
3729
+ const executions: GateExecution[] = [];
3730
+ let smokeTmp: string | undefined;
3731
+ const execSteps: GateStep[] = plan.steps.filter((s) => s.kind !== 'synthetic-fail');
3732
+ say(`\ndz release — executing ${execSteps.length} gate step(s) across ${plan.packages.length} package(s)…`);
3733
+ for (const step of execSteps) {
3734
+ let stepCwd = step.cwd;
3735
+ if (step.tempCwd === true) {
3736
+ // AM-4: boot bins in a throwaway cwd so an installer-style bin cannot mutate the workspace.
3737
+ if (smokeTmp === undefined) smokeTmp = mkdtempSync(join(tmpdir(), 'dz-release-smoke-'));
3738
+ stepCwd = smokeTmp;
3739
+ }
3740
+ const started = Date.now();
3741
+ const r = run(step.cmd, { cwd: stepCwd, timeoutMs: step.timeoutMs });
3742
+ executions.push({
3743
+ stepId: step.id,
3744
+ exitCode: r.exitCode,
3745
+ stdout: r.stdout,
3746
+ stderr: r.stderr,
3747
+ durationMs: Date.now() - started,
3748
+ timedOut: r.timedOut,
3749
+ });
3750
+ }
3751
+ if (smokeTmp !== undefined) {
3752
+ try { rmSync(smokeTmp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
3753
+ }
3754
+
3755
+ const verdict = classifyGateExecutions(plan, executions);
3756
+
3757
+ // Report: per-gate ✓/✗/○ with package granularity + timestamp (NFR-5). Skips are named,
3758
+ // never folded into pass wording (AM-2: "N passed, M skipped", not "all tests passed").
3759
+ for (const g of verdict.gates) {
3760
+ const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
3761
+ say(` ${icon} ${g.gate.padEnd(7)} ${g.status.toUpperCase().padEnd(4)} ${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped`);
3762
+ for (const f of g.failures) say(` [${f.class}] ${f.pkg !== undefined ? `${f.pkg}: ` : ''}${f.reason}`);
3763
+ for (const sk of g.skips) say(` [${sk.class}] ${sk.pkg}: ${sk.reason}`);
3764
+ }
3765
+ say(` verdict at ${verdict.timestamp}: ${verdict.publishAction === 'proceed' ? '✓ all gates green' : '✗ RELEASE BLOCKED'}`);
3766
+
3767
+ const invocation = `dz release${filterStr !== undefined ? ` --filter ${filterStr}` : ''}`;
3768
+ const shq = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`;
3769
+
3770
+ if (verdict.publishAction === 'blocked') {
3771
+ say(`dz release: ✗ release STOPPED — ${verdict.blockedBy.join('; ')}`);
3772
+ // FR-7 / AM-6: best-effort issue — a courier, never a judge. Loud on any failure;
3773
+ // the verdict and exit code are ALREADY decided and cannot change here.
3774
+ let issueUrl: string | undefined;
3775
+ if (!flags.has('no-issue')) {
3776
+ const issue = buildFailureIssue(verdict, { invocation });
3777
+ const probe = run('command -v gh', { cwd, timeoutMs: 10_000 });
3778
+ if (probe.exitCode !== 0) {
3779
+ loud(`dz release: ⚠ gh unavailable — file the issue manually: ${issue.title}`);
3780
+ } else {
3781
+ const res = run(`gh issue create --title ${shq(issue.title)} --body ${shq(issue.body)}`, { cwd, timeoutMs: 30_000 });
3782
+ if (res.exitCode === 0) {
3783
+ issueUrl = oneLine(res.stdout) || undefined;
3784
+ say(`dz release: gh issue created${issueUrl !== undefined ? `: ${issueUrl}` : ''}`);
3785
+ } else {
3786
+ loud(`dz release: ⚠ gh issue creation failed (${oneLine(res.stderr, res.stdout) || 'unknown error'}) — file the issue manually: ${issue.title}`);
3787
+ }
3788
+ }
3789
+ }
3790
+ emitJson({
3791
+ gates: verdict.gates,
3792
+ ok: verdict.ok,
3793
+ blockedBy: verdict.blockedBy,
3794
+ skipped: verdict.skipped,
3795
+ publishAction: verdict.publishAction,
3796
+ timestamp: verdict.timestamp,
3797
+ ...(issueUrl !== undefined ? { issueUrl } : {}),
3798
+ exitCode: 1,
3799
+ });
3800
+ return 1;
3801
+ }
3802
+
3803
+ // Success path. FR-8: --tag is best-effort trimming — loud on failure, never gate-affecting.
3804
+ let tagName: string | undefined;
3805
+ if (flags.has('tag')) {
3806
+ const sha = run('git rev-parse --short HEAD', { cwd, timeoutMs: 10_000 });
3807
+ const log = run('git log --oneline -n 15 --no-decorate', { cwd, timeoutMs: 10_000 });
3808
+ const notes = buildReleaseNotes(log.exitCode === 0 ? log.stdout.split('\n') : []);
3809
+ tagName = releaseTagName(new Date(), sha.exitCode === 0 ? sha.stdout.trim() : '');
3810
+ const tag = run(`git tag -a ${tagName} -m ${shq(notes)}`, { cwd, timeoutMs: 10_000 });
3811
+ if (tag.exitCode === 0) {
3812
+ say(`dz release: tagged ${tagName} (annotated with short release notes from recent commits)`);
3813
+ say('dz release: note — the tag attests a VERIFIED tree, not a completed publish');
3814
+ } else {
3815
+ loud(`dz release: ⚠ git tag failed (${oneLine(tag.stderr, tag.stdout) || 'unknown error'}) — non-blocking; tag manually: git tag -a ${tagName}`);
3816
+ }
3817
+ }
3818
+
3819
+ // FR-10 / AC-8: re-sign reminder BEFORE the handoff — publish's signature gate is
3820
+ // refuse-unsigned, and gates 3–4 often follow a rebuild.
3821
+ say('dz release: reminder — re-sign any rebuilt packs BEFORE publishing (dz sign --pack <dir> --key <path-outside-repo>); the publish signature gate is refuse-unsigned');
3822
+
3823
+ // FR-9 / AM-5: the handoff NEVER injects --yes/--confirm/--no-dry-run — live publish stays
3824
+ // the operator's explicit, loud-bannered act inside dz publish itself.
3825
+ const publishCommand = `dz publish${filterStr !== undefined ? ` --filter ${filterStr}` : ''}`;
3826
+ let publishExit: number | undefined;
3827
+ let publishOutput: string[] | undefined;
3828
+ if (flags.has('publish')) {
3829
+ say(`dz release: ✓ gates green — chaining into ${publishCommand} in-process (ALL publish gates + its dry-run-by-default protocol run untouched)`);
3830
+ const pubOptions = new Map<string, string>(filterStr !== undefined ? [['filter', filterStr]] : []);
3831
+ // --json contract: the chained publish must NOT print prose before the JSON envelope —
3832
+ // capture its lines and carry them INSIDE the envelope instead (stdout stays one JSON doc).
3833
+ if (json) {
3834
+ publishOutput = [];
3835
+ const captured = publishOutput;
3836
+ publishExit = cmdPublish(pubOptions, new Set<string>(), cwd, (line: string) => {
3837
+ captured.push(line);
3838
+ });
3839
+ } else {
3840
+ publishExit = cmdPublish(pubOptions, new Set<string>(), cwd, write);
3841
+ }
3842
+ } else {
3843
+ say(`dz release: ✓ all gates green — publish when ready: ${publishCommand}`);
3844
+ }
3845
+
3846
+ emitJson({
3847
+ gates: verdict.gates,
3848
+ ok: verdict.ok,
3849
+ blockedBy: verdict.blockedBy,
3850
+ skipped: verdict.skipped,
3851
+ publishAction: verdict.publishAction,
3852
+ timestamp: verdict.timestamp,
3853
+ ...(tagName !== undefined ? { tag: tagName } : {}),
3854
+ publishCommand,
3855
+ ...(publishExit !== undefined ? { publishExit } : {}),
3856
+ ...(publishOutput !== undefined ? { publishOutput } : {}),
3857
+ exitCode: publishExit ?? 0,
3858
+ });
3859
+ return publishExit ?? 0;
3860
+ }
3861
+
3562
3862
  function cmdRegistry(options: Map<string, string>, cwd: string, write: Write): number {
3563
3863
  const registry = buildRegistry(cwd);
3564
3864
  if (registry.totalSkills === 0) {
@@ -5410,6 +5710,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
5410
5710
  return await cmdAutoCanonicalize(options, cwd, write);
5411
5711
  case 'publish':
5412
5712
  return cmdPublish(options, flags, cwd, write);
5713
+ case 'release':
5714
+ return cmdRelease(options, flags, cwd, write, io.releaseRunner);
5413
5715
  case 'registry':
5414
5716
  return cmdRegistry(options, cwd, write);
5415
5717
  case 'benchmark':
package/src/index.ts CHANGED
@@ -11,4 +11,4 @@ export const HARNESS_CLI_VERSION: string =
11
11
  (createRequire(import.meta.url)('../package.json') as { version: string }).version;
12
12
 
13
13
  export { runCli } from './cli.js';
14
- export type { CliIo } from './cli.js';
14
+ export type { CliIo, ReleaseExecRunner } from './cli.js';