@kisev/agentomatic 12.0.1-dev.130.g78ea8685c8d4 → 12.0.1-dev.132.g6d50bbe22d9e

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.
@@ -6,18 +6,22 @@ import type { DoctorReport } from "./doctor.js";
6
6
  import type { Scope } from "./lifecycle.js";
7
7
  type DisplayPlan = InstallerPlan | AgentProfilePlan;
8
8
  export declare function terminalSafe(value: string): string;
9
+ export declare function colorEnabled(env?: NodeJS.ProcessEnv, isTTY?: boolean | undefined): boolean;
9
10
  export declare function renderPlan(plan: DisplayPlan, options: {
10
11
  applied: boolean;
11
12
  applyHint?: string;
12
13
  hint?: string;
14
+ color?: boolean;
13
15
  }): string;
16
+ export declare function renderConfigNoop(plan: ConfigSetupPlan, color?: boolean): string;
14
17
  export declare function renderConfigSetup(plan: ConfigSetupPlan, options: {
15
18
  applied: boolean;
16
19
  applyHint?: string;
17
20
  hint?: string;
21
+ color?: boolean;
18
22
  }): string;
19
- export declare function renderCriticsTable(inventory: AgentInventory): string;
20
- export declare function renderFixedRoles(inventory: AgentInventory): string;
23
+ export declare function renderCriticsTable(inventory: AgentInventory, color?: boolean): string;
24
+ export declare function renderFixedRoles(inventory: AgentInventory, color?: boolean): string;
21
25
  export declare function renderInventory(inventory: AgentInventory): string;
22
26
  export declare function shellCommand(arguments_: readonly string[]): string;
23
27
  export declare function scopeArguments(scope: Scope): [] | ["--global"];
@@ -34,6 +34,36 @@ export function terminalSafe(value) {
34
34
  })
35
35
  .join("");
36
36
  }
37
+ // ANSI styling is opt-in per call so tests can force either form; the CLI uses
38
+ // the environment default, which disables color on non-TTY output and NO_COLOR.
39
+ export function colorEnabled(env = process.env, isTTY = process.stdout.isTTY) {
40
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
41
+ return false;
42
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "")
43
+ return env.FORCE_COLOR !== "0";
44
+ return Boolean(isTTY);
45
+ }
46
+ function paint(color, code, text) {
47
+ return color && code ? `\u001b[${code}m${text}\u001b[0m` : text;
48
+ }
49
+ // One glyph per operation keeps the summary readable even with color off; the
50
+ // matching ANSI code is applied only when color is enabled.
51
+ const OPERATION_STYLES = {
52
+ create: { symbol: "▸", code: "32" },
53
+ update: { symbol: "●", code: "36" },
54
+ remove: { symbol: "■", code: "31" },
55
+ "archive-pending": { symbol: "■", code: "33" },
56
+ conflict: { symbol: "✗", code: "31" },
57
+ missing: { symbol: "✗", code: "33" },
58
+ unchanged: { symbol: "◻", code: "2" },
59
+ };
60
+ const MARKS = {
61
+ ok: "✓",
62
+ bad: "✗",
63
+ change: "▸",
64
+ item: "●",
65
+ pending: "◻",
66
+ };
37
67
  function groupFor(path) {
38
68
  if (path.startsWith("agents/"))
39
69
  return "Agents";
@@ -53,10 +83,13 @@ function actionName(action) {
53
83
  reconcile: "Repair selected agents",
54
84
  }[action];
55
85
  }
