@kisev/agentomatic 12.0.1-dev.129.g99196bf396c1 → 12.0.1-dev.131.gfdcd7e5c228f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,8 +110,9 @@ npx agentomatic configure integration --global --dry-run
110
110
  }
111
111
  ```
112
112
 
113
- Restart OpenCode after activation or asset changes. A confirmed `install` can
114
- apply its core config step and stage optional models and additional critics.
113
+ Restart OpenCode after activation or asset changes. A confirmed `install`
114
+ deploys the six fixed agents only as one complete package; agent models and
115
+ critics live in the confirmed `configure agent` command.
115
116
  `configure` provides one settings menu; `status` shows the installed set and models.
116
117
  `uninstall` proposes plugin disconnection, retains models, and removes the npm
117
118
  dependency only with an explicit selection (`--remove-dependency`). Other
package/README.ru.md CHANGED
@@ -114,7 +114,9 @@ npx agentomatic configure integration --global --dry-run
114
114
  ```
115
115
 
116
116
  Перезапустите OpenCode после активации или изменения файлов. Подтверждённый
117
- `install` настраивает core и предлагает модели и дополнительных критиков.
117
+ `install` настраивает core и разворачивает шесть фиксированных агентов только
118
+ целиком пакетом; модели агентов и критиков меняет подтверждённая команда
119
+ `configure agent`.
118
120
  `configure` открывает единое меню настроек, `status` показывает состав и модели.
119
121
  `uninstall` предлагает отключение плагина, сохраняет модели и удаляет npm-зависимость
120
122
  только при явном выборе (`--remove-dependency`). Другие фрагменты применяет
@@ -1,4 +1,4 @@
1
- import type { AgentInventory, AgentProfilePlan } from "./agent-profiles.js";
1
+ import { type AgentInventory, type AgentProfilePlan } from "./agent-profiles.js";
2
2
  import type { ConfigSetupPlan } from "./config-setup.js";
3
3
  import type { Plan as InstallerPlan } from "./installer.js";
4
4
  import type { ReconcilePlan } from "./reconcile.js";
@@ -6,17 +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;
23
+ export declare function renderCriticsTable(inventory: AgentInventory, color?: boolean): string;
24
+ export declare function renderFixedRoles(inventory: AgentInventory, color?: boolean): string;
20
25
  export declare function renderInventory(inventory: AgentInventory): string;
21
26
  export declare function shellCommand(arguments_: readonly string[]): string;
22
27
  export declare function scopeArguments(scope: Scope): [] | ["--global"];
@@ -1,3 +1,4 @@
1
+ import { FIXED_AGENT_ROLES } from "./agent-profiles.js";
1
2
  import { CATALOG } from "./catalog.js";
2
3
  const GROUPS = ["Agents", "Commands", "Plugins", "State"];
3
4
  const OPERATIONS = [
@@ -33,6 +34,36 @@ export function terminalSafe(value) {
33
34
  })
34
35
  .join("");
35
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
+ };
36
67
  function groupFor(path) {
37
68
  if (path.startsWith("agents/"))
38
69
  return "Agents";
@@ -52,10 +83,13 @@ function actionName(action) {
52
83
  reconcile: "Repair selected agents",
53
84
  }[action];
54
85
  }
