@hegemonart/get-design-done 1.23.0 → 1.24.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,217 @@
1
+ /**
2
+ * hedge-ensemble.cjs — AdaNormalHedge weighted-majority over verifier
3
+ * + checker agents (Plan 23.5-02).
4
+ *
5
+ * Parameter-free: no manual learning rate. Weights self-adapt via
6
+ * the AdaNormalHedge regret-bound trick — η is recomputed each round
7
+ * from cumulative loss variance, eliminating the typical "tune η or
8
+ * suffer" tax.
9
+ *
10
+ * Weights persist at `.design/telemetry/hedge-weights.json` (atomic
11
+ * .tmp + rename). Schema:
12
+ * { schema_version: '1.0.0',
13
+ * generated_at: ISO,
14
+ * pools: { <poolId>: { agents: { <agentId>: {weight, cumLoss, cumLoss2, rounds} } } } }
15
+ *
16
+ * Reused by adaptive_mode = "hedge" or "full" — see Plan 23.5-04.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const DEFAULT_WEIGHTS_PATH = '.design/telemetry/hedge-weights.json';
25
+ const SCHEMA_VERSION = '1.0.0';
26
+ const DEFAULT_VOTE_THRESHOLD = 0.5;
27
+
28
+ function resolvePath(opts = {}) {
29
+ if (opts.weightsPath) {
30
+ return path.isAbsolute(opts.weightsPath)
31
+ ? opts.weightsPath
32
+ : path.resolve(opts.baseDir ?? process.cwd(), opts.weightsPath);
33
+ }
34
+ return path.resolve(opts.baseDir ?? process.cwd(), DEFAULT_WEIGHTS_PATH);
35
+ }
36
+
37
+ /**
38
+ * @returns {{schema_version: string, generated_at: string, pools: object}}
39
+ */
40
+ function loadWeights(opts = {}) {
41
+ const p = resolvePath(opts);
42
+ if (!fs.existsSync(p)) {
43
+ return { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), pools: {} };
44
+ }
45
+ try {
46
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
47
+ if (!data.pools || typeof data.pools !== 'object') data.pools = {};
48
+ return data;
49
+ } catch {
50
+ return { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), pools: {} };
51
+ }
52
+ }
53
+
54
+ function saveWeights(state, opts = {}) {
55
+ const p = resolvePath(opts);
56
+ fs.mkdirSync(path.dirname(p), { recursive: true });
57
+ state.generated_at = new Date().toISOString();
58
+ const tmp = p + '.tmp';
59
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
60
+ fs.renameSync(tmp, p);
61
+ return p;
62
+ }
63
+
64
+ function ensurePool(state, poolId) {
65
+ if (!state.pools[poolId]) state.pools[poolId] = { agents: {} };
66
+ return state.pools[poolId];
67
+ }
68
+
69
+ function ensureAgent(pool, agentId) {
70
+ if (!pool.agents[agentId]) {
71
+ pool.agents[agentId] = {
72
+ weight: 1, // uniform start; normalised on read
73
+ cumLoss: 0,
74
+ cumLoss2: 0,
75
+ rounds: 0,
76
+ };
77
+ }
78
+ return pool.agents[agentId];
79
+ }
80
+
81
+ /**
82
+ * Apply one round of losses to a pool. losses: Record<agentId, lossInZeroOne>.
83
+ *
84
+ * AdaNormalHedge update (parameter-free):
85
+ * For each agent i:
86
+ * R_i = sum of (mean_loss - loss_i) over rounds (instantaneous regret)
87
+ * C_i = sum of (loss_i - mean_loss)^2 (cumulative loss variance)
88
+ * Set η_i = sqrt(ln(N) / max(1, C_i)) per-agent learning rate.
89
+ * weight_i ∝ Phi(R_i, C_i) where Phi is a positive-only potential.
90
+ *
91
+ * Simplification used here: w_i *= exp(-η * loss_i) with η derived
92
+ * from cumulative variance — gives the same regret bound as full
93
+ * AdaNormalHedge for the binary-loss case we care about (verifier
94
+ * pass/fail). Trade off: slightly less tight bound vs the full
95
+ * potential, but no need to plumb regret tracking everywhere.
96
+ *
97
+ * @param {{poolId: string, losses: Record<string, number>, baseDir?: string, weightsPath?: string, eta?: number}} input
98
+ * @returns {{weights: Record<string, number>, weightsPath: string}}
99
+ */
100
+ function loss(input) {
101
+ if (!input || typeof input.poolId !== 'string' || input.poolId.length === 0) {
102
+ throw new TypeError('hedge-ensemble.loss: poolId (string) required');
103
+ }
104
+ if (!input.losses || typeof input.losses !== 'object') {
105
+ throw new TypeError('hedge-ensemble.loss: losses (Record<string, number>) required');
106
+ }
107
+ const state = loadWeights(input);
108
+ const pool = ensurePool(state, input.poolId);
109
+ // First, ensure every losing agent exists.
110
+ for (const [agentId, lossVal] of Object.entries(input.losses)) {
111
+ if (typeof lossVal !== 'number' || Number.isNaN(lossVal)) {
112
+ throw new TypeError(`hedge-ensemble.loss: losses.${agentId} must be a number`);
113
+ }
114
+ }
115
+ for (const agentId of Object.keys(input.losses)) {
116
+ ensureAgent(pool, agentId);
117
+ }
118
+ const N = Object.keys(pool.agents).length;
119
+ // Compute mean loss this round (over agents that received a value).
120
+ const lossList = Object.values(input.losses);
121
+ const meanLoss = lossList.length > 0 ? lossList.reduce((a, b) => a + b, 0) / lossList.length : 0;
122
+ // Update each agent's cumulative variance + regret-like signal, then
123
+ // recompute its weight via exp(-η_i * loss_i).
124
+ for (const [agentId, rawLoss] of Object.entries(input.losses)) {
125
+ const lossVal = Math.min(1, Math.max(0, rawLoss));
126
+ const a = pool.agents[agentId];
127
+ const dev = lossVal - meanLoss;
128
+ a.cumLoss += lossVal;
129
+ a.cumLoss2 += dev * dev;
130
+ a.rounds += 1;
131
+ const eta =
132
+ typeof input.eta === 'number'
133
+ ? input.eta
134
+ : Math.sqrt(Math.log(Math.max(2, N)) / Math.max(1, a.cumLoss2));
135
+ a.weight *= Math.exp(-eta * lossVal);
136
+ if (!Number.isFinite(a.weight) || a.weight <= 0) a.weight = 1e-9;
137
+ }
138
+ // Renormalize.
139
+ const total = Object.values(pool.agents).reduce((s, x) => s + x.weight, 0) || 1;
140
+ /** @type {Record<string, number>} */
141
+ const out = {};
142
+ for (const agentId of Object.keys(pool.agents)) {
143
+ pool.agents[agentId].weight /= total;
144
+ out[agentId] = pool.agents[agentId].weight;
145
+ }
146
+ const writtenPath = saveWeights(state, input);
147
+ return { weights: out, weightsPath: writtenPath };
148
+ }
149
+
150
+ /**
151
+ * Compute the weighted-majority verdict for a pool given each agent's
152
+ * binary vote (pass=1, fail=0). Vote passes when the weighted sum
153
+ * exceeds threshold (default 0.5).
154
+ *
155
+ * @param {{poolId: string, votes: Record<string, 0|1|boolean>, threshold?: number, baseDir?: string, weightsPath?: string}} input
156
+ * @returns {{passes: boolean, weighted: number, threshold: number, perAgent: Record<string, {weight: number, vote: number}>}}
157
+ */
158
+ function vote(input) {
159
+ if (!input || typeof input.poolId !== 'string') {
160
+ throw new TypeError('hedge-ensemble.vote: poolId required');
161
+ }
162
+ if (!input.votes || typeof input.votes !== 'object') {
163
+ throw new TypeError('hedge-ensemble.vote: votes required');
164
+ }
165
+ const state = loadWeights(input);
166
+ const pool = ensurePool(state, input.poolId);
167
+ const threshold = typeof input.threshold === 'number' ? input.threshold : DEFAULT_VOTE_THRESHOLD;
168
+ let total = 0;
169
+ /** @type {Record<string, {weight: number, vote: number}>} */
170
+ const perAgent = {};
171
+ let weightSum = 0;
172
+ for (const [agentId, raw] of Object.entries(input.votes)) {
173
+ const v = raw === true || raw === 1 ? 1 : 0;
174
+ const a = ensureAgent(pool, agentId);
175
+ perAgent[agentId] = { weight: a.weight, vote: v };
176
+ total += a.weight * v;
177
+ weightSum += a.weight;
178
+ }
179
+ // Normalise the weighted sum against the SUM of voting agents'
180
+ // weights — agents in the pool that didn't vote this round don't
181
+ // dilute the result.
182
+ const weighted = weightSum > 0 ? total / weightSum : 0;
183
+ return { passes: weighted >= threshold, weighted, threshold, perAgent };
184
+ }
185
+
186
+ /**
187
+ * Read current weights for a pool, normalised over the pool's agents.
188
+ *
189
+ * @param {{poolId: string, baseDir?: string, weightsPath?: string}} input
190
+ * @returns {Record<string, number>}
191
+ */
192
+ function weights(input) {
193
+ if (!input || typeof input.poolId !== 'string') {
194
+ throw new TypeError('hedge-ensemble.weights: poolId required');
195
+ }
196
+ const state = loadWeights(input);
197
+ const pool = state.pools[input.poolId];
198
+ if (!pool) return {};
199
+ const total = Object.values(pool.agents).reduce((s, x) => s + x.weight, 0);
200
+ /** @type {Record<string, number>} */
201
+ const out = {};
202
+ for (const [k, v] of Object.entries(pool.agents)) {
203
+ out[k] = total > 0 ? v.weight / total : 0;
204
+ }
205
+ return out;
206
+ }
207
+
208
+ module.exports = {
209
+ loss,
210
+ vote,
211
+ weights,
212
+ loadWeights,
213
+ saveWeights,
214
+ DEFAULT_VOTE_THRESHOLD,
215
+ DEFAULT_WEIGHTS_PATH,
216
+ SCHEMA_VERSION,
217
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // Config-dir lookup chain for the get-design-done multi-runtime installer.
4
+ //
5
+ // Order of precedence (Phase 24 D-03):
6
+ // 1. Explicit override (--config-dir <dir> from caller).
7
+ // 2. Per-runtime env var (CLAUDE_CONFIG_DIR, OPENCODE_CONFIG_DIR, ...).
8
+ // 3. POSIX/Windows fallback at $HOME / $USERPROFILE + the runtime's
9
+ // configDirFallback (e.g. ~/.claude, ~/.gemini, ~/.config/opencode).
10
+ //
11
+ // resolveConfigDir returns the absolute path the installer should target.
12
+ // It does NOT verify the directory exists — that is the caller's job.
13
+
14
+ const path = require('node:path');
15
+ const os = require('node:os');
16
+
17
+ const { getRuntime, listRuntimes } = require('./runtimes.cjs');
18
+
19
+ function homeDir() {
20
+ return os.homedir();
21
+ }
22
+
23
+ function resolveConfigDir(runtimeId, opts) {
24
+ const runtime = getRuntime(runtimeId);
25
+ const overrides = (opts && opts.env) || process.env;
26
+ const explicit = opts && opts.configDir;
27
+
28
+ if (explicit && String(explicit).trim()) {
29
+ return path.resolve(String(explicit).trim());
30
+ }
31
+
32
+ const envValue = overrides[runtime.configDirEnv];
33
+ if (envValue && String(envValue).trim()) {
34
+ return path.resolve(String(envValue).trim());
35
+ }
36
+
37
+ const home = (opts && opts.home) || homeDir();
38
+ // configDirFallback may use POSIX separators (e.g. ".config/opencode") for
39
+ // cross-runtime portability — path.join + path.resolve normalises to the
40
+ // host platform's separator on output.
41
+ return path.resolve(path.join(home, ...runtime.configDirFallback.split('/')));
42
+ }
43
+
44
+ function resolveAllConfigDirs(opts) {
45
+ const out = {};
46
+ for (const runtime of listRuntimes()) {
47
+ out[runtime.id] = resolveConfigDir(runtime.id, opts);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ module.exports = {
53
+ resolveConfigDir,
54
+ resolveAllConfigDirs,
55
+ };
@@ -0,0 +1,244 @@
1
+ 'use strict';
2
+
3
+ // Per-runtime install/uninstall orchestrator. Returns a structured Result
4
+ // for every runtime touched so the caller can render a per-runtime summary.
5
+
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+
9
+ const { getRuntime } = require('./runtimes.cjs');
10
+ const { resolveConfigDir } = require('./config-dir.cjs');
11
+ const {
12
+ mergeClaudeSettings,
13
+ removeClaudeSettings,
14
+ buildAgentsFileContent,
15
+ isPluginOwned,
16
+ } = require('./merge.cjs');
17
+
18
+ function loadJsonOr(empty, filePath) {
19
+ if (!fs.existsSync(filePath)) return empty;
20
+ const raw = fs.readFileSync(filePath, 'utf8');
21
+ if (!raw.trim()) return empty;
22
+ try {
23
+ return JSON.parse(raw);
24
+ } catch (err) {
25
+ const friendly = new Error(
26
+ `get-design-done installer: cannot parse ${filePath} as JSON\n ${err.message}\n Fix the file manually or delete it, then re-run.`,
27
+ );
28
+ friendly.code = 'EINSTALLER_BAD_JSON';
29
+ friendly.path = filePath;
30
+ throw friendly;
31
+ }
32
+ }
33
+
34
+ function atomicWrite(target, contents) {
35
+ const tmp = `${target}.tmp-${process.pid}`;
36
+ fs.writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
37
+ fs.renameSync(tmp, target);
38
+ }
39
+
40
+ function ensureDir(dir, dryRun) {
41
+ if (fs.existsSync(dir)) return false;
42
+ if (!dryRun) fs.mkdirSync(dir, { recursive: true });
43
+ return true;
44
+ }
45
+
46
+ function installRuntime(runtimeId, opts) {
47
+ const runtime = getRuntime(runtimeId);
48
+ const dryRun = Boolean(opts && opts.dryRun);
49
+ const configDir = resolveConfigDir(runtimeId, opts);
50
+
51
+ if (runtime.kind === 'claude-marketplace') {
52
+ return installClaudeMarketplace(runtime, configDir, dryRun);
53
+ }
54
+ if (runtime.kind === 'agents-md') {
55
+ return installAgentsMd(runtime, configDir, dryRun);
56
+ }
57
+ throw new Error(`Unsupported runtime kind: ${runtime.kind}`);
58
+ }
59
+
60
+ function uninstallRuntime(runtimeId, opts) {
61
+ const runtime = getRuntime(runtimeId);
62
+ const dryRun = Boolean(opts && opts.dryRun);
63
+ const configDir = resolveConfigDir(runtimeId, opts);
64
+
65
+ if (runtime.kind === 'claude-marketplace') {
66
+ return uninstallClaudeMarketplace(runtime, configDir, dryRun);
67
+ }
68
+ if (runtime.kind === 'agents-md') {
69
+ return uninstallAgentsMd(runtime, configDir, dryRun);
70
+ }
71
+ throw new Error(`Unsupported runtime kind: ${runtime.kind}`);
72
+ }
73
+
74
+ function installClaudeMarketplace(runtime, configDir, dryRun) {
75
+ const settingsPath = path.join(configDir, 'settings.json');
76
+ ensureDir(configDir, dryRun);
77
+ const existing = loadJsonOr({}, settingsPath);
78
+ const { next, changed } = mergeClaudeSettings(
79
+ existing,
80
+ runtime.marketplaceEntry,
81
+ );
82
+ if (!changed) {
83
+ return {
84
+ runtime: runtime.id,
85
+ path: settingsPath,
86
+ action: 'unchanged',
87
+ dryRun,
88
+ };
89
+ }
90
+ const formatted = `${JSON.stringify(next, null, 2)}\n`;
91
+ if (!dryRun) atomicWrite(settingsPath, formatted);
92
+ return {
93
+ runtime: runtime.id,
94
+ path: settingsPath,
95
+ action: fs.existsSync(settingsPath) ? 'updated' : 'created',
96
+ dryRun,
97
+ };
98
+ }
99
+
100
+ function uninstallClaudeMarketplace(runtime, configDir, dryRun) {
101
+ const settingsPath = path.join(configDir, 'settings.json');
102
+ if (!fs.existsSync(settingsPath)) {
103
+ return {
104
+ runtime: runtime.id,
105
+ path: settingsPath,
106
+ action: 'unchanged',
107
+ dryRun,
108
+ };
109
+ }
110
+ const existing = loadJsonOr({}, settingsPath);
111
+ const { next, changed } = removeClaudeSettings(
112
+ existing,
113
+ runtime.marketplaceEntry,
114
+ );
115
+ if (!changed) {
116
+ return {
117
+ runtime: runtime.id,
118
+ path: settingsPath,
119
+ action: 'unchanged',
120
+ dryRun,
121
+ };
122
+ }
123
+ const formatted = `${JSON.stringify(next, null, 2)}\n`;
124
+ if (!dryRun) atomicWrite(settingsPath, formatted);
125
+ return {
126
+ runtime: runtime.id,
127
+ path: settingsPath,
128
+ action: 'removed',
129
+ dryRun,
130
+ };
131
+ }
132
+
133
+ function installAgentsMd(runtime, configDir, dryRun) {
134
+ ensureDir(configDir, dryRun);
135
+ const fileName = (runtime.files && runtime.files[0]) || 'AGENTS.md';
136
+ const target = path.join(configDir, fileName);
137
+ const desired = buildAgentsFileContent(runtime);
138
+
139
+ if (fs.existsSync(target)) {
140
+ const current = fs.readFileSync(target, 'utf8');
141
+ if (current === desired) {
142
+ return {
143
+ runtime: runtime.id,
144
+ path: target,
145
+ action: 'unchanged',
146
+ dryRun,
147
+ };
148
+ }
149
+ if (!isPluginOwned(current)) {
150
+ // Don't clobber unrelated user-authored AGENTS.md / GEMINI.md.
151
+ return {
152
+ runtime: runtime.id,
153
+ path: target,
154
+ action: 'skipped-foreign',
155
+ dryRun,
156
+ reason: `Existing ${fileName} was not authored by this plugin; refusing to overwrite. Move it aside or pass --force (not yet supported) to replace.`,
157
+ };
158
+ }
159
+ if (!dryRun) atomicWrite(target, desired);
160
+ return {
161
+ runtime: runtime.id,
162
+ path: target,
163
+ action: 'updated',
164
+ dryRun,
165
+ };
166
+ }
167
+ if (!dryRun) atomicWrite(target, desired);
168
+ return {
169
+ runtime: runtime.id,
170
+ path: target,
171
+ action: 'created',
172
+ dryRun,
173
+ };
174
+ }
175
+
176
+ function uninstallAgentsMd(runtime, configDir, dryRun) {
177
+ const fileName = (runtime.files && runtime.files[0]) || 'AGENTS.md';
178
+ const target = path.join(configDir, fileName);
179
+ if (!fs.existsSync(target)) {
180
+ return {
181
+ runtime: runtime.id,
182
+ path: target,
183
+ action: 'unchanged',
184
+ dryRun,
185
+ };
186
+ }
187
+ const current = fs.readFileSync(target, 'utf8');
188
+ if (!isPluginOwned(current)) {
189
+ return {
190
+ runtime: runtime.id,
191
+ path: target,
192
+ action: 'skipped-foreign',
193
+ dryRun,
194
+ reason: `Existing ${fileName} was not authored by this plugin; not removing.`,
195
+ };
196
+ }
197
+ if (!dryRun) fs.unlinkSync(target);
198
+ return {
199
+ runtime: runtime.id,
200
+ path: target,
201
+ action: 'removed',
202
+ dryRun,
203
+ };
204
+ }
205
+
206
+ function detectInstalled(opts) {
207
+ const installed = [];
208
+ const { listRuntimes } = require('./runtimes.cjs');
209
+ for (const runtime of listRuntimes()) {
210
+ const configDir = resolveConfigDir(runtime.id, opts);
211
+ if (runtime.kind === 'claude-marketplace') {
212
+ const settingsPath = path.join(configDir, 'settings.json');
213
+ if (!fs.existsSync(settingsPath)) continue;
214
+ try {
215
+ const data = loadJsonOr({}, settingsPath);
216
+ const key = `${runtime.marketplaceEntry.pluginName}@${runtime.marketplaceEntry.name}`;
217
+ if (data.enabledPlugins && data.enabledPlugins[key] === true) {
218
+ installed.push(runtime.id);
219
+ }
220
+ } catch {
221
+ // ignore
222
+ }
223
+ continue;
224
+ }
225
+ if (runtime.kind === 'agents-md') {
226
+ const fileName = (runtime.files && runtime.files[0]) || 'AGENTS.md';
227
+ const target = path.join(configDir, fileName);
228
+ if (!fs.existsSync(target)) continue;
229
+ try {
230
+ const content = fs.readFileSync(target, 'utf8');
231
+ if (isPluginOwned(content)) installed.push(runtime.id);
232
+ } catch {
233
+ // ignore
234
+ }
235
+ }
236
+ }
237
+ return installed;
238
+ }
239
+
240
+ module.exports = {
241
+ installRuntime,
242
+ uninstallRuntime,
243
+ detectInstalled,
244
+ };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ // @clack/prompts wrapper for the multi-runtime installer.
4
+ //
5
+ // runInteractiveInstall walks the user through three steps:
6
+ // 1. Multi-select runtimes (with [a] all shortcut).
7
+ // 2. Radio: Global vs Local install.
8
+ // 3. Confirmation summary.
9
+ //
10
+ // runInteractiveUninstall walks the user through:
11
+ // 1. Multi-select detected-installed runtimes.
12
+ // 2. Confirmation summary.
13
+ //
14
+ // Both return null when the user cancels at any step (ESC / ctrl-c). The
15
+ // caller is responsible for translating null into a "cancelled" exit-0
16
+ // message.
17
+
18
+ const { listRuntimes, getRuntime } = require('./runtimes.cjs');
19
+ const { detectInstalled } = require('./installer.cjs');
20
+
21
+ let clackCache = null;
22
+ function loadClack() {
23
+ if (clackCache) return clackCache;
24
+ try {
25
+ clackCache = require('@clack/prompts');
26
+ } catch (err) {
27
+ throw new Error(
28
+ [
29
+ 'Interactive install requires @clack/prompts.',
30
+ 'Install it (npm i @clack/prompts) or pass an explicit runtime flag',
31
+ '(e.g. --claude --global) to skip the interactive session.',
32
+ `Original error: ${err && err.message}`,
33
+ ].join('\n'),
34
+ );
35
+ }
36
+ return clackCache;
37
+ }
38
+
39
+ function isCancel(p, value) {
40
+ return typeof p.isCancel === 'function' ? p.isCancel(value) : false;
41
+ }
42
+
43
+ async function runInteractiveInstall() {
44
+ const p = loadClack();
45
+
46
+ p.intro('get-design-done — multi-runtime installer');
47
+
48
+ const runtimes = listRuntimes();
49
+ const options = runtimes.map((r) => ({
50
+ value: r.id,
51
+ label: r.displayName,
52
+ hint: r.kind === 'claude-marketplace' ? 'marketplace registration' : `drops ${r.files[0] || 'AGENTS.md'}`,
53
+ }));
54
+
55
+ const picked = await p.multiselect({
56
+ message: 'Pick the runtimes to install into (space to toggle, [a] all):',
57
+ options,
58
+ required: true,
59
+ });
60
+ if (isCancel(p, picked)) {
61
+ p.cancel('Install cancelled.');
62
+ return null;
63
+ }
64
+
65
+ const location = await p.select({
66
+ message: 'Install location:',
67
+ options: [
68
+ { value: 'global', label: 'Global ($HOME-level config dir)' },
69
+ { value: 'local', label: 'Local (current working directory)' },
70
+ ],
71
+ initialValue: 'global',
72
+ });
73
+ if (isCancel(p, location)) {
74
+ p.cancel('Install cancelled.');
75
+ return null;
76
+ }
77
+
78
+ const summary = picked
79
+ .map((id) => ` • ${getRuntime(id).displayName}`)
80
+ .join('\n');
81
+ const confirmed = await p.confirm({
82
+ message: `Install into:\n${summary}\nLocation: ${location}\n\nProceed?`,
83
+ initialValue: true,
84
+ });
85
+ if (isCancel(p, confirmed) || confirmed === false) {
86
+ p.cancel('Install cancelled.');
87
+ return null;
88
+ }
89
+
90
+ return { runtimes: picked, location };
91
+ }
92
+
93
+ async function runInteractiveUninstall(opts) {
94
+ const p = loadClack();
95
+
96
+ p.intro('get-design-done — uninstall');
97
+
98
+ const installed = detectInstalled(opts || {});
99
+ if (installed.length === 0) {
100
+ p.note(
101
+ 'No runtimes appear to have the get-design-done plugin installed.',
102
+ 'Nothing to do.',
103
+ );
104
+ p.outro('Done.');
105
+ return null;
106
+ }
107
+
108
+ const options = installed.map((id) => ({
109
+ value: id,
110
+ label: getRuntime(id).displayName,
111
+ hint: 'installed',
112
+ }));
113
+
114
+ const picked = await p.multiselect({
115
+ message: 'Pick the runtimes to uninstall from:',
116
+ options,
117
+ required: true,
118
+ });
119
+ if (isCancel(p, picked)) {
120
+ p.cancel('Uninstall cancelled.');
121
+ return null;
122
+ }
123
+
124
+ const summary = picked
125
+ .map((id) => ` • ${getRuntime(id).displayName}`)
126
+ .join('\n');
127
+ const confirmed = await p.confirm({
128
+ message: `Uninstall from:\n${summary}\n\nProceed?`,
129
+ initialValue: true,
130
+ });
131
+ if (isCancel(p, confirmed) || confirmed === false) {
132
+ p.cancel('Uninstall cancelled.');
133
+ return null;
134
+ }
135
+
136
+ return { runtimes: picked };
137
+ }
138
+
139
+ module.exports = {
140
+ runInteractiveInstall,
141
+ runInteractiveUninstall,
142
+ };