56
- function operationSummary(operations) {
86
+ function operationSummary(operations, color) {
57
87
  return OPERATIONS.map((operation) => {
58
88
  const count = operations.filter((item) => item.operation === operation).length;
59
- return count ? `${operation} ${count}` : undefined;
89
+ if (!count)
90
+ return undefined;
91
+ const spec = OPERATION_STYLES[operation];
92
+ return paint(color, spec.code, `${spec.symbol} ${operation} ${count}`);
60
93
  })
61
94
  .filter(Boolean)
62
95
  .join(", ");
@@ -67,7 +100,7 @@ function shortPath(path, group) {
67
100
  const value = path.slice(path.indexOf("/") + 1);
68
101
  return terminalSafe(value.endsWith(".md") || value.endsWith(".js") ? value.slice(0, -3) : value);
69
102
  }
70
- function detailLines(operations) {
103
+ function detailLines(operations, color) {
71
104
  const lines = [];
72
105
  for (const group of GROUPS) {
73
106
  const grouped = operations.filter((item) => groupFor(item.path) === group && item.operation !== "unchanged");
@@ -79,7 +112,8 @@ function detailLines(operations) {
79
112
  : shortPath(item.path, group));
80
113
  const visible = operation === "conflict" ? values : values.slice(0, 8);
81
114
  const rest = values.length - visible.length;
82
- lines.push(` ${group}/${operation}: ${visible.join(", ")}${rest ? ` (+${rest} more)` : ""}`);
115
+ const spec = OPERATION_STYLES[operation];
116
+ lines.push(` ${paint(color, spec.code, spec.symbol)} ${group}/${operation}: ${visible.join(", ")}${rest ? ` (+${rest} more)` : ""}`);
83
117
  }
84
118
  }
85
119
  return lines;
@@ -91,9 +125,10 @@ function migrationSummary(operations) {
91
125
  : undefined;
92
126
  }
93
127
  export function renderPlan(plan, options) {
128
+ const color = options.color ?? colorEnabled();
94
129
  const version = "package_version" in plan ? ` @kisev/agentomatic ${plan.package_version}` : "";
95
130
  const lines = [
96
- `${actionName(plan.action)}${version} (${plan.scope})`,
131
+ paint(color, "1", `${actionName(plan.action)}${version} (${plan.scope})`),
97
132
  `Target: ${terminalSafe(plan.root)}`,
98
133
  ...("selection" in plan
99
134
  ? [
@@ -102,72 +137,88 @@ export function renderPlan(plan, options) {
102
137
  ]
103
138
  : []),
104
139
  "",
105
- options.applied ? "Applied changes:" : "Planned changes:",
140
+ paint(color, "1", options.applied ? "Applied changes:" : "Planned changes:"),
106
141
  ];
107
142
  for (const group of GROUPS) {
108
143
  const operations = plan.operations.filter((item) => groupFor(item.path) === group);
109
144
  if (operations.length)
110
- lines.push(` ${group}: ${operationSummary(operations)}`);
145
+ lines.push(` ${paint(color, "1", group)}: ${operationSummary(operations, color)}`);
111
146
  }
112
147
  const migration = migrationSummary(plan.operations);
113
148
  if (migration)
114
149
  lines.push(migration);
115
- const details = detailLines(plan.operations);
150
+ const details = detailLines(plan.operations, color);
116
151
  if (details.length)
117
- lines.push("", "Details:", ...details);
152
+ lines.push("", paint(color, "1", "Details:"), ...details);
118
153
  const conflicts = plan.operations.filter((item) => item.operation === "conflict").length;
119
- lines.push("", `Conflicts: ${conflicts || "none"}`);
154
+ lines.push("", `Conflicts: ${conflicts ? paint(color, "31", `${MARKS.bad} ${conflicts}`) : paint(color, "32", `${MARKS.ok} none`)}`);
120
155
  const restartNote = "restart the host session so the deployed roles and settings load";
121
156
  lines.push(plan.requires_restart
122
- ? `${options.applied ? "Restart required" : "Restart after apply"}: yes — ${restartNote}`
123
- : `${options.applied ? "Restart required" : "Restart after apply"}: no`);
157
+ ? `${options.applied ? "Restart required" : "Restart after apply"}: ${paint(color, "33", `${MARKS.ok} yes`)} — ${restartNote}`
158
+ : `${options.applied ? "Restart required" : "Restart after apply"}: ${paint(color, "2", `${MARKS.pending} no`)}`);
124
159
  if (!options.applied && options.applyHint)
125
- lines.push("", "Apply (repeat non-interactively):", ` ${options.applyHint}`);
160
+ lines.push("", paint(color, "1", "Apply (repeat non-interactively):"), ` ${options.applyHint}`);
126
161
  if (options.applied && options.hint)
127
- lines.push("", "Next:", ` ${options.hint}`);
162
+ lines.push("", paint(color, "1", "Next:"), ` ${options.hint}`);
128
163
  return `${lines.join("\n")}\n`;
129
164
  }
165
+ export function renderConfigNoop(plan, color = colorEnabled()) {
166
+ return [
167
+ paint(color, "1", `Integration configuration @kisev/agentomatic ${plan.package_version} (${plan.scope})`),
168
+ `Root: ${terminalSafe(plan.root)}`,
169
+ "",
170
+ paint(color, "32", `${MARKS.ok} No configuration changes are required.`),
171
+ "",
172
+ ].join("\n");
173
+ }
130
174
  export function renderConfigSetup(plan, options) {
175
+ const color = options.color ?? colorEnabled();
131
176
  const lines = [
132
- `Integration configuration @kisev/agentomatic ${plan.package_version} (${plan.scope})`,
177
+ paint(color, "1", `Integration configuration @kisev/agentomatic ${plan.package_version} (${plan.scope})`),
133
178
  `Root: ${terminalSafe(plan.root)}`,
134
179
  "",
135
180
  ];
136
181
  if (plan.targets.length)
137
- lines.push("Targets:", ...plan.targets.map((item) => ` ${item.target}: ${terminalSafe(item.path)} (${item.exists ? "existing" : "new"})`));
182
+ lines.push(paint(color, "1", "Targets:"), ...plan.targets.map((item) => ` ${item.target}: ${terminalSafe(item.path)} ${paint(color, "2", `(${item.exists ? "existing" : "new"})`)}`));
138
183
  else
139
184
  lines.push("Targets: none");
140
185
  const fragments = plan.operations.filter((item) => item.fragment !== "file");
141
186
  if (plan.dependency)
142
187
  lines.push("", `Dependency: ${plan.dependency.status} ${plan.dependency.name}@${plan.dependency.version}`, " Provisioning may access npm and update package.json, package-lock.json, and node_modules.");
143
188
  if (fragments.length) {
144
- lines.push("", options.applied ? "Applied fragments:" : "Planned fragments:", ...fragments.map((item) => ` ${item.target}/${item.fragment}: ${item.operation}${item.reason ? ` (${terminalSafe(item.reason)})` : ""}`));
189
+ lines.push("", paint(color, "1", options.applied ? "Applied fragments:" : "Planned fragments:"), ...fragments.map((item) => {
190
+ const spec = OPERATION_STYLES[item.operation];
191
+ const badge = spec
192
+ ? `${paint(color, spec.code, spec.symbol)} `
193
+ : `${paint(color, "33", MARKS.change)} `;
194
+ return ` ${badge}${item.target}/${item.fragment}: ${item.operation}${item.reason ? ` (${terminalSafe(item.reason)})` : ""}`;
195
+ }));
145
196
  }
146
197
  if (plan.skipped_fragments.length)
147
- lines.push("", "Skipped fragments:", ...plan.skipped_fragments.map((item) => ` ${item.fragment}: ${terminalSafe(item.reason)}`));
148
- const conflicts = plan.operations.filter((item) => item.operation === "conflict");
149
- lines.push("", `Conflicts: ${conflicts.length || "none"}`);
198
+ lines.push("", paint(color, "1", "Skipped fragments:"), ...plan.skipped_fragments.map((item) => ` ${item.fragment}: ${terminalSafe(item.reason)}`));
199
+ const conflicts = plan.operations.filter((item) => item.operation === "conflict").length;
200
+ lines.push("", `Conflicts: ${conflicts ? paint(color, "31", `${MARKS.bad} ${conflicts}`) : paint(color, "32", `${MARKS.ok} none`)}`);
150
201
  lines.push(plan.requires_restart
151
- ? `${options.applied ? "Restart required" : "Restart after apply"}: yes — restart the host session so the merged presets load`
152
- : `${options.applied ? "Restart required" : "Restart after apply"}: no`);
202
+ ? `${options.applied ? "Restart required" : "Restart after apply"}: ${paint(color, "33", `${MARKS.ok} yes`)} — restart the host session so the merged presets load`
203
+ : `${options.applied ? "Restart required" : "Restart after apply"}: ${paint(color, "2", `${MARKS.pending} no`)}`);
153
204
  if (!options.applied) {
154
205
  if (!plan.confirmable)
155
- lines.push("", "No configuration changes are required.");
206
+ lines.push("", paint(color, "32", "No configuration changes are required."));
156
207
  else if (options.applyHint)
157
- lines.push("", "Apply (repeat non-interactively):", ` ${options.applyHint}`);
208
+ lines.push("", paint(color, "1", "Apply (repeat non-interactively):"), ` ${options.applyHint}`);
158
209
  }
159
210
  if (options.applied && options.hint)
160
- lines.push("", "Next:", ` ${options.hint}`);
211
+ lines.push("", paint(color, "1", "Next:"), ` ${options.hint}`);
161
212
  return `${lines.join("\n")}\n`;
162
213
  }
163
- function table(rows) {
214
+ function table(rows, color = false) {
164
215
  const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0)));
165
- return rows.map((row) => row
216
+ return rows.map((row, index) => paint(color && index === 0, "1", row
166
217
  .map((value, column) => value.padEnd(widths[column]))
167
218
  .join(" ")
168
- .trimEnd());
219
+ .trimEnd()));
169
220
  }
