gsdd-cli 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,616 @@
1
+ import os from 'os';
2
+ import { spawnSync } from 'child_process';
3
+ import { existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { promptMultiSelect } from './init-prompts.mjs';
6
+ import {
7
+ buildPortableSkillEntries,
8
+ getDelegateContent,
9
+ renderOpenCodeCommandContent,
10
+ renderSkillContent,
11
+ } from './rendering.mjs';
12
+ import {
13
+ renderClaudeApproachExplorer,
14
+ renderClaudePlanChecker,
15
+ renderClaudePlanCommand,
16
+ renderClaudePlanSkill,
17
+ CLAUDE_MODEL_PROFILES,
18
+ } from '../adapters/claude.mjs';
19
+ import {
20
+ renderOpenCodeApproachExplorer,
21
+ renderOpenCodePlanChecker,
22
+ renderOpenCodePlanCommand,
23
+ } from '../adapters/opencode.mjs';
24
+ import {
25
+ renderCodexApproachExplorer,
26
+ renderCodexPlanChecker,
27
+ } from '../adapters/codex.mjs';
28
+ import {
29
+ GLOBAL_MANIFEST_FILENAME,
30
+ pruneStaleManifestTrackedFiles,
31
+ readGlobalManifest,
32
+ writeGlobalManifest,
33
+ writeManifestTrackedFile,
34
+ } from './global-manifest.mjs';
35
+ import { parseFlagValue } from './cli-utils.mjs';
36
+
37
+ export const GLOBAL_AGENT_OPTIONS = [
38
+ {
39
+ id: 'claude',
40
+ label: 'Claude Code',
41
+ description: 'Install global skills, slash-command alias, and native GSDD agents under ~/.claude.',
42
+ },
43
+ {
44
+ id: 'opencode',
45
+ label: 'OpenCode',
46
+ description: 'Install shared global skills under ~/.agents plus slash commands and native GSDD agents under ~/.config/opencode.',
47
+ },
48
+ {
49
+ id: 'codex',
50
+ label: 'Codex CLI',
51
+ description: 'Install shared global skills under ~/.agents and native GSDD agents under ~/.codex.',
52
+ },
53
+ {
54
+ id: 'copilot',
55
+ label: 'GitHub Copilot CLI',
56
+ description: 'Install shared global skills under ~/.agents and Copilot agent profiles under ~/.copilot.',
57
+ },
58
+ ];
59
+
60
+ const GLOBAL_AGENT_IDS = GLOBAL_AGENT_OPTIONS.map((option) => option.id);
61
+
62
+ function getHomeDir() {
63
+ return process.env.GSDD_TEST_HOME || os.homedir();
64
+ }
65
+
66
+ function getConfigHome(homeDir, env = process.env) {
67
+ return env.XDG_CONFIG_HOME || join(homeDir, '.config');
68
+ }
69
+
70
+ export function resolveGlobalInstallRoots({ homeDir = getHomeDir(), env = process.env } = {}) {
71
+ const configHome = getConfigHome(homeDir, env);
72
+ const agentSkills = join(homeDir, '.agents');
73
+ return {
74
+ home: homeDir,
75
+ configHome,
76
+ claude: env.CLAUDE_CONFIG_DIR || join(homeDir, '.claude'),
77
+ opencode: env.OPENCODE_CONFIG_DIR || join(configHome, 'opencode'),
78
+ agentSkills,
79
+ opencodeSkills: agentSkills,
80
+ codex: env.CODEX_HOME || join(homeDir, '.codex'),
81
+ codexSkills: agentSkills,
82
+ copilot: env.COPILOT_HOME || env.COPILOT_CONFIG_DIR || join(homeDir, '.copilot'),
83
+ copilotSkills: agentSkills,
84
+ };
85
+ }
86
+
87
+ function parseGlobalToolsFlag(args) {
88
+ const toolsFlag = parseFlagValue(args, '--tools');
89
+ if (!toolsFlag.value) return [];
90
+ return toolsFlag.value.split(',').map((tool) => tool.trim()).filter(Boolean);
91
+ }
92
+
93
+ function normalizeGlobalTools(rawTools) {
94
+ if (rawTools.length === 0) return [];
95
+ const expanded = rawTools.flatMap((tool) => (tool === 'all' ? GLOBAL_AGENT_IDS : [tool]));
96
+ return [...new Set(expanded)].filter(Boolean);
97
+ }
98
+
99
+ function validateGlobalTools(tools) {
100
+ const invalid = tools.filter((tool) => !GLOBAL_AGENT_IDS.includes(tool));
101
+ if (invalid.length === 0) return null;
102
+ return `ERROR: unsupported global install target(s): ${invalid.join(', ')}. Use --tools claude,opencode,codex,copilot or --tools all.`;
103
+ }
104
+
105
+ function displayPath(filePath) {
106
+ return filePath.replace(/\\/g, '/');
107
+ }
108
+
109
+ async function resolveGlobalInstallTargets({ args, promptApi, output }) {
110
+ const parsedTools = normalizeGlobalTools(parseGlobalToolsFlag(args));
111
+ if (parsedTools.length > 0) return parsedTools;
112
+
113
+ if (!process.stdin.isTTY) {
114
+ return [];
115
+ }
116
+
117
+ if (promptApi?.selectGlobalInstallTargets) {
118
+ return promptApi.selectGlobalInstallTargets(GLOBAL_AGENT_OPTIONS.map((option) => ({ ...option, selected: false, detected: false })));
119
+ }
120
+
121
+ return promptMultiSelect({
122
+ input: process.stdin,
123
+ output,
124
+ title: 'Select global agent installs',
125
+ hint: 'Space toggles, Enter confirms.',
126
+ choices: GLOBAL_AGENT_OPTIONS.map((option) => ({ ...option, selected: false, detected: false })),
127
+ });
128
+ }
129
+
130
+ function buildClaudeGlobalEntries(ctx, rootDir) {
131
+ const checkerModelAlias = CLAUDE_MODEL_PROFILES.balanced;
132
+ const explorerModelAlias = CLAUDE_MODEL_PROFILES.quality;
133
+
134
+ const entries = ctx.workflows.map((workflow) => ({
135
+ relativePath: `skills/${workflow.name}/SKILL.md`,
136
+ content: workflow.name === 'gsdd-plan' ? renderClaudePlanSkill({ portableContractPath: null }) : renderSkillContent(workflow),
137
+ }));
138
+
139
+ entries.push(
140
+ { relativePath: 'commands/gsdd-plan.md', content: renderClaudePlanCommand({ skillPath: displayPath(join(rootDir, 'skills', 'gsdd-plan', 'SKILL.md')) }) },
141
+ { relativePath: 'agents/gsdd-plan-checker.md', content: renderClaudePlanChecker(getDelegateContent('plan-checker.md'), checkerModelAlias) },
142
+ { relativePath: 'agents/gsdd-approach-explorer.md', content: renderClaudeApproachExplorer(getDelegateContent('approach-explorer.md'), explorerModelAlias) }
143
+ );
144
+
145
+ return entries;
146
+ }
147
+
148
+ function buildOpenCodeGlobalCommandEntries(ctx, rootDir) {
149
+ return ctx.workflows.map((workflow) => ({
150
+ relativePath: `commands/${workflow.name}.md`,
151
+ content: workflow.name === 'gsdd-plan'
152
+ ? renderOpenCodePlanCommand({ skillPath: displayPath(join(rootDir, 'skills', 'gsdd-plan', 'SKILL.md')) })
153
+ : renderOpenCodeCommandContent(workflow),
154
+ }));
155
+ }
156
+
157
+ function buildOpenCodeGlobalAgentEntries() {
158
+ return [
159
+ { relativePath: 'agents/gsdd-plan-checker.md', content: renderOpenCodePlanChecker(getDelegateContent('plan-checker.md')) },
160
+ { relativePath: 'agents/gsdd-approach-explorer.md', content: renderOpenCodeApproachExplorer(getDelegateContent('approach-explorer.md')) },
161
+ ];
162
+ }
163
+
164
+ function buildOpenCodeGlobalEntries(ctx, rootDir) {
165
+ return [
166
+ ...buildOpenCodeGlobalCommandEntries(ctx, rootDir),
167
+ ...buildOpenCodeGlobalAgentEntries(ctx),
168
+ ];
169
+ }
170
+
171
+ function buildAgentCompatibleGlobalSkillEntries(ctx) {
172
+ return buildPortableSkillEntries(ctx.workflows).map((entry) => ({
173
+ relativePath: entry.relativePath.replace(/^\.agents\/skills\//, 'skills/'),
174
+ content: entry.content,
175
+ }));
176
+ }
177
+
178
+ function buildCodexGlobalAgentEntries() {
179
+ return [
180
+ { relativePath: 'agents/gsdd-plan-checker.toml', content: renderCodexPlanChecker(getDelegateContent('plan-checker.md')) },
181
+ { relativePath: 'agents/gsdd-approach-explorer.toml', content: renderCodexApproachExplorer(getDelegateContent('approach-explorer.md')) },
182
+ ];
183
+ }
184
+
185
+ function renderCopilotAgent({ name, description, tools, body, disableModelInvocation = true }) {
186
+ const toolList = tools.map((tool) => `"${tool}"`).join(', ');
187
+ return `---
188
+ name: ${name}
189
+ description: ${description}
190
+ target: github-copilot
191
+ tools: [${toolList}]
192
+ disable-model-invocation: ${disableModelInvocation ? 'true' : 'false'}
193
+ ---
194
+
195
+ ${body.trim()}
196
+ `;
197
+ }
198
+
199
+ function buildCopilotGlobalAgentEntries() {
200
+ return [
201
+ {
202
+ relativePath: 'agents/gsdd-plan-checker.agent.md',
203
+ content: renderCopilotAgent({
204
+ name: 'gsdd-plan-checker',
205
+ description: 'Fresh-context plan checker for GSDD plan drafts. Review-only; never edits plans directly.',
206
+ tools: ['read', 'search'],
207
+ body: getDelegateContent('plan-checker.md'),
208
+ }),
209
+ },
210
+ {
211
+ relativePath: 'agents/gsdd-approach-explorer.agent.md',
212
+ content: renderCopilotAgent({
213
+ name: 'gsdd-approach-explorer',
214
+ description: 'Explores implementation approaches for a phase and aligns with the user before planning begins.',
215
+ tools: ['read', 'search', 'edit', 'web', 'agent'],
216
+ body: getDelegateContent('approach-explorer.md'),
217
+ }),
218
+ },
219
+ ];
220
+ }
221
+
222
+ function buildGlobalEntries(target, ctx, rootDir) {
223
+ if (target === 'claude') return buildClaudeGlobalEntries(ctx, rootDir);
224
+ if (target === 'opencode') return buildOpenCodeGlobalEntries(ctx, rootDir);
225
+ if (target === 'copilot') return buildCopilotGlobalAgentEntries();
226
+ return [];
227
+ }
228
+
229
+ function buildGlobalInstallSpecs(target, roots, ctx) {
230
+ if (target === 'codex') {
231
+ return [
232
+ {
233
+ runtime: 'agent-skills',
234
+ rootDir: roots.codexSkills,
235
+ entries: buildAgentCompatibleGlobalSkillEntries(ctx),
236
+ },
237
+ {
238
+ runtime: 'codex',
239
+ rootDir: roots.codex,
240
+ entries: buildCodexGlobalAgentEntries(),
241
+ },
242
+ ];
243
+ }
244
+
245
+ if (target === 'opencode' && roots.opencode !== roots.opencodeSkills) {
246
+ return [
247
+ {
248
+ runtime: 'agent-skills',
249
+ rootDir: roots.opencodeSkills,
250
+ entries: buildAgentCompatibleGlobalSkillEntries(ctx),
251
+ },
252
+ {
253
+ runtime: 'opencode',
254
+ rootDir: roots.opencode,
255
+ entries: [
256
+ ...buildOpenCodeGlobalCommandEntries(ctx, roots.opencodeSkills),
257
+ ...buildOpenCodeGlobalAgentEntries(ctx),
258
+ ],
259
+ },
260
+ ];
261
+ }
262
+
263
+ if (target === 'copilot' && roots.copilot !== roots.copilotSkills) {
264
+ return [
265
+ {
266
+ runtime: 'agent-skills',
267
+ rootDir: roots.copilotSkills,
268
+ entries: buildAgentCompatibleGlobalSkillEntries(ctx),
269
+ },
270
+ {
271
+ runtime: 'copilot',
272
+ rootDir: roots.copilot,
273
+ entries: buildCopilotGlobalAgentEntries(),
274
+ },
275
+ ];
276
+ }
277
+
278
+ return [
279
+ {
280
+ runtime: target,
281
+ rootDir: roots[target],
282
+ entries: buildGlobalEntries(target, ctx, roots[target]),
283
+ },
284
+ ];
285
+ }
286
+
287
+ function preflightInstallSpec(spec) {
288
+ const previousManifest = readGlobalManifest(spec.rootDir);
289
+ const nextFiles = {};
290
+ const fileResults = spec.entries.map((entry) => writeManifestTrackedFile({
291
+ rootDir: spec.rootDir,
292
+ relativePath: entry.relativePath,
293
+ content: entry.content,
294
+ previousManifest,
295
+ nextFiles,
296
+ dryRun: true,
297
+ }));
298
+ const pruneResults = pruneStaleManifestTrackedFiles({
299
+ rootDir: spec.rootDir,
300
+ previousManifest,
301
+ nextFiles,
302
+ dryRun: true,
303
+ });
304
+ const results = [...fileResults, ...pruneResults];
305
+
306
+ return {
307
+ ...spec,
308
+ previousManifest,
309
+ nextFiles,
310
+ results,
311
+ blocked: results.filter((result) => result.status.startsWith('skipped_')),
312
+ };
313
+ }
314
+
315
+ function writeInstallSpec(plan, ctx) {
316
+ const nextFiles = {};
317
+ const results = plan.entries.map((entry) => writeManifestTrackedFile({
318
+ rootDir: plan.rootDir,
319
+ relativePath: entry.relativePath,
320
+ content: entry.content,
321
+ previousManifest: plan.previousManifest,
322
+ nextFiles,
323
+ dryRun: false,
324
+ }));
325
+ results.push(...pruneStaleManifestTrackedFiles({
326
+ rootDir: plan.rootDir,
327
+ previousManifest: plan.previousManifest,
328
+ nextFiles,
329
+ dryRun: false,
330
+ }));
331
+
332
+ writeGlobalManifest(plan.rootDir, {
333
+ product: 'Workspine',
334
+ packageName: ctx.packageName,
335
+ packageVersion: ctx.packageVersion,
336
+ frameworkVersion: ctx.frameworkVersion,
337
+ runtime: plan.runtime,
338
+ generatedAt: new Date().toISOString(),
339
+ files: nextFiles,
340
+ });
341
+
342
+ return results;
343
+ }
344
+
345
+ function installTarget({ target, roots, ctx, dryRun }) {
346
+ const plans = buildGlobalInstallSpecs(target, roots, ctx).map(preflightInstallSpec);
347
+ const blocked = plans.flatMap((plan) => plan.blocked);
348
+ const results = dryRun || blocked.length > 0
349
+ ? plans.flatMap((plan) => plan.results)
350
+ : plans.flatMap((plan) => writeInstallSpec(plan, ctx));
351
+
352
+ const rootDir = plans.map((plan) => plan.rootDir).join(', ');
353
+
354
+ return {
355
+ target,
356
+ rootDir,
357
+ manifest: plans.map((plan) => `${plan.rootDir}/${GLOBAL_MANIFEST_FILENAME}`).join(', '),
358
+ results,
359
+ blocked,
360
+ writtenCount: results.filter((result) => result.status === 'written').length,
361
+ unchangedCount: results.filter((result) => result.status === 'unchanged').length,
362
+ wouldWriteCount: results.filter((result) => result.status === 'would_write').length,
363
+ removedCount: results.filter((result) => result.status === 'removed_stale' || result.status === 'removed_missing').length,
364
+ wouldRemoveCount: results.filter((result) => result.status === 'would_remove').length,
365
+ };
366
+ }
367
+
368
+ function makeEnv(overrides = {}) {
369
+ return Object.fromEntries(Object.entries({
370
+ ...process.env,
371
+ ...overrides,
372
+ }).filter(([, value]) => value !== undefined && value !== null));
373
+ }
374
+
375
+ function quoteShellPart(value) {
376
+ const text = String(value);
377
+ if (/^[A-Za-z0-9_./:=@-]+$/.test(text)) return text;
378
+ return `"${text.replace(/"/g, '\\"')}"`;
379
+ }
380
+
381
+ function runProbe(command, args, { cwd, env, timeoutMs = 30000, probeRunner } = {}) {
382
+ if (probeRunner) return probeRunner(command, args, { cwd, env, timeoutMs });
383
+ const commandLine = [command, ...args].map(quoteShellPart).join(' ');
384
+ const result = process.platform === 'win32'
385
+ ? spawnSync(commandLine, { cwd, env, encoding: 'utf-8', shell: true, timeout: timeoutMs })
386
+ : spawnSync(command, args, { cwd, env, encoding: 'utf-8', shell: false, timeout: timeoutMs });
387
+ return {
388
+ status: result.status,
389
+ signal: result.signal,
390
+ stdout: result.stdout || '',
391
+ stderr: result.stderr || '',
392
+ error: result.error ? String(result.error.message || result.error) : null,
393
+ };
394
+ }
395
+
396
+ function checkLayout({ target, roots, ctx }) {
397
+ const issues = [];
398
+ const specs = buildGlobalInstallSpecs(target, roots, ctx);
399
+
400
+ for (const spec of specs) {
401
+ const manifest = readGlobalManifest(spec.rootDir);
402
+ if (!manifest) {
403
+ issues.push(`${spec.runtime}: missing ${GLOBAL_MANIFEST_FILENAME} under ${spec.rootDir}`);
404
+ continue;
405
+ }
406
+ if (manifest.runtime !== spec.runtime) {
407
+ issues.push(`${spec.runtime}: manifest runtime is ${manifest.runtime}`);
408
+ }
409
+ for (const entry of spec.entries) {
410
+ const absolutePath = join(spec.rootDir, entry.relativePath);
411
+ if (!existsSync(absolutePath)) {
412
+ issues.push(`${spec.runtime}: missing ${entry.relativePath}`);
413
+ }
414
+ if (!manifest.files?.[entry.relativePath]) {
415
+ issues.push(`${spec.runtime}: manifest does not track ${entry.relativePath}`);
416
+ }
417
+ }
418
+ }
419
+
420
+ return issues.length === 0
421
+ ? { target, check: 'layout', status: 'passed', message: 'documented files and manifests exist' }
422
+ : { target, check: 'layout', status: 'failed', message: issues.join('; ') };
423
+ }
424
+
425
+ function checkOpenCodeRuntime({ roots, cwd, probeRunner }) {
426
+ const result = runProbe('opencode', ['debug', 'skill'], {
427
+ cwd,
428
+ env: makeEnv({
429
+ HOME: roots.home,
430
+ USERPROFILE: roots.home,
431
+ XDG_CONFIG_HOME: roots.configHome,
432
+ OPENCODE_CONFIG_DIR: roots.opencode,
433
+ }),
434
+ probeRunner,
435
+ });
436
+ const output = `${result.stdout}\n${result.stderr}`;
437
+
438
+ if (result.error) {
439
+ return { target: 'opencode', check: 'runtime_discovery', status: 'skipped', message: `opencode probe could not start: ${result.error}` };
440
+ }
441
+ if (result.status !== 0) {
442
+ return { target: 'opencode', check: 'runtime_discovery', status: 'failed', message: `opencode debug skill exited ${result.status}: ${output.trim()}` };
443
+ }
444
+ if (!/\bgsdd-plan\b/.test(output)) {
445
+ return { target: 'opencode', check: 'runtime_discovery', status: 'failed', message: 'opencode debug skill did not list gsdd-plan' };
446
+ }
447
+ return { target: 'opencode', check: 'runtime_discovery', status: 'passed', message: 'opencode debug skill listed gsdd-plan' };
448
+ }
449
+
450
+ function liveProbeCommand(target, roots) {
451
+ const prompt = 'Do not edit files or run tools. If a Workspine skill named gsdd-plan is available in this session, answer exactly GSDD_SKILL_OK. Otherwise answer GSDD_SKILL_MISSING.';
452
+ if (target === 'claude') {
453
+ const claudePrompt = '/gsdd-plan Verification mode only. Do not edit files, do not invoke subagents, and do not run shell commands. If this Workspine gsdd-plan command resolved successfully, answer exactly GSDD_SKILL_OK. Otherwise answer exactly GSDD_SKILL_MISSING.';
454
+ return {
455
+ command: 'claude',
456
+ args: ['-p', claudePrompt, '--no-session-persistence', '--max-budget-usd', '0.25', '--output-format', 'text', '--tools', 'Read'],
457
+ env: makeEnv({ CLAUDE_CONFIG_DIR: roots.claude }),
458
+ };
459
+ }
460
+ if (target === 'codex') {
461
+ return {
462
+ command: 'codex',
463
+ args: ['exec', '-c', 'model_reasoning_effort="high"', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check', prompt],
464
+ env: makeEnv({ HOME: roots.home, USERPROFILE: roots.home, CODEX_HOME: roots.codex }),
465
+ };
466
+ }
467
+ if (target === 'copilot') {
468
+ return {
469
+ command: 'copilot',
470
+ args: ['-p', prompt, '--effort', 'high', '--config-dir', roots.copilot, '--silent', '--no-custom-instructions'],
471
+ env: makeEnv({ HOME: roots.home, USERPROFILE: roots.home, COPILOT_HOME: roots.copilot }),
472
+ };
473
+ }
474
+ return null;
475
+ }
476
+
477
+ function checkLiveRuntime({ target, roots, cwd, probeRunner }) {
478
+ const probe = liveProbeCommand(target, roots);
479
+ if (!probe) {
480
+ return { target, check: 'runtime_discovery', status: 'unproven', message: 'no live probe is defined for this target' };
481
+ }
482
+ const result = runProbe(probe.command, probe.args, {
483
+ cwd,
484
+ env: probe.env,
485
+ timeoutMs: 120000,
486
+ probeRunner,
487
+ });
488
+ const output = `${result.stdout}\n${result.stderr}`;
489
+
490
+ if (result.error) {
491
+ return { target, check: 'runtime_discovery', status: 'failed', message: `${target} live probe could not start: ${result.error}` };
492
+ }
493
+ if (result.status !== 0) {
494
+ return { target, check: 'runtime_discovery', status: 'failed', message: `${target} live probe exited ${result.status}: ${output.trim()}` };
495
+ }
496
+ if (!/GSDD_SKILL_OK/.test(output)) {
497
+ return { target, check: 'runtime_discovery', status: 'failed', message: `${target} live probe did not confirm gsdd-plan: ${output.trim()}` };
498
+ }
499
+ return { target, check: 'runtime_discovery', status: 'passed', message: `${target} live probe confirmed gsdd-plan` };
500
+ }
501
+
502
+ export function verifyGlobalRuntimeInstall({ targets, roots, ctx, liveRuntime = false, probeRunner } = {}) {
503
+ const checks = [];
504
+
505
+ for (const target of targets) {
506
+ checks.push(checkLayout({ target, roots, ctx }));
507
+ if (target === 'opencode') {
508
+ checks.push(checkOpenCodeRuntime({ roots, cwd: ctx.cwd, probeRunner }));
509
+ continue;
510
+ }
511
+ checks.push(liveRuntime
512
+ ? checkLiveRuntime({ target, roots, cwd: ctx.cwd, probeRunner })
513
+ : {
514
+ target,
515
+ check: 'runtime_discovery',
516
+ status: 'unproven',
517
+ message: 'no model-free runtime discovery probe is available; use the internal liveRuntime pressure harness for authenticated CLI sessions',
518
+ });
519
+ }
520
+
521
+ return {
522
+ checks,
523
+ failed: checks.filter((check) => check.status === 'failed'),
524
+ unproven: checks.filter((check) => check.status === 'unproven'),
525
+ skipped: checks.filter((check) => check.status === 'skipped'),
526
+ };
527
+ }
528
+
529
+ export function createCmdInstall(ctx) {
530
+ return async function cmdInstall(...installArgs) {
531
+ const globalFlag = installArgs.includes('--global') || installArgs.includes('-g');
532
+ const localFlag = installArgs.includes('--local');
533
+ const dryRun = installArgs.includes('--dry');
534
+ const verifyRuntime = installArgs.includes('--verify-runtime');
535
+ const liveRuntime = installArgs.includes('--live-runtime');
536
+ const toolsFlag = parseFlagValue(installArgs, '--tools');
537
+
538
+ if (toolsFlag.invalid) {
539
+ console.error('ERROR: --tools requires a value. Example: npx -y gsdd-cli install --global --tools claude,opencode');
540
+ process.exitCode = 1;
541
+ return;
542
+ }
543
+
544
+ if (localFlag) {
545
+ console.error('ERROR: local project installation is `npx -y gsdd-cli init`. Global installation is `npx -y gsdd-cli install --global`.');
546
+ process.exitCode = 1;
547
+ return;
548
+ }
549
+
550
+ if (!globalFlag) {
551
+ console.error('ERROR: install currently requires --global. For repo-local setup, run `npx -y gsdd-cli init`.');
552
+ process.exitCode = 1;
553
+ return;
554
+ }
555
+
556
+ if (verifyRuntime || liveRuntime) {
557
+ console.error('ERROR: runtime probing is not part of the public install command. Use `npx -y gsdd-cli health` for repo-local setup checks; runtime pressure probes live in tests.');
558
+ process.exitCode = 1;
559
+ return;
560
+ }
561
+
562
+ const targets = await resolveGlobalInstallTargets({
563
+ args: installArgs,
564
+ promptApi: ctx.globalInstallPromptApi,
565
+ output: process.stdout,
566
+ });
567
+
568
+ if (targets.length === 0) {
569
+ console.error('ERROR: no global install targets selected. Use --tools claude,opencode,codex,copilot or run interactively.');
570
+ process.exitCode = 1;
571
+ return;
572
+ }
573
+
574
+ const invalidMessage = validateGlobalTools(targets);
575
+ if (invalidMessage) {
576
+ console.error(invalidMessage);
577
+ process.exitCode = 1;
578
+ return;
579
+ }
580
+
581
+ const roots = resolveGlobalInstallRoots();
582
+ console.log(`Workspine global install - installing runtime surfaces${dryRun ? ' (dry run)' : ''}\n`);
583
+
584
+ const reports = targets.map((target) => installTarget({
585
+ target,
586
+ roots,
587
+ ctx,
588
+ dryRun,
589
+ }));
590
+
591
+ let hasBlocked = false;
592
+ for (const report of reports) {
593
+ const actionSummary = dryRun
594
+ ? `${report.wouldWriteCount} file(s) would be written, ${report.wouldRemoveCount} stale file(s) would be removed`
595
+ : `${report.writtenCount} written, ${report.unchangedCount} unchanged, ${report.removedCount} stale removed`;
596
+ console.log(` - ${report.target}: ${actionSummary} (${report.rootDir})`);
597
+ if (report.blocked.length > 0) {
598
+ hasBlocked = true;
599
+ for (const blocked of report.blocked.slice(0, 5)) {
600
+ console.log(` WARN ${blocked.relativePath}: ${blocked.message}`);
601
+ }
602
+ if (report.blocked.length > 5) {
603
+ console.log(` WARN ${report.blocked.length - 5} more file(s) were skipped`);
604
+ }
605
+ }
606
+ }
607
+
608
+ if (hasBlocked) {
609
+ console.error('\nGlobal install finished with skipped files. Review them before re-running or deleting local modifications.');
610
+ process.exitCode = 1;
611
+ return;
612
+ }
613
+
614
+ console.log('\nGlobal install complete.');
615
+ };
616
+ }