55
- function operationSummary(operations) {
86
+ function operationSummary(operations, color) {
56
87
  return OPERATIONS.map((operation) => {
57
88
  const count = operations.filter((item) => item.operation === operation).length;
58
- 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}`);
59
93
  })
60
94
  .filter(Boolean)
61
95
  .join(", ");
@@ -66,7 +100,7 @@ function shortPath(path, group) {
66
100
  const value = path.slice(path.indexOf("/") + 1);
67
101
  return terminalSafe(value.endsWith(".md") || value.endsWith(".js") ? value.slice(0, -3) : value);
68
102
  }
69
- function detailLines(operations) {
103
+ function detailLines(operations, color) {
70
104
  const lines = [];
71
105
  for (const group of GROUPS) {
72
106
  const grouped = operations.filter((item) => groupFor(item.path) === group && item.operation !== "unchanged");
@@ -78,7 +112,8 @@ function detailLines(operations) {
78
112
  : shortPath(item.path, group));
79
113
  const visible = operation === "conflict" ? values : values.slice(0, 8);
80
114
  const rest = values.length - visible.length;
81
- 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)` : ""}`);
82
117
  }
83
118
  }
84
119
  return lines;
@@ -90,9 +125,10 @@ function migrationSummary(operations) {
90
125
  : undefined;
91
126
  }
92
127
  export function renderPlan(plan, options) {
128
+ const color = options.color ?? colorEnabled();
93
129
  const version = "package_version" in plan ? ` @kisev/agentomatic ${plan.package_version}` : "";
94
130
  const lines = [
95
- `${actionName(plan.action)}${version} (${plan.scope})`,
131
+ paint(color, "1", `${actionName(plan.action)}${version} (${plan.scope})`),
96
132
  `Target: ${terminalSafe(plan.root)}`,
97
133
  ...("selection" in plan
98
134
  ? [
@@ -101,84 +137,126 @@ export function renderPlan(plan, options) {
101
137
  ]
102
138
  : []),
103
139
  "",
104
- options.applied ? "Applied changes:" : "Planned changes:",
140
+ paint(color, "1", options.applied ? "Applied changes:" : "Planned changes:"),
105
141
  ];
106
142
  for (const group of GROUPS) {
107
143
  const operations = plan.operations.filter((item) => groupFor(item.path) === group);
108
144
  if (operations.length)
109
- lines.push(` ${group}: ${operationSummary(operations)}`);
145
+ lines.push(` ${paint(color, "1", group)}: ${operationSummary(operations, color)}`);
110
146
  }
111
147
  const migration = migrationSummary(plan.operations);
112
148
  if (migration)
113
149
  lines.push(migration);
114
- const details = detailLines(plan.operations);
150
+ const details = detailLines(plan.operations, color);
115
151
  if (details.length)
116
- lines.push("", "Details:", ...details);
152
+ lines.push("", paint(color, "1", "Details:"), ...details);
117
153
  const conflicts = plan.operations.filter((item) => item.operation === "conflict").length;
118
- lines.push("", `Conflicts: ${conflicts || "none"}`);
119
- lines.push(options.applied
120
- ? `Restart required: ${plan.requires_restart ? "yes" : "no"}`
121
- : `Restart after apply: ${plan.requires_restart ? "yes" : "no"}`);
154
+ lines.push("", `Conflicts: ${conflicts ? paint(color, "31", `${MARKS.bad} ${conflicts}`) : paint(color, "32", `${MARKS.ok} none`)}`);
155
+ const restartNote = "restart the host session so the deployed roles and settings load";
156
+ lines.push(plan.requires_restart
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`)}`);
122
159
  if (!options.applied && options.applyHint)
123
- lines.push("", "Apply:", ` ${options.applyHint}`);
160
+ lines.push("", paint(color, "1", "Apply (repeat non-interactively):"), ` ${options.applyHint}`);
124
161
  if (options.applied && options.hint)
125
- lines.push("", "Next:", ` ${options.hint}`);
162
+ lines.push("", paint(color, "1", "Next:"), ` ${options.hint}`);
126
163
  return `${lines.join("\n")}\n`;
127
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
+ }
128
174
  export function renderConfigSetup(plan, options) {
175
+ const color = options.color ?? colorEnabled();
129
176
  const lines = [
130
- `Integration configuration @kisev/agentomatic ${plan.package_version} (${plan.scope})`,
177
+ paint(color, "1", `Integration configuration @kisev/agentomatic ${plan.package_version} (${plan.scope})`),
131
178
  `Root: ${terminalSafe(plan.root)}`,
132
179
  "",
133
180
  ];
134
181
  if (plan.targets.length)
135
- 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"})`)}`));
136
183
  else
137
184
  lines.push("Targets: none");
138
185
  const fragments = plan.operations.filter((item) => item.fragment !== "file");
139
186
  if (plan.dependency)
140
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.");
141
188
  if (fragments.length) {
142
- 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
+ }));
143
196
  }
144
197
  if (plan.skipped_fragments.length)
145
- lines.push("", "Skipped fragments:", ...plan.skipped_fragments.map((item) => ` ${item.fragment}: ${terminalSafe(item.reason)}`));
146
- const conflicts = plan.operations.filter((item) => item.operation === "conflict");
147
- lines.push("", `Conflicts: ${conflicts.length || "none"}`);
148
- lines.push(options.applied
149
- ? `Restart required: ${plan.requires_restart ? "yes" : "no"}`
150
- : `Restart after apply: ${plan.requires_restart ? "yes" : "no"}`);
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`)}`);
201
+ lines.push(plan.requires_restart
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`)}`);
151
204
  if (!options.applied) {
152
205
  if (!plan.confirmable)
153
- lines.push("", "No configuration changes are required.");
206
+ lines.push("", paint(color, "32", "No configuration changes are required."));
154
207
  else if (options.applyHint)
155
- lines.push("", "Apply:", ` ${options.applyHint}`);
208
+ lines.push("", paint(color, "1", "Apply (repeat non-interactively):"), ` ${options.applyHint}`);
156
209
  }
157
210
  if (options.applied && options.hint)
158
- lines.push("", "Next:", ` ${options.hint}`);
211
+ lines.push("", paint(color, "1", "Next:"), ` ${options.hint}`);
159
212
  return `${lines.join("\n")}\n`;
160
213
  }
161
- function table(rows) {
214
+ function table(rows, color = false) {
162
215
  const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0)));
163
- return rows.map((row) => row
216
+ return rows.map((row, index) => paint(color && index === 0, "1", row
164
217
  .map((value, column) => value.padEnd(widths[column]))
165
218
  .join(" ")
166
- .trimEnd());
219
+ .trimEnd()));
167
220
  }
168
- export function renderCriticsTable(inventory) {
169
- const critics = inventory.profiles.filter((profile) => profile.name.startsWith("critic-") && profile.ownership !== "user-owned");
221
+ export function renderCriticsTable(inventory, color = colorEnabled()) {
222
+ // The panel keeps the package-owned fixed critic (`critic`) apart from the
223
+ // additional `critic-*` pool entries and points at the canonical overview.
224
+ const critics = inventory.profiles.filter((profile) => profile.ownership !== "user-owned" &&
225
+ (profile.name === "critic" || profile.name.startsWith("critic-")));
170
226
  if (!critics.length)
171
227
  return "Critics:\n No critics\n";
228
+ const pool = critics.filter((profile) => profile.name !== "critic");
172
229
  const rows = [
173
230
  ["NAME", "MODEL", "VARIANT", "PROVIDER"],
174
231
  ...critics.map((profile) => [
175
- terminalSafe(profile.name),
176
- terminalSafe(profile.model ?? "default"),
232
+ `${profile.name === "critic" ? MARKS.item : MARKS.change} ${terminalSafe(profile.name)}`,
233
+ terminalSafe(profile.model ?? "default (host)"),
177
234
  terminalSafe(profile.variant ?? "-"),
178
235
  terminalSafe(profile.model?.split("/", 1)[0] ?? "-"),
179
236
  ]),
180
237
  ];
181
- 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` +
240
+ `Pool: additional critics are optional specialist profiles (` +
241
+ `${pool.length ? pool.map((profile) => terminalSafe(profile.name)).join(", ") : "none"}); ` +
242
+ "the fixed critic always deploys with the package.\n");
243
+ }
244
+ export function renderFixedRoles(inventory, color = colorEnabled()) {
245
+ const roles = inventory.profiles.filter((profile) => profile.ownership !== "user-owned" &&
246
+ FIXED_AGENT_ROLES.includes(profile.name));
247
+ const rows = [
248
+ ["NAME", "MODEL", "VARIANT"],
249
+ ...roles.map((profile) => [
250
+ `${MARKS.item} ${terminalSafe(profile.name)}`,
251
+ terminalSafe(profile.model ?? "default (host)"),
252
+ terminalSafe(profile.variant ?? "-"),
253
+ ]),
254
+ ];
255
+ return [
256
+ paint(color, "1", "Fixed roles (all six belong to the package; models and critic composition: agentomatic configure agent):"),
257
+ ...table(rows, color),
258
+ "",
259
+ ].join("\n");
182
260
  }