170
- export function renderCriticsTable(inventory) {
221
+ export function renderCriticsTable(inventory, color = colorEnabled()) {
171
222
  // The panel keeps the package-owned fixed critic (`critic`) apart from the
172
223
  // additional `critic-*` pool entries and points at the canonical overview.
173
224
  const critics = inventory.profiles.filter((profile) => profile.ownership !== "user-owned" &&
@@ -178,31 +229,32 @@ export function renderCriticsTable(inventory) {
178
229
  const rows = [
179
230
  ["NAME", "MODEL", "VARIANT", "PROVIDER"],
180
231
  ...critics.map((profile) => [
181
- terminalSafe(profile.name),
232
+ `${profile.name === "critic" ? MARKS.item : MARKS.change} ${terminalSafe(profile.name)}`,
182
233
  terminalSafe(profile.model ?? "default (host)"),
183
234
  terminalSafe(profile.variant ?? "-"),
184
235
  terminalSafe(profile.model?.split("/", 1)[0] ?? "-"),
185
236
  ]),
186
237
  ];
187
- return (`Critics:\n${table(rows).join("\n")}\n` +
238
+ const head = paint(color, "1", "Critics:");
239
+ return (`${head}\n${table(rows, color).join("\n")}\n` +
188
240
  `Pool: additional critics are optional specialist profiles (` +
189
241
  `${pool.length ? pool.map((profile) => terminalSafe(profile.name)).join(", ") : "none"}); ` +
190
242
  "the fixed critic always deploys with the package.\n");
191
243
  }
192
- export function renderFixedRoles(inventory) {
244
+ export function renderFixedRoles(inventory, color = colorEnabled()) {
193
245
  const roles = inventory.profiles.filter((profile) => profile.ownership !== "user-owned" &&
194
246
  FIXED_AGENT_ROLES.includes(profile.name));
195
247
  const rows = [
196
248
  ["NAME", "MODEL", "VARIANT"],
197
249
  ...roles.map((profile) => [
198
- terminalSafe(profile.name),
250
+ `${MARKS.item} ${terminalSafe(profile.name)}`,
199
251
  terminalSafe(profile.model ?? "default (host)"),
200
252
  terminalSafe(profile.variant ?? "-"),
201
253
  ]),
202
254
  ];
203
255
  return [
204
- "Fixed roles (all six belong to the package; models and critic composition: agentomatic configure agent):",
205
- ...table(rows),
256
+ paint(color, "1", "Fixed roles (all six belong to the package; models and critic composition: agentomatic configure agent):"),
257
+ ...table(rows, color),
206
258
  "",
207
259
  ].join("\n");
208
260
  }
@@ -9,8 +9,8 @@ import { CATALOG } from "./catalog.js";
9
9
  import { skillsInstallerSpec } from "./package-metadata.js";
10
10
  import { LifecycleError } from "./lifecycle.js";
11
11
  import { previewDependencyRemoval, removeDependency, planDependency } from "./self-install.js";
12
- import { selectOption, selectOptions, promptText, confirmQuestion } from "./terminal-wizard.js";
13
- import { renderPlan, renderInventory, renderCriticsTable, renderFixedRoles, renderDoctor, renderConfigSetup, renderReconcile, terminalSafe, shellCommand, scopeArguments, } from "./cli-output.js";
12
+ import { selectOption, selectOptions, promptText, confirmQuestion, runWizard, isBack, } from "./terminal-wizard.js";
13
+ import { renderPlan, renderInventory, renderCriticsTable, renderFixedRoles, renderDoctor, renderConfigSetup, renderConfigNoop, renderReconcile, terminalSafe, shellCommand, scopeArguments, } from "./cli-output.js";
14
14
  let diagnostics;
15
15
  let machineOutput = false;
16
16
  let diagnosticJSON = false;
@@ -304,7 +304,21 @@ function conflicts(operations) {
304
304
  if (blocked.length)
305
305
  fail("conflict", `Plan blocked; preserved paths: ${blocked.map((operation) => operation.path).join(", ")}. Inspect doctor; restore recorded bytes or resolve ownership before retrying.`);
306
306
  }
307
- async function integrationSelection(options, presetsOnly = false) {
307
+ const CONNECTION_CHOICES = [
308
+ "Keep current connection",
309
+ "Connect and pin this package version",
310
+ "Disconnect agentomatic",
311
+ ];
312
+ function presetFragments(targets, filter) {
313
+ return CONFIG_FRAGMENTS.filter((fragment) => !["core-plugin", "core-disable"].includes(fragment.name) &&
314
+ fragment.targets.some((target) => targets.includes(target)) &&
315
+ (!filter || filter.has(fragment.name))).map((fragment) => fragment.name);
316
+ }
317
+ function applicablePresets(fragments, targets) {
318
+ return fragments.filter((name) => !["core-plugin", "core-disable"].includes(name) &&
319
+ CONFIG_FRAGMENTS.find((fragment) => fragment.name === name)?.targets.some((target) => targets.includes(target)));
320
+ }
321
+ async function integrationSelection(options, presetsOnly = false, filter) {
308
322
  if (options.targets !== undefined || options.fragments !== undefined) {
309
323
  if (options.targets === undefined || options.fragments === undefined)
310
324
  fail("invalid_input", "Supply both --targets and --fragments");
@@ -314,30 +328,60 @@ async function integrationSelection(options, presetsOnly = false) {
314
328
  });
315
329
  }
316
330
  const defaults = await defaultConfigSelection(options.scope);
317
- const targets = value(await selectOptions("Agent harnesses to configure", options.scope === "project" ? ["opencode"] : CONFIG_TARGETS, defaults.targets, CONFIG_TARGETS.map((target) => TARGET_DESCRIPTIONS[target])));
318
- if (!targets.length)
319
- return { targets, fragments: [] };
331
+ const scopedTargets = options.scope === "project" ? ["opencode"] : [...CONFIG_TARGETS];
332
+ const targetChoices = filter?.targets
333
+ ? scopedTargets.filter((target) => filter.targets.includes(target))
334
+ : scopedTargets;
335
+ const fragmentFilter = filter?.fragments ? new Set(filter.fragments) : undefined;
336
+ const steps = [
337
+ {
338
+ run: async (state, back) => {
339
+ const result = await selectOptions("Agent harnesses to configure", targetChoices, state.targets.filter((target) => targetChoices.includes(target)), targetChoices.map((target) => TARGET_DESCRIPTIONS[target]), process.stdin, process.stderr, back);
340
+ if (isBack(result))
341
+ return result;
342
+ return { ...state, targets: value(result) };
343
+ },
344
+ },
345
+ {
346
+ enabled: (state) => !presetsOnly && state.targets.includes("opencode") && (filter?.connection ?? true),
347
+ run: async (state, back) => {
348
+ const result = await selectOption("OpenCode plugin connection", CONNECTION_CHOICES, process.stdin, process.stderr, state.connection, undefined, back);
349
+ if (isBack(result))
350
+ return result;
351
+ return { ...state, connection: value(result) };
352
+ },
353
+ },
354
+ {
355
+ enabled: (state) => presetFragments(state.targets, fragmentFilter).length > 0,
356
+ run: async (state, back) => {
357
+ const available = presetFragments(state.targets, fragmentFilter);
358
+ const result = await selectOptions("Application presets to merge", available, state.fragments.filter((fragment) => available.includes(fragment)), available.map((name) => CONFIG_FRAGMENTS.find((fragment) => fragment.name === name)?.description), process.stdin, process.stderr, back);
359
+ if (isBack(result))
360
+ return result;
361
+ return { ...state, fragments: value(result) };
362
+ },
363
+ },
364
+ ];
365
+ const state = await runWizard({
366
+ targets: filter?.targets ? [...filter.targets] : defaults.targets,
367
+ connection: 0,
368
+ fragments: filter?.fragments
369
+ ? [...filter.fragments]
370
+ : applicablePresets(defaults.fragments, defaults.targets),
371
+ }, steps);
372
+ if (!state.targets.length)
373
+ return { targets: [], fragments: [] };
320
374
  const connection = [];
321
- if (!presetsOnly && targets.includes("opencode")) {
322
- const choice = value(await selectOption("OpenCode plugin connection", [
323
- "Keep current connection",
324
- "Connect and pin this package version",
325
- "Disconnect agentomatic",
326
- ]));
327
- if (choice === 1)
328
- connection.push("core-plugin");
329
- if (choice === 2)
330
- connection.push("core-disable");
331
- }
332
- const available = CONFIG_FRAGMENTS.filter((fragment) => !["core-plugin", "core-disable"].includes(fragment.name) &&
333
- fragment.targets.some((target) => targets.includes(target))).map((fragment) => fragment.name);
334
- const fragments = value(await selectOptions("Application presets to merge", available, defaults.fragments.filter((fragment) => available.includes(fragment)), available.map((name) => CONFIG_FRAGMENTS.find((fragment) => fragment.name === name)?.description)));
375
+ if (state.connection === 1)
376
+ connection.push("core-plugin");
377
+ if (state.connection === 2)
378
+ connection.push("core-disable");
335
379
  return normalizeConfigSelection(options.scope, {
336
- targets,
337
- fragments: [...connection, ...fragments],
380
+ targets: state.targets,
381
+ fragments: [...connection, ...applicablePresets(state.fragments, state.targets)],
338
382
  });
339
383
  }
340
- async function modelSelection(options, name, current) {
384
+ async function modelSelection(options, name, current, allowBack = false) {
341
385
  validateAgentName(name);
342
386
  if (options.model) {
343
387
  const model = validateModel(options.model.includes("/") ? options.model : `${options.provider ?? ""}/${options.model}`);
@@ -362,42 +406,102 @@ async function modelSelection(options, name, current) {
362
406
  ...(current.variant ? ["Clear variant"] : []),
363
407
  ]
364
408
  : ["Change model"];
365
- const choice = choices[value(await selectOption(`Agent: ${name}`, choices))];
366
- if (choice.startsWith("Keep current"))
409
+ const changing = (state) => state.manual === null && state.choice === "Change model";
410
+ const candidates = (state) => state.models.filter((model) => model.startsWith(`${state.providers[state.provider]}/`));
411
+ const state = await runWizard({
412
+ choice: choices[0],
413
+ provider: 0,
414
+ model: 0,
415
+ variant: 0,
416
+ models: [],
417
+ providers: [],
418
+ variants: null,
419
+ manual: null,
420
+ }, [
421
+ {
422
+ run: async (current_, canBack) => {
423
+ const result = await selectOption(`Agent: ${name}`, choices, process.stdin, process.stderr, Math.max(0, choices.indexOf(current_.choice)), undefined, allowBack || canBack);
424
+ if (isBack(result))
425
+ return result;
426
+ return { ...current_, choice: choices[value(result)] };
427
+ },
428
+ },
429
+ {
430
+ // The catalog is fetched on the provider screen (after the model
431
+ // choice), so the catalog-unavailable fallback keeps its exact order.
432
+ enabled: changing,
433
+ run: async (current_, canBack) => {
434
+ const fallback = async () => {
435
+ process.stderr.write("Start opencode in another terminal to browse models, or enter provider/model manually\n");
436
+ const model = validateModel(value(await promptText("Model (provider/model)")));
437
+ const answer = await promptText(current?.variant ? "Variant (empty keeps current)" : "Variant (optional)");
438
+ const variant = (answer ? validateVariant(answer) : current?.variant) ?? null;
439
+ return { ...current_, manual: { model, variant } };
440
+ };
441
+ let providers = current_.providers;
442
+ let models = current_.models;
443
+ if (!models.length) {
444
+ try {
445
+ models = await availableModels();
446
+ }
447
+ catch (error) {
448
+ if (!(error instanceof LifecycleError && error.code === "catalog_unavailable"))
449
+ throw error;
450
+ return fallback();
451
+ }
452
+ providers = [...new Set(models.map((model) => model.split("/", 1)[0]))].sort();
453
+ }
454
+ const result = await selectOption("Provider", providers, process.stdin, process.stderr, Math.min(current_.provider, providers.length - 1), undefined, canBack);
455
+ if (isBack(result))
456
+ return result;
457
+ const provider = value(result);
458
+ return { ...current_, models, providers, provider, model: 0, variants: null };
459
+ },
460
+ },
461
+ {
462
+ enabled: (current_) => changing(current_) && current_.models.length > 0,
463
+ run: async (current_, canBack) => {
464
+ const available = candidates(current_);
465
+ const result = await selectOption("Model", available, process.stdin, process.stderr, Math.min(current_.model, available.length - 1), undefined, canBack);
466
+ if (isBack(result))
467
+ return result;
468
+ const model = value(result);
469
+ const variants = await availableModelVariants(available[model]);
470
+ const variant = variants && current?.variant ? Math.max(0, variants.indexOf(current.variant) + 1) : 0;
471
+ return { ...current_, model, variants, variant };
472
+ },
473
+ },
474
+ {
475
+ enabled: (current_) => changing(current_) && current_.variants !== null && current_.variants.length > 0,
476
+ run: async (current_, canBack) => {
477
+ const variants = current_.variants;
478
+ const labels = variants.map((item) => item === current?.variant ? `${item} (default)` : item);
479
+ const result = await selectOption("Variant", ["(none)", ...labels], process.stdin, process.stderr, Math.min(current_.variant, labels.length), undefined, canBack);
480
+ if (isBack(result))
481
+ return result;
482
+ return { ...current_, variant: value(result) };
483
+ },
484
+ },
485
+ ]);
486
+ if (state.manual)
487
+ return { action: "model-set", name, ...state.manual };
488
+ if (state.choice.startsWith("Keep current"))
367
489
  return { action: "model-set", name, model: current.model, variant: current.variant ?? null };
368
- if (choice === "Clear variant")
490
+ if (state.choice === "Clear variant")
369
491
  return { action: "model-set", name, model: current.model, variant: null };
370
- try {
371
- const models = await availableModels();
372
- const providers = [...new Set(models.map((model) => model.split("/", 1)[0]))].sort();
373
- const provider = providers[value(await selectOption("Provider", providers))];
374
- const candidates = models.filter((model) => model.startsWith(`${provider}/`));
375
- const model = candidates[value(await selectOption("Model", candidates))];
376
- const variants = await availableModelVariants(model);
377
- let variant = null;
378
- if (variants === null) {
379
- // The variants metadata is unavailable: say so honestly and accept a
380
- // manual variant instead of degrading the whole model selection.
381
- process.stderr.write("Model variants are unknown (variants metadata unavailable); enter one manually or leave it empty.\n");
382
- const answer = await promptText(current?.variant ? "Variant (empty keeps current)" : "Variant (optional)");
383
- variant = (answer ? validateVariant(answer) : current?.variant) ?? null;
384
- }
385
- else if (variants.length) {
386
- const labels = variants.map((item) => item === current?.variant ? `${item} (default)` : item);
387
- const choice = value(await selectOption("Variant", ["(none)", ...labels], undefined, undefined, current?.variant ? variants.indexOf(current.variant) + 1 : 0));
388
- variant = choice === 0 ? null : (variants[choice - 1] ?? null);
389
- }
390
- return { action: "model-set", name, model, variant };
391
- }
392
- catch (error) {
393
- if (!(error instanceof LifecycleError && error.code === "catalog_unavailable"))
394
- throw error;
395
- process.stderr.write("Start opencode in another terminal to browse models, or enter provider/model manually\n");
396
- const model = validateModel(value(await promptText("Model (provider/model)")));
492
+ const chosen = candidates(state)[state.model];
493
+ let variant = null;
494
+ if (state.variants === null) {
495
+ // The variants metadata is unavailable: say so honestly and accept a
496
+ // manual variant instead of degrading the whole model selection.
497
+ process.stderr.write("Model variants are unknown (variants metadata unavailable); enter one manually or leave it empty.\n");
397
498
  const answer = await promptText(current?.variant ? "Variant (empty keeps current)" : "Variant (optional)");
398
- const variant = (answer ? validateVariant(answer) : current?.variant) ?? null;
399
- return { action: "model-set", name, model, variant };
499
+ variant = (answer ? validateVariant(answer) : current?.variant) ?? null;
400
500
  }
501
+ else if (state.variants.length) {
502
+ variant = state.variant === 0 ? null : (state.variants[state.variant - 1] ?? null);
503
+ }
504
+ return { action: "model-set", name, model: chosen, variant };
401
505
  }
402
506
  async function profileDraft(options, names) {
403
507
  const inventory = await listAgentProfiles(options.scope);
@@ -407,15 +511,20 @@ async function profileDraft(options, names) {
407
511
  const changes = [];
408
512
  while (true) {
409
513
  const actions = ["Configure model", "Add critic", "Remove additional critic", "Done"];
410
- const action = actions[value(await selectOption("Agent models and critics (changes are staged until confirmation)", actions, process.stdin, process.stderr, 3))];
514
+ const actionIndex = value(await selectOption("Agent models and critics (changes are staged until confirmation)", actions, process.stdin, process.stderr, 3));
515
+ if (isBack(actionIndex))
516
+ fail("cancelled", "Wizard cancelled; no changes applied");
517
+ const action = actions[actionIndex];
411
518
  if (action === "Done")
412
519
  return changes;
413
520
  if (action === "Add critic") {
414
521
  const name = await promptCriticName();
415
522
  if (records.has(name))
416
523
  fail("profile_exists", `Profile already exists: ${name}`);
417
- const request = { ...(await modelSelection(options, name)), action: "critic-add" };
418
- changes.push(request);
524
+ const request = await modelSelection(options, name, undefined, true);
525
+ if (isBack(request))
526
+ continue;
527
+ changes.push({ ...request, action: "critic-add" });
419
528
  names.push(name);
420
529
  records.set(name, {
421
530
  name,
@@ -433,14 +542,19 @@ async function profileDraft(options, names) {
433
542
  process.stderr.write("No eligible profiles; choose another action.\n");
434
543
  continue;
435
544
  }
436
- const name = candidates[value(await selectOption("Agent", candidates))];
545
+ const selected = await selectOption("Agent", candidates, process.stdin, process.stderr, 0, undefined, true);
546
+ if (isBack(selected))
547
+ continue;
548
+ const name = candidates[value(selected)];
437
549
  if (action === "Remove additional critic") {
438
550
  changes.push({ action: "critic-remove", name });
439
551
  names = names.filter((candidate) => candidate !== name);
440
552
  records.delete(name);
441
553
  }
442
554
  else {
443
- const request = await modelSelection(options, name, records.get(name));
555
+ const request = await modelSelection(options, name, records.get(name), true);
556
+ if (isBack(request))
557
+ continue;
444
558
  changes.push(request);
445
559
  records.set(name, {
446
560
  ...records.get(name),
@@ -483,11 +597,41 @@ async function componentSelection(options) {
483
597
  return { ...saved, agents, core_activation: options.core ?? saved.core_activation };
484
598
  }
485
599
  process.stderr.write(`Portable skills are installed separately through the skills CLI (npx --yes ${skillsInstallerSpec()} add https://kisev.github.io/skills --agent opencode --copy). Selecting an adapter does not install its skill.\nAll six fixed agent roles deploy as one package; set their models or add critics with \`agentomatic configure agent\` afterwards.\n`);
486
- const commands = value(await selectOptions("Skill command adapters", SKILL_COMMANDS, defaults.commands, commandHints()));
487
- const plugins = value(await selectOptions("Optional plugins", SELECTABLE_PLUGINS, defaults.plugins, SELECTABLE_PLUGINS.map((plugin) => PLUGIN_DESCRIPTIONS[plugin])));
488
- const core_activation = options.core ??
489
- value(await confirmQuestion("Connect the OpenCode plugin and pin its npm dependency?", process.stdin, process.stderr, defaults.core_activation));
490
- return normalizeSelection({ commands: commands ?? [], agents, plugins, core_activation });
600
+ const state = await runWizard({
601
+ commands: defaults.commands,
602
+ plugins: defaults.plugins,
603
+ core_activation: defaults.core_activation,
604
+ }, [
605
+ {
606
+ run: async (current, back) => {
607
+ const result = await selectOptions("Skill command adapters", SKILL_COMMANDS, current.commands, commandHints(), process.stdin, process.stderr, back);
608
+ if (isBack(result))
609
+ return result;
610
+ return { ...current, commands: value(result) };
611
+ },
612
+ },
613
+ {
614
+ run: async (current, back) => {
615
+ const result = await selectOptions("Optional plugins", SELECTABLE_PLUGINS, current.plugins, SELECTABLE_PLUGINS.map((plugin) => PLUGIN_DESCRIPTIONS[plugin]), process.stdin, process.stderr, back);
616
+ if (isBack(result))
617
+ return result;
618
+ return { ...current, plugins: value(result) };
619
+ },
620
+ },
621
+ {
622
+ run: async (current) => ({
623
+ ...current,
624
+ core_activation: options.core ??
625
+ value(await confirmQuestion("Connect the OpenCode plugin and pin its npm dependency?", process.stdin, process.stderr, current.core_activation)),
626
+ }),
627
+ },
628
+ ]);
629
+ return normalizeSelection({
630
+ commands: state.commands ?? [],
631
+ agents,
632
+ plugins: state.plugins,
633
+ core_activation: state.core_activation,
634
+ });
491
635
  }
492
636
  async function deploy(options, repair = false) {
493
637
  const selection = repair
@@ -659,9 +803,50 @@ function partial(options, error, completed, next, requiresRestart = false) {
659
803
  }
660
804
  class ReportedError extends Error {
661
805
  }
806
+ function configDivergence(plan) {
807
+ const changed = plan.operations.filter((operation) => operation.operation === "create" || operation.operation === "update");
808
+ const conflicts = plan.operations.some((operation) => operation.operation === "conflict");
809
+ const targets = CONFIG_TARGETS.filter((target) => changed.some((operation) => operation.target === target));
810
+ const fragments = CONFIG_FRAGMENTS.map((fragment) => fragment.name).filter((name) => changed.some((operation) => operation.fragment === name));
811
+ const connection = changed.some((operation) => operation.fragment === "core-plugin" || operation.fragment === "core-disable");
812
+ return { targets, fragments, connection, conflicts, changes: changed.length };
813
+ }
662
814
  async function configureIntegration(options) {
663
815
  requireApply(options);
664
- const selection = await integrationSelection(options);
816
+ const supplied = options.targets !== undefined || options.fragments !== undefined;
817
+ let selection;
818
+ if (supplied) {
819
+ selection = await integrationSelection(options);
820
+ }
821
+ else {
822
+ if (!tty())
823
+ fail("terminal_required", "configure integration outside a terminal requires --targets and --fragments");
824
+ // Diff before the first screen: an already-satisfied state asks nothing.
825
+ const defaults = await defaultConfigSelection(options.scope);
826
+ const baseline = await previewConfigSetup(defaults, options.scope, undefined, undefined, false, false);
827
+ const divergence = configDivergence(baseline);
828
+ const dependency = !options.noDependency &&
829
+ defaults.targets.includes("opencode") &&
830
+ defaults.fragments.includes("core-plugin")
831
+ ? planDependency(options.scope)
832
+ : undefined;
833
+ const settled = !dependency || dependency.status === "satisfied" || dependency.status === "manual";
834
+ if (divergence.changes === 0 && !divergence.conflicts && settled) {
835
+ if (options.json)
836
+ json({ status: "ok", applied: false, plan: baseline, noop: true });
837
+ else {
838
+ process.stdout.write(renderConfigNoop(baseline));
839
+ await epilogue(options, false);
840
+ process.stdout.write("Done: nothing to apply.\n");
841
+ }
842
+ return;
843
+ }
844
+ selection = await integrationSelection(options, false, {
845
+ targets: divergence.targets.length ? divergence.targets : undefined,
846
+ fragments: divergence.fragments,
847
+ connection: divergence.connection,
848
+ });
849
+ }
665
850
  const plan = await previewConfigSetup(selection, options.scope, undefined, undefined, !options.noDependency);
666
851
  if (!options.json)
667
852
  process.stdout.write(renderConfigSetup(plan, {
@@ -697,8 +882,10 @@ async function configureIntegration(options) {
697
882
  requires_restart: result.requires_restart,
698
883
  });
699
884
  else {
700
- process.stdout.write(renderConfigSetup(result, { applied: true }));
701
885
  await epilogue(options, false);
886
+ process.stdout.write(result.requires_restart
887
+ ? "Done: configuration applied; restart the host session.\n"
888
+ : "Done: configuration applied.\n");
702
889
  }
703
890
  }
704
891
  catch (error) {
@@ -880,6 +1067,8 @@ async function run(args) {
880
1067
  "Agent models and critics",
881
1068
  "Application connection and presets",
882
1069
  ]));
1070
+ if (isBack(selected))
1071
+ fail("cancelled", "Wizard cancelled; no changes applied");
883
1072
  topic = routes[selected];
884
1073
  }
885
1074
  if (topic === "install" || topic === "configure components" || topic === "maintenance repair") {
@@ -965,10 +1154,14 @@ async function run(args) {
965
1154
  // configure agent on a fresh critic-<name> adds the critic: one command
966
1155
  // covers model-set for existing profiles and critic-add for new critics.
967
1156
  const request = await modelSelection(options, name, undefined);
1157
+ if (isBack(request))
1158
+ fail("cancelled", "Wizard cancelled; no changes applied");
968
1159
  await profileChange(options, { ...request, action: "critic-add" });
969
1160
  return;
970
1161
  }
971
1162
  const request = await modelSelection(options, name, existing);
1163
+ if (isBack(request))
1164
+ fail("cancelled", "Wizard cancelled; no changes applied");
972
1165
  await profileChange(options, { ...request, action: "model-set" });
973
1166
  return;
974
1167
  }
@@ -1,5 +1,15 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
- export declare function selectOption(label: string, options: readonly string[], stdin?: Readable, stderr?: Writable, initial?: number): Promise<number | null>;
3
- export declare function selectOptions(label: string, options: readonly string[], initialSelected?: readonly string[], hints?: readonly (string | undefined)[], stdin?: Readable, stderr?: Writable): Promise<string[] | null>;
2
+ export declare const BACK: unique symbol;
3
+ export type BackSignal = typeof BACK;
4
+ export declare function isBack(value: unknown): value is BackSignal;
5
+ export type WizardStep<T> = {
6
+ enabled?: (state: T) => boolean;
7
+ run: (state: T, back: boolean) => Promise<T | BackSignal>;
8
+ };
9
+ export declare function runWizard<T>(initial: T, steps: ReadonlyArray<WizardStep<T>>): Promise<T>;
10
+ export declare function truncateLine(value: string, width: number): string;
11
+ export declare function optionLabel(name: string, description: string | undefined, columns: number): string;
12
+ export declare function selectOption(label: string, options: readonly string[], stdin?: Readable, stderr?: Writable, initial?: number, descriptions?: readonly (string | undefined)[], back?: boolean): Promise<number | null | BackSignal>;
13
+ export declare function selectOptions(label: string, options: readonly string[], initialSelected?: readonly string[], descriptions?: readonly (string | undefined)[], stdin?: Readable, stderr?: Writable, back?: boolean): Promise<string[] | null | BackSignal>;
4
14
  export declare function promptText(label: string, stdin?: Readable, stderr?: Writable): Promise<string | null>;
5
15
  export declare function confirmQuestion(label: string, stdin?: Readable, stderr?: Writable, initialValue?: boolean): Promise<boolean | null>;
@@ -1,5 +1,41 @@
1
1
  import * as clack from "@clack/prompts";
2
2
  import { InstallerError } from "./installer.js";
3
+ // Sentinel returned when the user chooses the "Back" item; `runWizard` steps
4
+ // back to the previous enabled screen and restores the saved answer.
5
+ export const BACK = Symbol("wizard-back");
6
+ export function isBack(value) {
7
+ return value === BACK;
8
+ }
9
+ const BACK_VALUE = "$__agentomatic_back__";
10
+ const BACK_LABEL = "← Back";
11
+ export async function runWizard(initial, steps) {
12
+ const enabled = (index, state) => !steps[index].enabled || steps[index].enabled(state);
13
+ let state = initial;
14
+ let index = 0;
15
+ while (index < steps.length) {
16
+ if (!enabled(index, state)) {
17
+ index += 1;
18
+ continue;
19
+ }
20
+ let previous = index - 1;
21
+ while (previous >= 0 && !enabled(previous, state))
22
+ previous -= 1;
23
+ const result = await steps[index].run(state, previous >= 0);
24
+ if (result === BACK) {
25
+ if (previous < 0)
26
+ continue;
27
+ index = previous;
28
+ continue;
29
+ }
30
+ state = result;
31
+ index += 1;
32
+ }
33
+ return state;
34
+ }
35
+ const DEFAULT_COLUMNS = 80;
36
+ // clack renders a guide and a state marker ("│ ◻ ") before the label; keep the
37
+ // composed label inside the remaining terminal columns so it never wraps.
38
+ const LABEL_RESERVE = 8;
3
39
  function interactiveStreams(stdin, stderr) {
4
40
  if (!isTTY(stdin) || !isTTY(stderr))
5
41
  throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
@@ -13,24 +49,55 @@ function validateOptions(options) {
13
49
  if (new Set(options).size !== options.length)
14
50
  throw new InstallerError("invalid_input", "Interactive selector options must be unique");
15
51
  }
16
- export async function selectOption(label, options, stdin = process.stdin, stderr = process.stderr, initial = 0) {
52
+ function terminalColumns(stderr) {
53
+ const columns = stderr.columns;
54
+ return Number.isFinite(columns) && (columns ?? 0) > 0 ? columns : DEFAULT_COLUMNS;
55
+ }
56
+ // Long single-line values are cut to the terminal width with a trailing
57
+ // ellipsis; the description is never silently dropped for a normal terminal.
58
+ export function truncateLine(value, width) {
59
+ if (width <= 1)
60
+ return "…";
61
+ return [...value].length <= width ? value : `${[...value].slice(0, width - 1).join("")}…`;
62
+ }
63
+ // Every option label carries its data-driven description so the text is
64
+ // visible on every line, not only under the cursor (clack renders `hint` for
65
+ // the focused option only).
66
+ export function optionLabel(name, description, columns) {
67
+ const combined = description ? `${name} — ${description}` : name;
68
+ return truncateLine(combined, Math.max(12, columns - LABEL_RESERVE));
69
+ }
70
+ function backOption() {
71
+ return { value: BACK_VALUE, label: BACK_LABEL };
72
+ }
73
+ export async function selectOption(label, options, stdin = process.stdin, stderr = process.stderr, initial = 0, descriptions, back = false) {
17
74
  interactiveStreams(stdin, stderr);
18
75
  validateOptions(options);
76
+ const columns = terminalColumns(stderr);
77
+ const entries = options.map((option, index) => ({
78
+ value: String(index),
79
+ label: optionLabel(option, descriptions?.[index], columns),
80
+ }));
81
+ if (back)
82
+ entries.push(backOption());
19
83
  const value = await clack.select({
20
84
  message: label,
21
- options: options.map((option, index) => ({ value: index, label: option })),
22
- initialValue: Math.max(0, Math.min(initial, options.length - 1)),
85
+ options: entries,
86
+ initialValue: String(Math.max(0, Math.min(initial, options.length - 1))),
23
87
  input: stdin,
24
88
  output: stderr,
25
89
  });
26
90
  if (clack.isCancel(value))
27
91
  return null;
28
- return value;
92
+ if (value === BACK_VALUE)
93
+ return BACK;
94
+ return Number(value);
29
95
  }
30
96
  // The multiselect contract: each option carries a data-driven description and
31
97
  // the message states the all/none controls, so every group is self-explanatory.
98
+ // SIMPLIFY: Back is a normal option, so the all/none key also selects it -> upgrade to a dedicated Back key if clack exposes one.
32
99
  const MULTISELECT_CONTROLS = " (move: up/down; toggle: space; all/none: a; enter confirms)";
33
- export async function selectOptions(label, options, initialSelected = [], hints, stdin = process.stdin, stderr = process.stderr) {
100
+ export async function selectOptions(label, options, initialSelected = [], descriptions, stdin = process.stdin, stderr = process.stderr, back = false) {
34
101
  interactiveStreams(stdin, stderr);
35
102
  validateOptions(options);
36
103
  const allowed = new Set(options);
@@ -38,13 +105,16 @@ export async function selectOptions(label, options, initialSelected = [], hints,
38
105
  if (!allowed.has(value))
39
106
  throw new InstallerError("invalid_input", `Unknown initial selector option: ${value}`);
40
107
  }
108
+ const columns = terminalColumns(stderr);
109
+ const entries = options.map((option, index) => ({
110
+ value: option,
111
+ label: optionLabel(option, descriptions?.[index], columns),
112
+ }));
113
+ if (back)
114
+ entries.push(backOption());
41
115
  const value = await clack.multiselect({
42
116
  message: `${label}${MULTISELECT_CONTROLS}`,
43
- options: options.map((option, index) => ({
44
- value: option,
45
- label: option,
46
- ...(hints?.[index] ? { hint: hints[index] } : {}),
47
- })),
117
+ options: entries,
48
118
  initialValues: [...initialSelected],
49
119
  required: false,
50
120
  input: stdin,
@@ -52,7 +122,10 @@ export async function selectOptions(label, options, initialSelected = [], hints,
52
122
  });
53
123
  if (clack.isCancel(value))
54
124
  return null;
55
- return value;
125
+ const selected = value;
126
+ if (selected.includes(BACK_VALUE))
127
+ return BACK;
128
+ return selected;
56
129
  }
57
130
  export async function promptText(label, stdin = process.stdin, stderr = process.stderr) {
58
131
  interactiveStreams(stdin, stderr);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/agentomatic",
3
- "version": "12.0.1-dev.130.g78ea8685c8d4",
3
+ "version": "12.0.1-dev.132.g6d50bbe22d9e",
4
4
  "description": "OpenCode integration and opt-in installer for portable Agent Skills.",
5
5
  "homepage": "https://github.com/kisev/skills#readme",
6
6
  "bugs": {
@@ -50,12 +50,12 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@clack/prompts": "^1.8.1",
53
- "@kisev/safe-fs": "12.0.1-dev.130.g78ea8685c8d4",
53
+ "@kisev/safe-fs": "12.0.1-dev.132.g6d50bbe22d9e",
54
54
  "commander": "15.0.0",
55
55
  "zod": "4.1.8"
56
56
  },
57
57
  "devDependencies": {
58
- "@opencode/plugin": "2.0.24",
58
+ "@opencode/plugin": "2.0.25",
59
59
  "@types/node": "22.15.30",
60
60
  "typescript": "7.0.2"
61
61
  },