@ran-sh/dsh-crew 0.3.3 → 0.3.4

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/README.md CHANGED
@@ -40,21 +40,26 @@ Codex Desktop / Claude Code
40
40
 
41
41
  Prerequisites: **Node.js** (Git is needed only for coding-worker isolation).
42
42
 
43
- Recommended: manage DSH Crew directly through npm/npx no clone required:
43
+ Recommended: install a stable, package-manager-installed launcher and manage DSH Crew with it:
44
44
 
45
45
  ```bash
46
- npx @ran-sh/dsh-crew@latest install
46
+ npm install -g @ran-sh/dsh-crew@latest
47
+ dsh-crew install
47
48
  ```
48
49
 
49
50
  Manage the installation later with the same CLI:
50
51
 
51
52
  ```bash
52
- npx @ran-sh/dsh-crew@latest status
53
- npx @ran-sh/dsh-crew@latest update
54
- npx @ran-sh/dsh-crew@latest uninstall # add --purge to also remove config/backups
53
+ dsh-crew status
54
+ dsh-crew update
55
+ dsh-crew uninstall # add --purge to also remove config/backups
55
56
  ```
56
57
 
57
- An npx install persists the already-built package under Crew-owned state (`~/.config/dsh-crew/app`) before registering it, so the installation never depends on a transient npx cache and does not require pnpm, a lockfile, or rebuilding anything.
58
+ The globally installed package is only the launcher/manager. The actual Crew runtime/plugin payload is persisted under Crew-owned state (`~/.config/dsh-crew/app`) before Harness registration, so runtime behavior never depends on npm cache paths.
59
+
60
+ > Known compatibility issue: transient `npx @ran-sh/dsh-crew …` execution is currently unreliable on some npm versions (npm/cli#9870: the npx cache bin is not put on the spawned command PATH). Until that upstream fix reaches your npm, use the global-launcher flow above instead.
61
+
62
+ An update of the managed payload does not require a clone or rebuild; `dsh-crew update` resolves the newest permitted package from your configured npm registry (or an explicit `--candidate <path>` override), validates it under Crew-owned state, and switches only after validation succeeds. The global launcher intentionally does not self-replace; after an update the CLI prints the exact one-line command to refresh it.
58
63
 
59
64
  Developer / source setup (alternative path):
60
65
 
@@ -113,16 +118,16 @@ A coding worker receives a temporary git worktree at the requested revision, so
113
118
 
114
119
  ## Update / uninstall
115
120
 
116
- npx-managed installs update in place (config, credentials, and backups are preserved; the candidate is staged and validated before switching):
121
+ The managed Crew payload updates in place (config, credentials, and backups are preserved; the candidate is staged and validated before switching):
117
122
 
118
123
  ```bash
119
- npx @ran-sh/dsh-crew@latest update
124
+ dsh-crew update
120
125
  ```
121
126
 
122
- Uninstall an npx-managed install:
127
+ Uninstall the managed payload and integrations:
123
128
 
124
129
  ```bash
125
- npx @ran-sh/dsh-crew@latest uninstall
130
+ dsh-crew uninstall
126
131
  ```
127
132
 
128
133
  Update a source install instead with:
package/README.zh.md CHANGED
@@ -40,21 +40,26 @@ Codex Desktop / Claude Code
40
40
 
41
41
  需要:**Node.js**(编码 worker 隔离需要 **Git**)。
42
42
 
43
- 推荐直接通过 npm/npx 管理,无需克隆仓库:
43
+ 推荐安装稳定的包管理器全局启动器,并用它管理 DSH Crew:
44
44
 
45
45
  ```bash
46
- npx @ran-sh/dsh-crew@latest install
46
+ npm install -g @ran-sh/dsh-crew@latest
47
+ dsh-crew install
47
48
  ```
48
49
 
49
50
  后续管理使用同一 CLI:
50
51
 
51
52
  ```bash
52
- npx @ran-sh/dsh-crew@latest status
53
- npx @ran-sh/dsh-crew@latest update
54
- npx @ran-sh/dsh-crew@latest uninstall # 加 --purge 才会同时删除配置/备份
53
+ dsh-crew status
54
+ dsh-crew update
55
+ dsh-crew uninstall # 加 --purge 才会同时删除配置/备份
55
56
  ```
56
57
 
57
- npx 安装会把已构建好的包持久化到 Crew 自有状态(`~/.config/dsh-crew/app`)后再注册,安装不依赖临时的 npx 缓存,也不需要 pnpm、lockfile 或任何构建步骤。
58
+ 全局安装的包只是启动器/管理器;真正的 Crew 运行时/插件载荷会先持久化到 Crew 自有状态(`~/.config/dsh-crew/app`)再注册,运行时行为不依赖 npm 缓存路径。
59
+
60
+ > 已知兼容性问题:部分 npm 版本下,临时 `npx @ran-sh/dsh-crew …` 执行不可靠(npm/cli#9870:npx 缓存中的 bin 未加入子进程 PATH)。在该上游修复可用之前,请使用上面的全局启动器方式。
61
+
62
+ 托管载荷的更新无需克隆或构建:`dsh-crew update` 会从你配置的 npm registry 解析最新允许版本(或用 `--candidate <path>` 显式指定),在 Crew 自有状态下校验后再切换。全局启动器不会自我替换;更新完成后 CLI 会打印刷新启动器的准确命令。
58
63
 
59
64
  开发者 / 源码安装(备选路径):
60
65
 
@@ -113,16 +118,16 @@ Use ds-reviewer to review the implementation.
113
118
 
114
119
  ## 更新 / 卸载
115
120
 
116
- npx 安装使用 update 原地升级(配置、凭据和备份保留;候选包先暂存校验再切换):
121
+ 托管 Crew 载荷使用 update 原地升级(配置、凭据和备份保留;候选包先暂存校验再切换):
117
122
 
118
123
  ```bash
119
- npx @ran-sh/dsh-crew@latest update
124
+ dsh-crew update
120
125
  ```
121
126
 
122
- 卸载 npx 安装:
127
+ 卸载托管载荷与集成:
123
128
 
124
129
  ```bash
125
- npx @ran-sh/dsh-crew@latest uninstall
130
+ dsh-crew uninstall
126
131
  ```
127
132
 
128
133
  源码安装的更新方式:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -565,57 +565,235 @@ export async function npxInstall({
565
565
  return { ok: true, version: manifest.version, path: releaseDir };
566
566
  }
567
567
 
568
+ export const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
569
+ export const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
570
+
571
+ /**
572
+ * Extract a packed npm tarball with the platform `tar` binary (bsdtar ships
573
+ * with Windows 10+, macOS, and Linux) into destDir; npm tarballs always root
574
+ * at `package/`.
575
+ */
576
+ export function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
577
+ mkdirSync(destDir, { recursive: true });
578
+ const result = runner('tar', ['-xzf', String(tgzPath), '-C', destDir], {
579
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
580
+ });
581
+ const root = join(destDir, 'package');
582
+ if (result.status !== 0 || !existsSync(join(root, 'package.json'))) {
583
+ return { ok: false, detail: `tar extract failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-200)}` };
584
+ }
585
+ return { ok: true, sourceRoot: root };
586
+ }
587
+
588
+ /**
589
+ * The globally installed launcher intentionally does not self-replace. When
590
+ * it diverges from the freshly activated managed payload, say so explicitly
591
+ * and give the exact refresh command instead of pretending both updated.
592
+ */
593
+ function noteLauncherDivergence({ log, home = homedir() }) {
594
+ const launcherVersion = readManifest(runningPackageRoot())?.version ?? null;
595
+ let installedVersion = null;
596
+ try { installedVersion = readCurrentPointer({ home }).version ?? null; } catch { installedVersion = null; }
597
+ if (launcherVersion && installedVersion && launcherVersion !== installedVersion) {
598
+ log('');
599
+ log(`- note: the global launcher is still ${launcherVersion}; the managed Crew payload is now ${installedVersion}.`);
600
+ log(` Refresh the launcher when convenient: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Resolve an update candidate WITHOUT a source checkout:
606
+ * - explicit candidate path: a payload directory or a packed .tgz file;
607
+ * - otherwise npm registry mode: `npm pack <spec>` using the user's own
608
+ * configured registry/auth (no policy or config mutation), extracted to a
609
+ * disposable directory the caller cleans up via returned cleanup().
610
+ */
611
+ export function resolveUpdateCandidate({
612
+ candidate,
613
+ spec = UPDATE_DEFAULT_SPEC,
614
+ home = homedir(),
615
+ log = () => {},
616
+ runner = spawnSync,
617
+ } = {}) {
618
+ const candidateValue = candidate ?? process.env.DSH_CREW_CANDIDATE ?? undefined;
619
+ if (candidateValue !== undefined && typeof candidateValue !== 'string') {
620
+ return { ok: false, code: 'INVALID_CANDIDATE', detail: 'candidate must be a directory or .tgz path' };
621
+ }
622
+ if (typeof candidateValue === 'string' && candidateValue.trim()) {
623
+ const value = resolve(candidateValue.trim());
624
+ if (!existsSync(value)) return { ok: false, code: 'CANDIDATE_NOT_FOUND', detail: value };
625
+ if (existsSync(join(value, 'package.json'))) {
626
+ const manifest = readManifest(value);
627
+ if (!manifest?.name || !manifest?.version) return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
628
+ return { ok: true, sourceRoot: value, version: manifest.version, cleanup: null };
629
+ }
630
+ if (/\.tgz$/i.test(value)) {
631
+ const tmpRoot = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
632
+ const extracted = extractPackageTarball(value, tmpRoot, { runner });
633
+ if (!extracted.ok) {
634
+ rmSync(tmpRoot, { recursive: true, force: true });
635
+ return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
636
+ }
637
+ const manifest = readManifest(extracted.sourceRoot);
638
+ if (!manifest?.name || !manifest?.version) {
639
+ rmSync(tmpRoot, { recursive: true, force: true });
640
+ return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
641
+ }
642
+ return {
643
+ ok: true,
644
+ sourceRoot: extracted.sourceRoot,
645
+ version: manifest.version,
646
+ cleanup: () => rmSync(tmpRoot, { recursive: true, force: true }),
647
+ };
648
+ }
649
+ return { ok: false, code: 'UNSUPPORTED_CANDIDATE', detail: value };
650
+ }
651
+
652
+ // Registry mode.
653
+ const tmpDir = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
654
+ mkdirSync(tmpDir, { recursive: true });
655
+ try {
656
+ const effectiveSpec = process.env.DSH_CREW_UPDATE_SPEC ?? spec;
657
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
658
+ const packArgs = ['pack', effectiveSpec, '--pack-destination', tmpDir, '--json', '--loglevel=error'];
659
+ log(`- resolving update candidate from the configured npm registry (${effectiveSpec})`);
660
+ const packed = runner(npm, packArgs, {
661
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32',
662
+ timeout: 600_000, windowsHide: true, env: sanitizedPackageManagerEnv(),
663
+ });
664
+ if (packed.status !== 0) {
665
+ return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: `${effectiveSpec}: ${(packed.stderr || packed.stdout || '').trim().slice(-300)}` };
666
+ }
667
+ let info;
668
+ try { info = JSON.parse(packed.stdout)[0]; } catch {
669
+ return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: String(packed.stdout).slice(-200) };
670
+ }
671
+ if (!info?.filename) return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: 'pack output missing filename' };
672
+ const tgzPath = join(tmpDir, info.filename);
673
+ const extracted = extractPackageTarball(tgzPath, join(tmpDir, 'x'), { runner });
674
+ if (!extracted.ok) {
675
+ return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
676
+ }
677
+ const manifest = readManifest(extracted.sourceRoot);
678
+ if (!manifest?.name || !manifest?.version) {
679
+ return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: info.filename };
680
+ }
681
+ if (info.name !== manifest.name || info.version !== manifest.version) {
682
+ return { ok: false, code: 'CANDIDATE_IDENTITY_MISMATCH', detail: `pack ${info.name}@${info.version} vs manifest ${manifest.name}@${manifest.version}` };
683
+ }
684
+ return {
685
+ ok: true,
686
+ sourceRoot: extracted.sourceRoot,
687
+ version: manifest.version,
688
+ cleanup: () => rmSync(tmpDir, { recursive: true, force: true }),
689
+ };
690
+ } catch (error) {
691
+ rmSync(tmpDir, { recursive: true, force: true });
692
+ return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: String(error?.message ?? error).slice(-300) };
693
+ }
694
+ }
695
+
568
696
  export async function npxUpdate({
569
697
  home = homedir(),
570
698
  log = console.log,
571
699
  sourceRoot,
700
+ candidate,
701
+ spec,
572
702
  installer = realInstaller,
573
703
  ensureRuntime,
574
704
  npmInstaller,
705
+ runner = spawnSync,
575
706
  } = {}) {
576
- log('DSH Crew updater (npx-managed)');
577
- const candidateRoot = sourceRoot ?? runningPackageRoot();
578
- const manifest = readManifest(candidateRoot);
579
- if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
707
+ log('DSH Crew updater');
708
+ // Candidate resolution: explicit path/dir override > legacy sourceRoot >
709
+ // configured npm registry (@latest). Registry mode is a real update
710
+ // operation and never downgrades an already-newer managed payload.
711
+ const resolved = resolveUpdateCandidate({ candidate: candidate ?? sourceRoot, spec, home, log, runner });
712
+ if (!resolved.ok) {
713
+ log(`✗ candidate resolution failed (${resolved.code})${resolved.detail ? `: ${resolved.detail}` : ''}`);
714
+ return { ok: false, error: `candidate resolution failed (${resolved.code})` };
715
+ }
716
+ try {
717
+ const manifest = readManifest(resolved.sourceRoot);
718
+ if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
719
+
720
+ const health = currentInstallationHealth({ home });
721
+
722
+ if (health.installed && health.healthy && health.pointer.version === manifest.version) {
723
+ log(`- already current (${manifest.version}); repairing registration/integrations idempotently`);
724
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
725
+ if (!activated) return { ok: false, error: 'activation failed' };
726
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
727
+ log('');
728
+ log('Done.');
729
+ return { ok: true, idempotent: true, version: manifest.version, path: health.pointer.path };
730
+ }
580
731
 
581
- const health = currentInstallationHealth({ home });
732
+ if (health.installed && health.healthy && compareVersions(manifest.version, health.pointer.version) < 0 && (candidate ?? sourceRoot) === undefined) {
733
+ log(`- registry latest (${manifest.version}) is not newer than the installed payload (${health.pointer.version}); nothing to update`);
734
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest: readManifest(health.pointer.path), log, installer });
735
+ if (!activated) return { ok: false, error: 'activation failed' };
736
+ return { ok: true, idempotent: true, version: health.pointer.version, path: health.pointer.path };
737
+ }
582
738
 
583
- if (health.installed && health.healthy && health.pointer.version === manifest.version) {
584
- log(`- already current (${manifest.version}); repairing registration/integrations idempotently`);
585
- const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
739
+ // Stale, unhealthy, older, or missing installation: stage the candidate
740
+ // fully and validate it before switching. The previous usable release is
741
+ // left in place until the replacement has been committed and activated.
742
+ if (health.installed && !health.healthy) {
743
+ log('- existing installation is stale or incomplete; repairing via fresh candidate staging');
744
+ } else if (health.installed) {
745
+ log(`- updating managed payload ${health.pointer.version} -> ${manifest.version}`);
746
+ }
747
+
748
+ const staged = stageCandidatePayload({ sourceRoot: resolved.sourceRoot, home, log, npmInstaller });
749
+ if (!staged.ok) {
750
+ log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
751
+ return { ok: false, error: `staging failed (${staged.code})` };
752
+ }
753
+ log(`✓ candidate payload staged and validated (${manifest.version})`);
754
+
755
+ const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
756
+ log('✓ durable release committed under Crew-owned state');
757
+
758
+ const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
586
759
  if (!activated) return { ok: false, error: 'activation failed' };
760
+
587
761
  if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
762
+
763
+ noteLauncherDivergence({ log, home });
588
764
  log('');
589
765
  log('Done.');
590
- return { ok: true, idempotent: true, version: manifest.version, path: health.pointer.path };
591
- }
592
-
593
- // Stale, unhealthy, or missing installation: stage the candidate fully and
594
- // validate it before switching. The previous usable release is left in place
595
- // until the replacement has been committed and activated successfully.
596
- if (health.installed && !health.healthy) {
597
- log('- existing installation is stale or incomplete; repairing via fresh candidate staging');
766
+ log('Restart DeepSeek Harness and Codex Desktop.');
767
+ return { ok: true, updated: true, version: manifest.version, path: releaseDir };
768
+ } finally {
769
+ resolved.cleanup?.();
598
770
  }
771
+ }
599
772
 
600
- const staged = stageCandidatePayload({ sourceRoot: candidateRoot, home, log, npmInstaller });
601
- if (!staged.ok) {
602
- log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
603
- return { ok: false, error: `staging failed (${staged.code})` };
773
+ /**
774
+ * Compare dotted numeric versions; returns -1/0/1. Non-numeric segments fall
775
+ * back to string comparison so prerelease identifiers stay deterministic.
776
+ */
777
+ export function compareVersions(left, right) {
778
+ const asParts = (value) => String(value ?? '').split('.');
779
+ const a = asParts(left);
780
+ const b = asParts(right);
781
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
782
+ const x = a[index] ?? '0';
783
+ const y = b[index] ?? '0';
784
+ const xn = /^\d+$/.test(x);
785
+ const yn = /^\d+$/.test(y);
786
+ if (xn && yn) {
787
+ const diff = Number(x) - Number(y);
788
+ if (diff !== 0) return diff < 0 ? -1 : 1;
789
+ } else if (xn !== yn) {
790
+ // npm semantics: a numeric release segment outranks a prerelease segment.
791
+ return xn ? 1 : -1;
792
+ } else if (x !== y) {
793
+ return x < y ? -1 : 1;
794
+ }
604
795
  }
605
- log(`✓ candidate payload staged and validated (${manifest.version})`);
606
-
607
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
608
- log('✓ durable release committed under Crew-owned state');
609
-
610
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
611
- if (!activated) return { ok: false, error: 'activation failed' };
612
-
613
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
614
-
615
- log('');
616
- log('Done.');
617
- log('Restart DeepSeek Harness and Codex Desktop.');
618
- return { ok: true, updated: true, version: manifest.version, path: releaseDir };
796
+ return 0;
619
797
  }
620
798
 
621
799
  export function npxStatus({
@@ -664,8 +842,12 @@ export function npxStatus({
664
842
  const codex = st?.codex?.installed ? 'installed' : 'not installed';
665
843
  const claude = st?.claude?.installed ? 'installed' : 'not installed';
666
844
 
667
- log(`DSH Crew CLI (npx candidate): ${candidateVersion ?? 'unknown'}`);
668
- log(`Installed DSH Crew: ${installedLine}`);
845
+ log(`DSH Crew launcher/candidate: ${candidateVersion ?? 'unknown'}`);
846
+ log(`Installed DSH Crew payload: ${installedLine}`);
847
+ if (candidateVersion && installedVersion && candidateVersion !== installedVersion) {
848
+ log(`- launcher and managed payload differ; the payload is authoritative for runtime behavior.`);
849
+ log(` Refresh the launcher with: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
850
+ }
669
851
  log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile; official web profile ignored)`);
670
852
  log(`Codex Desktop integration: ${codex}`);
671
853
  log(`Claude Code integration: ${claude}`);
@@ -736,25 +918,40 @@ export async function npxUninstall({
736
918
 
737
919
  // ---- CLI dispatch --------------------------------------------------------------
738
920
 
739
- export const USAGE = `usage: dsh-crew <command> [--purge]
921
+ export const USAGE = `usage: dsh-crew <command> [--purge] [--candidate <path>]
740
922
 
741
923
  Commands:
742
924
  install persist the candidate package into Crew-owned state and register it
743
- status read-only report of candidate/installed versions and integrations
744
- update upgrade-aware update: stages/validates first, repairs, idempotent when current
925
+ status read-only report of launcher/installed versions and integrations
926
+ update resolve the newest permitted package from the configured npm registry (or
927
+ --candidate), stage and validate it, then activate; idempotent when current
745
928
  uninstall remove the Crew-managed payload, registration, and integrations (config kept)
746
929
 
747
930
  Options:
748
- --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
749
- --help show this help
931
+ --candidate <path> update from a local payload directory or packed .tgz instead of the registry
932
+ --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
933
+ --help show this help
750
934
 
935
+ Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
751
936
  Source checkouts use scripts/setup.mjs instead.`;
752
937
 
753
938
  function normalizeCommand(argv) {
754
939
  const flags = argv.slice(1);
940
+ let candidate;
941
+ for (let index = 0; index < flags.length; index += 1) {
942
+ if (flags[index] === '--candidate') {
943
+ candidate = flags[index + 1];
944
+ flags.splice(index, 2);
945
+ index -= 1;
946
+ } else if (flags[index]?.startsWith('--candidate=')) {
947
+ candidate = flags[index].slice('--candidate='.length);
948
+ flags.splice(index, 1);
949
+ index -= 1;
950
+ }
951
+ }
755
952
  const knownFlags = new Set(['--purge']);
756
953
  const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
757
- return { command: argv[0], purge: flags.includes('--purge'), unknown };
954
+ return { command: argv[0], purge: flags.includes('--purge'), candidate, unknown };
758
955
  }
759
956
 
760
957
  /**
@@ -766,7 +963,7 @@ export async function runNpxCli({
766
963
  error = console.error,
767
964
  commands = {},
768
965
  } = {}) {
769
- const { command, purge, unknown } = normalizeCommand(argv);
966
+ const { command, purge, candidate, unknown } = normalizeCommand(argv);
770
967
  if (command === '--help' || command === '-h' || command === 'help') {
771
968
  log(USAGE);
772
969
  return 0;
@@ -786,9 +983,10 @@ export async function runNpxCli({
786
983
  update: commands.update ?? npxUpdate,
787
984
  uninstall: commands.uninstall ?? npxUninstall,
788
985
  };
789
- const result = command === 'uninstall'
790
- ? await actions.uninstall({ purge, log })
791
- : await actions[command]({ log });
986
+ let result;
987
+ if (command === 'uninstall') result = await actions.uninstall({ purge, log });
988
+ else if (command === 'update') result = await actions.update({ candidate, log });
989
+ else result = await actions[command]({ log });
792
990
  return result?.ok === false ? 1 : 0;
793
991
  } catch (err) {
794
992
  error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
@@ -8,7 +8,7 @@
8
8
  // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
9
  // exact same compatibility rules.
10
10
 
11
- export const RUNTIME_VERSION = '0.3.3';
11
+ export const RUNTIME_VERSION = '0.3.4';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([