183
261
  export function renderInventory(inventory) {
184
262
  const rows = [
@@ -254,7 +332,7 @@ export function renderReconcile(plan, options) {
254
332
  lines.push("", "No cleanup changes are required.");
255
333
  }
256
334
  else if (options.applyHint) {
257
- lines.push("", "Apply:", ` ${options.applyHint}`);
335
+ lines.push("", "Apply (repeat non-interactively):", ` ${options.applyHint}`);
258
336
  }
259
337
  }
260
338
  return `${lines.join("\n")}\n`;
@@ -1,22 +1,23 @@
1
1
  import { Command, Option, common, applyConfig, configFileSync, reporter } from "./generated/cli.js";
2
2
  import { applyAgentProfileChange, previewAgentProfileChange, listAgentProfiles, availableModels, availableModelVariants, validateAgentName, validateModel, validateVariant, suggestCriticName, FIXED_AGENT_ROLES, } from "./agent-profiles.js";
3
- import { apply, preview, installedSelection, defaultSelection, normalizeSelection, InstallerError, SKILL_COMMANDS, SELECTABLE_PLUGINS, } from "./installer.js";
4
- import { applyConfigSetup, previewConfigSetup, recoverConfigSetup, normalizeConfigSelection, defaultConfigSelection, CONFIG_TARGETS, CONFIG_FRAGMENTS, inspectIntegration, } from "./config-setup.js";
3
+ import { apply, preview, installedSelection, defaultSelection, normalizeSelection, InstallerError, SKILL_COMMANDS, SELECTABLE_PLUGINS, PLUGIN_DESCRIPTIONS, } from "./installer.js";
4
+ import { applyConfigSetup, previewConfigSetup, recoverConfigSetup, normalizeConfigSelection, defaultConfigSelection, CONFIG_TARGETS, CONFIG_FRAGMENTS, TARGET_DESCRIPTIONS, inspectIntegration, } from "./config-setup.js";
5
+ import { COMMAND_REGISTRY } from "./registry.js";
5
6
  import { collectDoctorFacts, doctorExitCode } from "./doctor.js";
6
7
  import { applyReconcile, previewReconcile } from "./reconcile.js";
7
8
  import { CATALOG } from "./catalog.js";
8
9
  import { skillsInstallerSpec } from "./package-metadata.js";
9
10
  import { LifecycleError } from "./lifecycle.js";
10
11
  import { previewDependencyRemoval, removeDependency, planDependency } from "./self-install.js";
11
- import { selectOption, selectOptions, promptText, confirmQuestion } from "./terminal-wizard.js";
12
- import { renderPlan, renderInventory, renderCriticsTable, 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";
13
14
  let diagnostics;
14
15
  let machineOutput = false;
15
16
  let diagnosticJSON = false;
16
17
  const commands = {
17
18
  install: {
18
- description: "Install components, connect integration, and optionally configure models and critics.",
19
- options: ["commands", "agents", "plugins", "core", "dependency", "targets", "fragments"],
19
+ description: "Deploy the fixed agent package with chosen command adapters, plugins, and harness integration; agent models stay with configure agent.",
20
+ options: ["commands", "plugins", "core", "dependency", "targets", "fragments"],
20
21
  },
21
22
  configure: {
22
23
  description: "Choose components, agent models, critics, or application integration.",
@@ -24,7 +25,7 @@ const commands = {
24
25
  },
25
26
  "configure components": {
26
27
  description: "Change the installed component set without resetting models.",
27
- options: ["commands", "agents", "plugins", "core", "dependency", "targets", "fragments"],
28
+ options: ["commands", "plugins", "core", "dependency", "targets", "fragments"],
28
29
  },
29
30
  "configure agent": {
30
31
  description: "Open a staged editor for every agent, or set one model with a name and --model provider/model.",
@@ -165,7 +166,8 @@ function help(topic = "") {
165
166
  : []),
166
167
  ...(!topic || ["install", "configure components"].includes(topic)
167
168
  ? [
168
- "Non-TTY component selection: --commands <list|none> --agents <list|none> --plugins <list|none>",
169
+ "Fixed agents deploy only as the complete six-role package; no install flag or prompt changes agent composition.",
170
+ "Non-TTY component selection: --commands <list|none> --plugins <list|none>",
169
171
  "Saved selection is reused on repeat runs. --no-core disconnects the plugin; npm removal is an explicit uninstall choice.",
170
172
  ]
171
173
  : []),
@@ -302,7 +304,21 @@ function conflicts(operations) {
302
304
  if (blocked.length)
303
305
  fail("conflict", `Plan blocked; preserved paths: ${blocked.map((operation) => operation.path).join(", ")}. Inspect doctor; restore recorded bytes or resolve ownership before retrying.`);
304
306
  }
305
- 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) {
306
322
  if (options.targets !== undefined || options.fragments !== undefined) {
307
323
  if (options.targets === undefined || options.fragments === undefined)
308
324
  fail("invalid_input", "Supply both --targets and --fragments");
@@ -312,30 +328,60 @@ async function integrationSelection(options, presetsOnly = false) {
312
328
  });
313
329
  }
314
330
  const defaults = await defaultConfigSelection(options.scope);
315
- const targets = value(await selectOptions("Applications to configure", options.scope === "project" ? ["opencode"] : CONFIG_TARGETS, defaults.targets));
316
- if (!targets.length)
317
- 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: [] };
318
374
  const connection = [];
319
- if (!presetsOnly && targets.includes("opencode")) {
320
- const choice = value(await selectOption("OpenCode plugin connection", [
321
- "Keep current connection",
322
- "Connect and pin this package version",
323
- "Disconnect agentomatic",
324
- ]));
325
- if (choice === 1)
326
- connection.push("core-plugin");
327
- if (choice === 2)
328
- connection.push("core-disable");
329
- }
330
- const available = CONFIG_FRAGMENTS.filter((fragment) => !["core-plugin", "core-disable"].includes(fragment.name) &&
331
- fragment.targets.some((target) => targets.includes(target))).map((fragment) => fragment.name);
332
- const fragments = value(await selectOptions("Application presets", available, defaults.fragments.filter((fragment) => available.includes(fragment))));
375
+ if (state.connection === 1)
376
+ connection.push("core-plugin");
377
+ if (state.connection === 2)
378
+ connection.push("core-disable");
333
379
  return normalizeConfigSelection(options.scope, {
334
- targets,
335
- fragments: [...connection, ...fragments],
380
+ targets: state.targets,
381
+ fragments: [...connection, ...applicablePresets(state.fragments, state.targets)],
336
382
  });
337
383
  }
338
- async function modelSelection(options, name, current) {
384
+ async function modelSelection(options, name, current, allowBack = false) {
339
385
  validateAgentName(name);
340
386
  if (options.model) {
341
387
  const model = validateModel(options.model.includes("/") ? options.model : `${options.provider ?? ""}/${options.model}`);
@@ -360,42 +406,102 @@ async function modelSelection(options, name, current) {
360
406
  ...(current.variant ? ["Clear variant"] : []),
361
407
  ]
362
408
  : ["Change model"];
363
- const choice = choices[value(await selectOption(`Agent: ${name}`, choices))];
364
- 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"))
365
489
  return { action: "model-set", name, model: current.model, variant: current.variant ?? null };
366
- if (choice === "Clear variant")
490
+ if (state.choice === "Clear variant")
367
491
  return { action: "model-set", name, model: current.model, variant: null };
368
- try {
369
- const models = await availableModels();
370
- const providers = [...new Set(models.map((model) => model.split("/", 1)[0]))].sort();
371
- const provider = providers[value(await selectOption("Provider", providers))];
372
- const candidates = models.filter((model) => model.startsWith(`${provider}/`));
373
- const model = candidates[value(await selectOption("Model", candidates))];
374
- const variants = await availableModelVariants(model);
375
- let variant = null;
376
- if (variants === null) {
377
- // The variants metadata is unavailable: say so honestly and accept a
378
- // manual variant instead of degrading the whole model selection.
379
- process.stderr.write("Model variants are unknown (variants metadata unavailable); enter one manually or leave it empty.\n");
380
- const answer = await promptText(current?.variant ? "Variant (empty keeps current)" : "Variant (optional)");
381
- variant = (answer ? validateVariant(answer) : current?.variant) ?? null;
382
- }
383
- else if (variants.length) {
384
- const labels = variants.map((item) => item === current?.variant ? `${item} (default)` : item);
385
- const choice = value(await selectOption("Variant", ["(none)", ...labels], undefined, undefined, current?.variant ? variants.indexOf(current.variant) + 1 : 0));
386
- variant = choice === 0 ? null : (variants[choice - 1] ?? null);
387
- }
388
- return { action: "model-set", name, model, variant };
389
- }
390
- catch (error) {
391
- if (!(error instanceof LifecycleError && error.code === "catalog_unavailable"))
392
- throw error;
393
- process.stderr.write("Start opencode in another terminal to browse models, or enter provider/model manually\n");
394
- 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");
395
498
  const answer = await promptText(current?.variant ? "Variant (empty keeps current)" : "Variant (optional)");
396
- const variant = (answer ? validateVariant(answer) : current?.variant) ?? null;
397
- return { action: "model-set", name, model, variant };
499
+ variant = (answer ? validateVariant(answer) : current?.variant) ?? null;
398
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 };
399
505
  }
400
506
  async function profileDraft(options, names) {
401
507
  const inventory = await listAgentProfiles(options.scope);
@@ -405,15 +511,20 @@ async function profileDraft(options, names) {
405
511
  const changes = [];
406
512
  while (true) {
407
513
  const actions = ["Configure model", "Add critic", "Remove additional critic", "Done"];
408
- 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];
409
518
  if (action === "Done")
410
519
  return changes;
411
520
  if (action === "Add critic") {
412
521
  const name = await promptCriticName();
413
522
  if (records.has(name))
414
523
  fail("profile_exists", `Profile already exists: ${name}`);
415
- const request = { ...(await modelSelection(options, name)), action: "critic-add" };
416
- 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" });
417
528
  names.push(name);
418
529
  records.set(name, {
419
530
  name,
@@ -431,14 +542,19 @@ async function profileDraft(options, names) {
431
542
  process.stderr.write("No eligible profiles; choose another action.\n");
432
543
  continue;
433
544
  }
434
- 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)];
435
549
  if (action === "Remove additional critic") {
436
550
  changes.push({ action: "critic-remove", name });
437
551
  names = names.filter((candidate) => candidate !== name);
438
552
  records.delete(name);
439
553
  }
440
554
  else {
441
- 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;
442
558
  changes.push(request);
443
559
  records.set(name, {
444
560
  ...records.get(name),
@@ -449,17 +565,24 @@ async function profileDraft(options, names) {
449
565
  }
450
566
  }
451
567
  }
568
+ // Descriptions for the adapter multiselect come from the command registry
569
+ // (one-to-one catalog data), with an explicit note for commandless skills.
570
+ function commandHints() {
571
+ const descriptions = new Map(COMMAND_REGISTRY.filter((command) => "skill" in command).map((command) => [command.name, command.description.split(" Russian trigger:")[0]]));
572
+ return SKILL_COMMANDS.map((name) => descriptions.get(name) ?? `No adapter: ${name} loads through the native Skill tool`);
573
+ }
452
574
  async function componentSelection(options) {
453
- const supplied = [options.commands, options.agents, options.plugins].some((selection) => selection !== undefined);
454
- if (supplied && (!options.commands || !options.agents || !options.plugins))
455
- fail("invalid_input", "Supply --commands, --agents, and --plugins together (none selects an empty set)");
575
+ const agents = [...FIXED_AGENT_ROLES];
576
+ const supplied = [options.commands, options.plugins].some((selection) => selection !== undefined);
577
+ if (supplied && (!options.commands || !options.plugins))
578
+ fail("invalid_input", "Supply --commands and --plugins together (none selects an empty set)");
456
579
  const overrides = supplied
457
580
  ? {
458
581
  commands: options.commands,
459
- agents: options.agents,
582
+ agents,
460
583
  plugins: options.plugins,
461
584
  }
462
- : {};
585
+ : { agents };
463
586
  const saved = await installedSelection(options.scope, undefined, undefined, overrides);
464
587
  const defaults = saved ?? defaultSelection();
465
588
  if (supplied) {
@@ -470,16 +593,45 @@ async function componentSelection(options) {
470
593
  }
471
594
  if (!tty()) {
472
595
  if (!saved)
473
- fail("terminal_required", "First install outside a terminal requires --commands, --agents, and --plugins");
474
- return { ...saved, core_activation: options.core ?? saved.core_activation };
596
+ fail("terminal_required", "First install outside a terminal requires --commands and --plugins");
597
+ return { ...saved, agents, core_activation: options.core ?? saved.core_activation };
475
598
  }
476
- 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.\n`);
477
- const commands = value(await selectOptions("Skill command adapters", SKILL_COMMANDS, defaults.commands));
478
- const agents = value(await selectOptions("Fixed agents", FIXED_AGENT_ROLES, defaults.agents));
479
- const plugins = value(await selectOptions("Optional plugins", SELECTABLE_PLUGINS, defaults.plugins));
480
- const core_activation = options.core ??
481
- value(await confirmQuestion("Connect the OpenCode plugin and pin its npm dependency?", process.stdin, process.stderr, defaults.core_activation));
482
- return normalizeSelection({ commands, agents, plugins, core_activation });
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`);
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
+ });
483
635
  }
484
636
  async function deploy(options, repair = false) {
485
637
  const selection = repair
@@ -495,7 +647,8 @@ async function deploy(options, repair = false) {
495
647
  if (!repair &&
496
648
  (options.targets !== undefined ||
497
649
  options.fragments !== undefined ||
498
- (tty() && value(await confirmQuestion("Configure application presets as well?"))))) {
650
+ (tty() &&
651
+ value(await confirmQuestion("Configure presets for the selected agent harnesses as well? Presets merge permission rules, the secrets guard, and terminal settings into each harness config."))))) {
499
652
  integration = await integrationSelection(options, true);
500
653
  const chosenCore = integration.fragments.includes("core-plugin")
501
654
  ? true
@@ -543,8 +696,6 @@ async function deploy(options, repair = false) {
543
696
  ? [
544
697
  "--commands",
545
698
  selection.commands.join(",") || "none",
546
- "--agents",
547
- selection.agents.join(",") || "none",
548
699
  "--plugins",
549
700
  selection.plugins.join(",") || "none",
550
701
  selection.core_activation ? "--core" : "--no-core",
@@ -569,7 +720,7 @@ async function deploy(options, repair = false) {
569
720
  if (changes.length)
570
721
  process.stdout.write(`Staged profiles: ${terminalSafe(JSON.stringify(changes))}\n`);
571
722
  process.stdout.write("Components, npm, and application configuration are separate stages; a later failure does not roll back completed stages.\n");
572
- await criticsEpilogue(options);
723
+ await epilogue(options, true);
573
724
  }
574
725
  if (options.dryRun) {
575
726
  if (options.json)
@@ -610,7 +761,7 @@ async function deploy(options, repair = false) {
610
761
  json(result);
611
762
  else {
612
763
  process.stdout.write(renderPlan(applied, { applied: true }) + renderConfigSetup(connected, { applied: true }));
613
- await criticsEpilogue(options);
764
+ await epilogue(options, true);
614
765
  }
615
766
  }
616
767
  catch (error) {
@@ -621,13 +772,15 @@ async function deploy(options, repair = false) {
621
772
  partial(options, error, completed, `Run status, then ${repair ? "maintenance repair" : "install"} with the same selected components; use configure integration for configuration-only continuation.`, completed.includes("owned-components-and-profiles") && plan.requires_restart);
622
773
  }
623
774
  }
624
- // The critic panel epilogue: the table plus the canonical view and mutation
625
- // pointers, printed wherever the panel matters (install, staged applies,
626
- // configure integration).
627
- async function criticsEpilogue(options) {
775
+ // The epilogue: install adds the fixed-role package overview first, then the
776
+ // critic panel, and every variant points at the canonical `agent list` view
777
+ // plus the configure agent mutation point.
778
+ async function epilogue(options, overview) {
628
779
  if (options.json)
629
780
  return;
630
781
  const inventory = await listAgentProfiles(options.scope);
782
+ if (overview)
783
+ process.stdout.write(renderFixedRoles(inventory));
631
784
  process.stdout.write(renderCriticsTable(inventory));
632
785
  process.stdout.write("Inspect any time: agentomatic agent list | Manage critics: agentomatic configure agent\n");
633
786
  }
@@ -650,9 +803,50 @@ function partial(options, error, completed, next, requiresRestart = false) {
650
803
  }
651
804
  class ReportedError extends Error {
652
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
+ }
653
814
  async function configureIntegration(options) {
654
815
  requireApply(options);
655
- 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
+ }
656
850
  const plan = await previewConfigSetup(selection, options.scope, undefined, undefined, !options.noDependency);
657
851
  if (!options.json)
658
852
  process.stdout.write(renderConfigSetup(plan, {
@@ -688,8 +882,10 @@ async function configureIntegration(options) {
688
882
  requires_restart: result.requires_restart,
689
883
  });
690
884
  else {
691
- process.stdout.write(renderConfigSetup(result, { applied: true }));
692
- await criticsEpilogue(options);
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");
693
889
  }
694
890
  }
695
891
  catch (error) {
@@ -738,7 +934,7 @@ async function profileChange(options, request) {
738
934
  else {
739
935
  process.stdout.write(renderPlan(result.plan, { applied: true }));
740
936
  // After a panel mutation the fresh table closes the loop.
741
- await criticsEpilogue(options);
937
+ await epilogue(options, false);
742
938
  }
743
939
  }
744
940
  async function uninstall(options) {
@@ -871,6 +1067,8 @@ async function run(args) {
871
1067
  "Agent models and critics",
872
1068
  "Application connection and presets",
873
1069
  ]));
1070
+ if (isBack(selected))
1071
+ fail("cancelled", "Wizard cancelled; no changes applied");
874
1072
  topic = routes[selected];
875
1073
  }
876
1074
  if (topic === "install" || topic === "configure components" || topic === "maintenance repair") {
@@ -956,10 +1154,14 @@ async function run(args) {
956
1154
  // configure agent on a fresh critic-<name> adds the critic: one command
957
1155
  // covers model-set for existing profiles and critic-add for new critics.
958
1156
  const request = await modelSelection(options, name, undefined);
1157
+ if (isBack(request))
1158
+ fail("cancelled", "Wizard cancelled; no changes applied");
959
1159
  await profileChange(options, { ...request, action: "critic-add" });
960
1160
  return;
961
1161
  }
962
1162
  const request = await modelSelection(options, name, existing);
1163
+ if (isBack(request))
1164
+ fail("cancelled", "Wizard cancelled; no changes applied");
963
1165
  await profileChange(options, { ...request, action: "model-set" });
964
1166
  return;
965
1167
  }
@@ -5,6 +5,7 @@ export declare class ConfigSetupError extends LifecycleError {
5
5
  export declare const CONFIG_TARGETS: readonly ["opencode", "kilo", "mimo"];
6
6
  export type ConfigTargetName = (typeof CONFIG_TARGETS)[number];
7
7
  export type TargetFileKind = "main" | "tui";
8
+ export declare const TARGET_DESCRIPTIONS: Record<ConfigTargetName, string>;
8
9
  export declare const CONFIG_FRAGMENTS: readonly [{
9
10
  readonly name: "core-disable";
10
11
  readonly description: "Disconnect only the agentomatic plugin, preserving unrelated configuration";
@@ -50,6 +50,13 @@ const SECRET_PATHS = [
50
50
  export class ConfigSetupError extends LifecycleError {
51
51
  }
52
52
  export const CONFIG_TARGETS = ["opencode", "kilo", "mimo"];
53
+ // One-line descriptions for the interactive harness prompt, kept next to the
54
+ // target list so the wizard never invents them ad hoc.
55
+ export const TARGET_DESCRIPTIONS = {
56
+ opencode: "OpenCode: config opencode.json(c) and terminal cli.json",
57
+ kilo: "Kilo Code: config kilo.json(c) and TUI settings",
58
+ mimo: "MiMo: config mimocode.json(c) and TUI settings",
59
+ };
53
60
  export const CONFIG_FRAGMENTS = [
54
61
  {
55
62
  name: "core-disable",
@@ -62,6 +62,7 @@ export type ArchiveCandidate = {
62
62
  };
63
63
  export declare const SELECTABLE_PLUGINS: SelectablePlugin[];
64
64
  export declare const SKILL_COMMANDS: string[];
65
+ export declare const PLUGIN_DESCRIPTIONS: Record<SelectablePlugin, string>;
65
66
  export declare function defaultSelection(): InstallerSelection;
66
67
  export declare function savedSelection(manifest: Pick<Manifest, "commands" | "agents" | "plugins">): Partial<InstallerSelection>;
67
68
  export declare function installedSelection(scope: Scope, cwd?: string, home?: string, overrides?: Partial<InstallerSelection>): Promise<InstallerSelection | null>;
package/dist/installer.js CHANGED
@@ -28,6 +28,14 @@ function retiredAssetPaths() {
28
28
  export const SELECTABLE_PLUGINS = [...CATALOG.plugins];
29
29
  export const SKILL_COMMANDS = [...CATALOG.skills];
30
30
  const PACKAGE_COMMANDS = [...CATALOG.package_commands];
31
+ // One-line descriptions for the interactive wrappers prompt, kept next to the
32
+ // selectable set so the wizard never invents them ad hoc.
33
+ export const PLUGIN_DESCRIPTIONS = {
34
+ "code-simplify": "Injects the code-simplify prevention ladder into agent sessions",
35
+ "rules-injector": "Injects AGENTS.md rules into the session on demand",
36
+ rtk: "Compresses verbose shell tool output through the RTK CLI",
37
+ "zed-bell": "Plays a terminal bell on session events (Zed integrated)",
38
+ };
31
39
  export function defaultSelection() {
32
40
  return {
33
41
  commands: [...SKILL_COMMANDS].sort(),
@@ -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[], 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,21 +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
- export async function selectOptions(label, options, initialSelected = [], stdin = process.stdin, stderr = process.stderr) {
96
+ // The multiselect contract: each option carries a data-driven description and
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.
99
+ const MULTISELECT_CONTROLS = " (move: up/down; toggle: space; all/none: a; enter confirms)";
100
+ export async function selectOptions(label, options, initialSelected = [], descriptions, stdin = process.stdin, stderr = process.stderr, back = false) {
31
101
  interactiveStreams(stdin, stderr);
32
102
  validateOptions(options);
33
103
  const allowed = new Set(options);
@@ -35,9 +105,16 @@ export async function selectOptions(label, options, initialSelected = [], stdin
35
105
  if (!allowed.has(value))
36
106
  throw new InstallerError("invalid_input", `Unknown initial selector option: ${value}`);
37
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());
38
115
  const value = await clack.multiselect({
39
- message: label,
40
- options: options.map((option) => ({ value: option, label: option })),
116
+ message: `${label}${MULTISELECT_CONTROLS}`,
117
+ options: entries,
41
118
  initialValues: [...initialSelected],
42
119
  required: false,
43
120
  input: stdin,
@@ -45,7 +122,10 @@ export async function selectOptions(label, options, initialSelected = [], stdin
45
122
  });
46
123
  if (clack.isCancel(value))
47
124
  return null;
48
- return value;
125
+ const selected = value;
126
+ if (selected.includes(BACK_VALUE))
127
+ return BACK;
128
+ return selected;
49
129
  }
50
130
  export async function promptText(label, stdin = process.stdin, stderr = process.stderr) {
51
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.129.g99196bf396c1",
3
+ "version": "12.0.1-dev.131.gfdcd7e5c228f",
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,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@clack/prompts": "^1.8.1",
53
- "@kisev/safe-fs": "12.0.1-dev.129.g99196bf396c1",
53
+ "@kisev/safe-fs": "12.0.1-dev.131.gfdcd7e5c228f",
54
54
  "commander": "15.0.0",
55
55
  "zod": "4.1.8"
56
56
  },