@kisev/agentomatic 11.0.0-dev.57.gb877c35a2446 → 11.0.0-dev.59.g0506803c61e5

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/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { apply, defaultSelection, InstallerError, normalizeSelection, preview, S
8
8
  import { LifecycleError } from "./lifecycle.js";
9
9
  import { skillsInstallerSpec } from "./package-metadata.js";
10
10
  import { applyReconcile, previewReconcile } from "./reconcile.js";
11
- import { promptText, selectOption, selectOptions } from "./terminal-wizard.js";
11
+ import { confirmQuestion, promptText, selectOption, selectOptions } from "./terminal-wizard.js";
12
12
  function parseOptions(values) {
13
13
  const options = {
14
14
  scope: "project",
@@ -41,6 +41,16 @@ function parseOptions(values) {
41
41
  throw new InstallerError("invalid_input", "--yes may be supplied once");
42
42
  options.yes = true;
43
43
  }
44
+ else if (value === "--core") {
45
+ if (options.core === true || options.core === false)
46
+ throw new InstallerError("invalid_input", "Use only one core integration option");
47
+ options.core = true;
48
+ }
49
+ else if (value === "--no-core") {
50
+ if (options.core === true || options.core === false)
51
+ throw new InstallerError("invalid_input", "Use only one core integration option");
52
+ options.core = false;
53
+ }
44
54
  else if (value === "--provider") {
45
55
  options.provider = values[++index];
46
56
  if (!options.provider)
@@ -162,6 +172,7 @@ function rootHelp() {
162
172
  ["--skill-commands <list|none>", "Select adapters for installed portable skills."],
163
173
  ["--agents <list|none>", "Select fixed agents."],
164
174
  ["--plugins <list|none>", "Select optional plugin wrappers; rtk is the default."],
175
+ ["--core / --no-core", "Toggle plugin entry wiring and npm dependency provisioning."],
165
176
  ["--targets <list|none>", "Select config targets for the config command."],
166
177
  ["--fragments <list|none>", "Select config fragments for the config command."],
167
178
  ]),
@@ -268,13 +279,16 @@ function contextualHelp(arguments_) {
268
279
  ["--skill-commands <list|none>", "Select installed-skill adapters."],
269
280
  ["--agents <list|none>", "Select fixed agents."],
270
281
  ["--plugins <list|none>", "Select optional plugin wrappers; rtk is preselected."],
282
+ ["--core", "Wire the plugin entry and pin the npm dependency in one run."],
283
+ ["--no-core", "Skip plugin entry wiring and npm dependency provisioning."],
271
284
  ["--json", "Emit stable machine-readable output."],
272
285
  ["--help", "Show this command help and exit."],
273
286
  ], [
274
- "Without selection flags, a TTY opens command, agent, and plugin selectors.",
287
+ "Without selection flags, a TTY opens command, agent, plugin, and core selectors.",
275
288
  "Use Up/Down to move, Space to toggle, A/N for all/none, and Enter to confirm.",
276
289
  "Outside a TTY, provide command selection, --agents, and --plugins.",
277
- "Writes occur only after confirmation; install never edits opencode.json.",
290
+ "Confirmed core integration wires the core-plugin fragment in the same run;",
291
+ "asset writes never touch opencode.json directly.",
278
292
  "Portable skills are installed separately; restart OpenCode after asset changes.",
279
293
  ], [
280
294
  shellCommand(["install", "--global", "--dry-run"]),
@@ -566,10 +580,14 @@ async function interactiveInstallerSelection() {
566
580
  const commands = await group("Skill command adapters", SKILL_COMMANDS, SKILL_COMMANDS);
567
581
  const agents = await group("Fixed agents", defaults.agents, defaults.agents);
568
582
  const plugins = await group("Selectable plugins", SELECTABLE_PLUGINS, defaults.plugins);
583
+ const core = await selectOption("Core integration: wire the plugin entry and pin the npm dependency?", ["Yes", "No"], process.stdin, process.stderr);
584
+ if (core === null)
585
+ throw new InstallerError("cancelled", "Wizard cancelled");
569
586
  return normalizeSelection({
570
587
  commands,
571
588
  agents: agents,
572
589
  plugins: plugins,
590
+ core_activation: core === 0,
573
591
  });
574
592
  }
575
593
  function installerSelection(options) {
@@ -577,6 +595,7 @@ function installerSelection(options) {
577
595
  commands: options.commands,
578
596
  agents: options.agents,
579
597
  plugins: options.plugins,
598
+ ...(options.core === undefined ? {} : { core_activation: options.core }),
580
599
  });
581
600
  }
582
601
  function configSelectionFromOptions(options) {
@@ -641,8 +660,8 @@ function requireApplyMode(options) {
641
660
  throw new InstallerError("invalid_input", "Applying outside a terminal requires --yes; use --dry-run to preview");
642
661
  }
643
662
  async function confirmSummary() {
644
- const choice = await selectOption("Apply these changes?", ["Yes", "No"]);
645
- if (choice !== 0)
663
+ const choice = await confirmQuestion("Apply these changes?");
664
+ if (choice !== true)
646
665
  throw new InstallerError("cancelled", "Cancelled before apply");
647
666
  }
648
667
  function applyHint(command) {
@@ -961,12 +980,36 @@ async function run(arguments_) {
961
980
  ? installerSelection(options)
962
981
  : await interactiveInstallerSelection()
963
982
  : undefined;
983
+ const coreSelection = action === "install" && selection?.core_activation
984
+ ? normalizeConfigSelection(options.scope, {
985
+ targets: ["opencode"],
986
+ fragments: ["core-plugin"],
987
+ })
988
+ : undefined;
989
+ const applyCoreIntegration = async () => {
990
+ if (!coreSelection)
991
+ return null;
992
+ try {
993
+ return await applyConfigSetup(coreSelection, options.scope);
994
+ }
995
+ catch (error) {
996
+ const known = error instanceof LifecycleError
997
+ ? error
998
+ : new InstallerError("internal_error", error instanceof Error ? error.message : String(error));
999
+ process.stderr.write(`Assets applied, but core integration failed [${known.code}]: ${terminalSafe(known.message)}\nRetry with: ${shellCommand(["config", ...scopeArguments(options.scope), "--targets", "opencode", "--fragments", "core-plugin"])}\n`);
1000
+ process.exitCode = 2;
1001
+ return null;
1002
+ }
1003
+ };
964
1004
  requireApplyMode(options);
965
1005
  if (options.dryRun) {
966
1006
  const plan = await preview(action, options.scope, process.cwd(), undefined, selection, !options.noDependency);
1007
+ const corePlan = coreSelection
1008
+ ? await previewConfigSetup(coreSelection, options.scope)
1009
+ : undefined;
967
1010
  if (options.json)
968
- process.stdout.write(`${JSON.stringify({ status: "ok", applied: false, requires_restart: plan.requires_restart, plan }, null, 2)}\n`);
969
- else
1011
+ process.stdout.write(`${JSON.stringify({ status: "ok", applied: false, requires_restart: plan.requires_restart, plan, ...(corePlan ? { core_integration: corePlan } : {}) }, null, 2)}\n`);
1012
+ else {
970
1013
  process.stdout.write(renderPlan(plan, {
971
1014
  applied: false,
972
1015
  applyHint: applyHint([
@@ -976,6 +1019,9 @@ async function run(arguments_) {
976
1019
  ...(options.noDependency ? ["--no-dependency"] : []),
977
1020
  ]),
978
1021
  }));
1022
+ if (corePlan)
1023
+ process.stdout.write(renderConfigSetup(corePlan, { applied: false }));
1024
+ }
979
1025
  }
980
1026
  else {
981
1027
  if (!options.yes) {
@@ -984,17 +1030,20 @@ async function run(arguments_) {
984
1030
  await confirmSummary();
985
1031
  }
986
1032
  const plan = await apply(action, options.scope, process.cwd(), undefined, {}, selection, !options.noDependency);
1033
+ const corePlan = await applyCoreIntegration();
987
1034
  if (options.json)
988
- process.stdout.write(`${JSON.stringify({ status: "ok", applied: true, requires_restart: plan.requires_restart, plan }, null, 2)}\n`);
1035
+ process.stdout.write(`${JSON.stringify({ status: "ok", applied: true, requires_restart: plan.requires_restart, plan, ...(corePlan ? { core_integration: corePlan } : {}) }, null, 2)}\n`);
989
1036
  else
990
1037
  process.stdout.write(renderPlan(plan, {
991
1038
  applied: true,
992
- ...(action === "install"
1039
+ ...(action === "install" && !corePlan
993
1040
  ? {
994
1041
  hint: `Connect the package into user configs: ${shellCommand(["config", ...scopeArguments(options.scope), "--dry-run"])}`,
995
1042
  }
996
1043
  : {}),
997
1044
  }));
1045
+ if (corePlan && !options.json)
1046
+ process.stdout.write(renderConfigSetup(corePlan, { applied: true }));
998
1047
  }
999
1048
  return;
1000
1049
  }
@@ -2,32 +2,39 @@ import { type DependencyRunner } from "./self-install.js";
2
2
  import { LifecycleError, type Scope } from "./lifecycle.js";
3
3
  export declare class ConfigSetupError extends LifecycleError {
4
4
  }
5
- export declare const CONFIG_TARGETS: readonly ["opencode", "tui", "kilo", "mimo"];
5
+ export declare const CONFIG_TARGETS: readonly ["opencode", "kilo", "mimo"];
6
6
  export type ConfigTargetName = (typeof CONFIG_TARGETS)[number];
7
+ export type TargetFileKind = "main" | "tui";
7
8
  export declare const CONFIG_FRAGMENTS: readonly [{
8
9
  readonly name: "core-plugin";
9
10
  readonly description: "Register @kisev/agentomatic in the plugin array (OpenCode only)";
10
11
  readonly targets: readonly ["opencode"];
12
+ readonly file: "main";
11
13
  }, {
12
14
  readonly name: "skills-state-permissions";
13
15
  readonly description: "Allow the standard skills XDG state paths without per-run prompts";
14
16
  readonly targets: readonly ["opencode", "kilo", "mimo"];
17
+ readonly file: "main";
15
18
  }, {
16
19
  readonly name: "lsp-preset";
17
20
  readonly description: "Add LSP servers from the shared catalog (OpenCode only)";
18
21
  readonly targets: readonly ["opencode"];
22
+ readonly file: "main";
19
23
  }, {
20
24
  readonly name: "secrets-guard";
21
25
  readonly description: "Deny reads and edits of common secret files";
22
26
  readonly targets: readonly ["opencode", "kilo", "mimo"];
27
+ readonly file: "main";
23
28
  }, {
24
29
  readonly name: "kilo-display";
25
30
  readonly description: "Expand Kilo reasoning, terminal, edit, and tool blocks";
26
31
  readonly targets: readonly ["kilo"];
32
+ readonly file: "main";
27
33
  }, {
28
34
  readonly name: "tui-schema";
29
- readonly description: "Add the tui.json schema and stacked diffs";
30
- readonly targets: readonly ["tui"];
35
+ readonly description: "Unify each agent TUI file: schema, theme, stacked diffs, and leader keybinds";
36
+ readonly targets: readonly ["opencode", "kilo", "mimo"];
37
+ readonly file: "tui";
31
38
  }];
32
39
  export type FragmentName = (typeof CONFIG_FRAGMENTS)[number]["name"];
33
40
  export type ConfigSetupSelection = {
@@ -1,7 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { lstat } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
6
+ import { archiveMutations } from "./installer.js";
5
7
  import { requirePackageVersion } from "./package-metadata.js";
6
8
  import { ensureDependency, planDependency } from "./self-install.js";
7
9
  import { applyTransaction, deploymentRoot, LifecycleError, lifecycleRoot, readRegular, recoverTransaction, sha256, withLifecycleLock, } from "./lifecycle.js";
@@ -35,40 +37,68 @@ const SECRET_PATHS = [
35
37
  ];
36
38
  export class ConfigSetupError extends LifecycleError {
37
39
  }
38
- export const CONFIG_TARGETS = ["opencode", "tui", "kilo", "mimo"];
40
+ export const CONFIG_TARGETS = ["opencode", "kilo", "mimo"];
39
41
  export const CONFIG_FRAGMENTS = [
40
42
  {
41
43
  name: "core-plugin",
42
44
  description: "Register @kisev/agentomatic in the plugin array (OpenCode only)",
43
45
  targets: ["opencode"],
46
+ file: "main",
44
47
  },
45
48
  {
46
49
  name: "skills-state-permissions",
47
50
  description: "Allow the standard skills XDG state paths without per-run prompts",
48
51
  targets: ["opencode", "kilo", "mimo"],
52
+ file: "main",
49
53
  },
50
54
  {
51
55
  name: "lsp-preset",
52
56
  description: "Add LSP servers from the shared catalog (OpenCode only)",
53
57
  targets: ["opencode"],
58
+ file: "main",
54
59
  },
55
60
  {
56
61
  name: "secrets-guard",
57
62
  description: "Deny reads and edits of common secret files",
58
63
  targets: ["opencode", "kilo", "mimo"],
64
+ file: "main",
59
65
  },
60
66
  {
61
67
  name: "kilo-display",
62
68
  description: "Expand Kilo reasoning, terminal, edit, and tool blocks",
63
69
  targets: ["kilo"],
70
+ file: "main",
64
71
  },
65
72
  {
66
73
  name: "tui-schema",
67
- description: "Add the tui.json schema and stacked diffs",
68
- targets: ["tui"],
74
+ description: "Unify each agent TUI file: schema, theme, stacked diffs, and leader keybinds",
75
+ targets: ["opencode", "kilo", "mimo"],
76
+ file: "tui",
69
77
  },
70
78
  ];
71
79
  const FRAGMENT_NAMES = CONFIG_FRAGMENTS.map((fragment) => fragment.name);
80
+ const UNIFIED_TUI_KEYBINDS = {
81
+ command_list: "alt+p",
82
+ app_exit: "ctrl+d,<leader>q,<leader>й",
83
+ messages_first: "ctrl+g",
84
+ messages_last: "ctrl+alt+g",
85
+ editor_open: "<leader>e,<leader>у",
86
+ theme_list: "<leader>t,<leader>е",
87
+ sidebar_toggle: "<leader>b,<leader>и",
88
+ status_view: "<leader>s,<leader>ы",
89
+ session_export: "<leader>x,<leader>ч",
90
+ session_new: "<leader>n,<leader>т",
91
+ session_list: "<leader>l,<leader>д",
92
+ session_timeline: "<leader>g,<leader>п",
93
+ session_compact: "<leader>c,<leader>с",
94
+ model_list: "<leader>m,<leader>ь",
95
+ agent_list: "<leader>a,<leader>ф",
96
+ messages_copy: "<leader>y,<leader>н",
97
+ messages_undo: "<leader>u,<leader>г",
98
+ messages_redo: "<leader>r,<leader>к",
99
+ messages_toggle_conceal: "<leader>h,<leader>р",
100
+ tips_toggle: "<leader>h,<leader>р",
101
+ };
72
102
  const LSP_SERVER_COMMANDS = {
73
103
  python: ["basedpyright-langserver", "--stdio"],
74
104
  typescript: ["typescript-language-server", "--stdio"],
@@ -151,20 +181,39 @@ function fragmentEdits(fragment, target, current) {
151
181
  { kind: "set-if-absent", path: ["mcp_tool_display"], value: "expanded" },
152
182
  ];
153
183
  }
184
+ const tuiSchemas = {
185
+ opencode: "https://opencode.ai/tui.json",
186
+ kilo: undefined,
187
+ mimo: "https://mimo.xiaomi.com/mimocode/tui.json",
188
+ };
154
189
  return [
155
- { kind: "set-if-absent", path: ["$schema"], value: "https://opencode.ai/tui.json" },
190
+ ...(tuiSchemas[target]
191
+ ? [{ kind: "set-if-absent", path: ["$schema"], value: tuiSchemas[target] }]
192
+ : []),
193
+ { kind: "set-if-absent", path: ["theme"], value: "ayu" },
156
194
  { kind: "set-if-absent", path: ["diff_style"], value: "stacked" },
195
+ ...Object.entries(UNIFIED_TUI_KEYBINDS).map(([key, value]) => ({
196
+ kind: "set-if-absent",
197
+ path: ["keybinds", key],
198
+ value,
199
+ })),
157
200
  ];
158
201
  }
159
202
  function applicable(fragment, target) {
160
203
  const targets = CONFIG_FRAGMENTS.find((item) => item.name === fragment)?.targets ?? [];
161
204
  return targets.includes(target);
162
205
  }
206
+ function fragmentFileKind(fragment) {
207
+ return CONFIG_FRAGMENTS.find((item) => item.name === fragment)?.file ?? "main";
208
+ }
163
209
  export function normalizeConfigSelection(scope, value = {}) {
164
210
  const requestedTargets = value.targets ?? ["opencode"];
165
211
  const targets = CONFIG_TARGETS.filter((target) => requestedTargets.includes(target));
166
- if (targets.length !== requestedTargets.length)
212
+ if (targets.length !== requestedTargets.length) {
213
+ if (requestedTargets.includes("tui"))
214
+ throw new ConfigSetupError("invalid_selection", "The tui target moved into opencode, kilo, and mimo as the tui-schema fragment");
167
215
  throw new ConfigSetupError("invalid_selection", "Unknown config target selection");
216
+ }
168
217
  if (scope === "project" && (targets.length !== 1 || targets[0] !== "opencode"))
169
218
  throw new ConfigSetupError("invalid_selection", "Project scope supports only the opencode target");
170
219
  const requestedFragments = value.fragments ?? [];
@@ -177,8 +226,6 @@ export async function defaultConfigSelection(scope, cwd = process.cwd(), home =
177
226
  const exists = async (path) => Boolean(await readRegular(path).catch(() => undefined));
178
227
  const targets = ["opencode"];
179
228
  if (scope === "global") {
180
- if (await exists(join(home, ".config", "opencode", "tui.json")))
181
- targets.push("tui");
182
229
  if (await exists(join(home, ".config", "kilo", "kilo.json")))
183
230
  targets.push("kilo");
184
231
  if (await exists(join(home, ".config", "kilo", "kilo.jsonc")))
@@ -193,9 +240,41 @@ export async function defaultConfigSelection(scope, cwd = process.cwd(), home =
193
240
  fragments: [...FRAGMENT_NAMES],
194
241
  });
195
242
  }
196
- async function resolveTargetFile(target, scope, cwd, home) {
243
+ async function resolveTargetFile(target, scope, cwd, home, kind = "main") {
197
244
  const existing = async (root, path) => Boolean(await readRegular(join(root, path)).catch(() => undefined));
198
245
  const globalRoot = deploymentRoot("global", cwd, home);
246
+ if (kind === "tui") {
247
+ if (target === "kilo") {
248
+ const kiloRoot = join(home, ".config", "kilo");
249
+ for (const path of ["tui.jsonc", "tui.json"])
250
+ if (await existing(kiloRoot, path))
251
+ return { target, root: kiloRoot, path, absolute: join(kiloRoot, path), exists: true };
252
+ return {
253
+ target,
254
+ root: kiloRoot,
255
+ path: "tui.jsonc",
256
+ absolute: join(kiloRoot, "tui.jsonc"),
257
+ exists: false,
258
+ };
259
+ }
260
+ if (target === "mimo") {
261
+ const mimoRoot = join(home, ".config", "mimocode");
262
+ return {
263
+ target,
264
+ root: mimoRoot,
265
+ path: "tui.json",
266
+ absolute: join(mimoRoot, "tui.json"),
267
+ exists: await existing(mimoRoot, "tui.json"),
268
+ };
269
+ }
270
+ return {
271
+ target,
272
+ root: globalRoot,
273
+ path: "tui.json",
274
+ absolute: join(globalRoot, "tui.json"),
275
+ exists: await existing(globalRoot, "tui.json"),
276
+ };
277
+ }
199
278
  if (target === "opencode") {
200
279
  if (scope === "global") {
201
280
  for (const path of ["opencode.json", "opencode.jsonc"])
@@ -226,14 +305,6 @@ async function resolveTargetFile(target, scope, cwd, home) {
226
305
  exists: false,
227
306
  };
228
307
  }
229
- if (target === "tui")
230
- return {
231
- target,
232
- root: globalRoot,
233
- path: "tui.json",
234
- absolute: join(globalRoot, "tui.json"),
235
- exists: await existing(globalRoot, "tui.json"),
236
- };
237
308
  if (target === "kilo") {
238
309
  const root = join(home, ".config", "kilo");
239
310
  for (const path of ["kilo.jsonc", "kilo.json"])
@@ -260,86 +331,101 @@ async function build(selection, scope, cwd = process.cwd(), home = homedir()) {
260
331
  const allowedRoots = new Set([root]);
261
332
  const files = [];
262
333
  for (const target of CONFIG_TARGETS.filter((name) => selection.targets.includes(name))) {
263
- let file;
264
- let current;
265
- try {
266
- file = await resolveTargetFile(target, scope, cwd, home);
267
- current = file.exists ? await readRegular(file.absolute) : undefined;
268
- }
269
- catch (error) {
270
- const reason = error instanceof LifecycleError ? error.message : "target is unreadable";
271
- for (const fragment of selection.fragments.filter((item) => applicable(item, target)))
272
- operations.push({ target, path: "unknown", fragment, operation: "conflict", reason });
273
- continue;
274
- }
275
- if (file.exists && current === undefined) {
276
- for (const fragment of selection.fragments.filter((item) => applicable(item, target)))
277
- operations.push({
278
- target,
279
- path: file.absolute,
280
- fragment,
281
- operation: "conflict",
282
- reason: "target is unreadable",
283
- });
284
- continue;
285
- }
286
- files.push(file);
287
- allowedRoots.add(file.root);
288
- let text = current ? current.toString("utf8") : "{}\n";
289
- let changed = false;
290
- for (const fragment of selection.fragments) {
291
- if (!applicable(fragment, target))
292
- continue;
293
- let parsed = undefined;
334
+ const kinds = ["main"];
335
+ const wantsTui = selection.fragments.some((fragment) => applicable(fragment, target) && fragmentFileKind(fragment) === "tui");
336
+ if (wantsTui && scope === "global")
337
+ kinds.push("tui");
338
+ for (const kind of kinds) {
339
+ let file;
340
+ let current;
294
341
  try {
295
- parsed = parseJsonc(text);
342
+ file = await resolveTargetFile(target, scope, cwd, home, kind);
343
+ current = file.exists ? await readRegular(file.absolute) : undefined;
296
344
  }
297
- catch {
298
- parsed = undefined;
345
+ catch (error) {
346
+ const reason = error instanceof LifecycleError ? error.message : "target is unreadable";
347
+ for (const fragment of selection.fragments.filter((item) => applicable(item, target) && fragmentFileKind(item) === kind))
348
+ operations.push({ target, path: "unknown", fragment, operation: "conflict", reason });
349
+ continue;
299
350
  }
300
- try {
301
- const applied = applyJsoncEdits(text, fragmentEdits(fragment, target, parsed));
302
- if (applied.changed) {
303
- text = applied.text;
304
- changed = true;
351
+ if (file.exists && current === undefined) {
352
+ for (const fragment of selection.fragments.filter((item) => applicable(item, target) && fragmentFileKind(item) === kind))
305
353
  operations.push({
306
354
  target,
307
355
  path: file.absolute,
308
356
  fragment,
309
- operation: file.exists ? "update" : "create",
357
+ operation: "conflict",
358
+ reason: "target is unreadable",
310
359
  });
360
+ continue;
361
+ }
362
+ files.push(file);
363
+ allowedRoots.add(file.root);
364
+ let text = current ? current.toString("utf8") : "{}\n";
365
+ let changed = false;
366
+ for (const fragment of selection.fragments) {
367
+ if (!applicable(fragment, target) || fragmentFileKind(fragment) !== kind)
368
+ continue;
369
+ let parsed = undefined;
370
+ try {
371
+ parsed = parseJsonc(text);
311
372
  }
312
- else {
313
- operations.push({ target, path: file.absolute, fragment, operation: "unchanged" });
373
+ catch {
374
+ parsed = undefined;
375
+ }
376
+ try {
377
+ const applied = applyJsoncEdits(text, fragmentEdits(fragment, target, parsed));
378
+ if (applied.changed) {
379
+ text = applied.text;
380
+ changed = true;
381
+ operations.push({
382
+ target,
383
+ path: file.absolute,
384
+ fragment,
385
+ operation: file.exists ? "update" : "create",
386
+ });
387
+ }
388
+ else {
389
+ operations.push({ target, path: file.absolute, fragment, operation: "unchanged" });
390
+ }
391
+ }
392
+ catch (error) {
393
+ operations.push({
394
+ target,
395
+ path: file.absolute,
396
+ fragment,
397
+ operation: "conflict",
398
+ reason: error instanceof LifecycleError || error instanceof Error
399
+ ? error.message
400
+ : String(error),
401
+ });
314
402
  }
315
403
  }
316
- catch (error) {
317
- operations.push({
318
- target,
319
- path: file.absolute,
320
- fragment,
321
- operation: "conflict",
322
- reason: error instanceof LifecycleError || error instanceof Error
323
- ? error.message
324
- : String(error),
404
+ if (changed) {
405
+ const content = Buffer.from(text.endsWith("\n") ? text : `${text}\n`);
406
+ mutations.push({
407
+ ...(file.root === root ? {} : { root: file.root }),
408
+ path: file.path,
409
+ operation: "write",
410
+ content,
411
+ mode: 0o644,
412
+ expected: current ? { sha256: sha256(current) } : { absent: true },
325
413
  });
326
414
  }
327
415
  }
328
- if (changed) {
329
- const content = Buffer.from(text.endsWith("\n") ? text : `${text}\n`);
330
- mutations.push({
331
- ...(file.root === root ? {} : { root: file.root }),
332
- path: file.path,
333
- operation: "write",
334
- content,
335
- mode: 0o644,
336
- expected: current ? { sha256: sha256(current) } : { absent: true },
337
- });
338
- }
339
416
  }
340
417
  const skipped = selection.fragments
341
- .filter((fragment) => !selection.targets.some((target) => applicable(fragment, target)))
342
- .map((fragment) => ({ fragment, reason: "no selected target supports this fragment" }));
418
+ .filter((fragment) => {
419
+ if (fragmentFileKind(fragment) === "tui" && scope === "project")
420
+ return true;
421
+ return !selection.targets.some((target) => applicable(fragment, target));
422
+ })
423
+ .map((fragment) => ({
424
+ fragment,
425
+ reason: fragmentFileKind(fragment) === "tui" && scope === "project"
426
+ ? "TUI files are global-only"
427
+ : "no selected target supports this fragment",
428
+ }));
343
429
  const sorted = operations.sort((left, right) => CONFIG_TARGETS.indexOf(left.target) - CONFIG_TARGETS.indexOf(right.target) ||
344
430
  FRAGMENT_NAMES.indexOf(left.fragment) -
345
431
  FRAGMENT_NAMES.indexOf(right.fragment) ||
@@ -414,7 +500,27 @@ export async function applyConfigSetup(selection, scope, cwd = process.cwd(), ho
414
500
  }
415
501
  throw new ConfigSetupError("invalid_state", "Config setup plan has no applicable changes");
416
502
  }
417
- await applyTransaction(root, stateRoot, built.mutations, {
503
+ const backups = [];
504
+ for (const mutation of built.mutations) {
505
+ if (mutation.operation !== "write")
506
+ continue;
507
+ const target = join(mutation.root ?? root, mutation.path);
508
+ const current = await readRegular(target).catch(() => undefined);
509
+ if (!current || current.equals(mutation.content))
510
+ continue;
511
+ backups.push({
512
+ path: target,
513
+ record: {
514
+ sha256: sha256(current),
515
+ mode: (await lstat(target)).mode & 0o777,
516
+ },
517
+ content: current,
518
+ reason: "pre-apply config backup",
519
+ kind: "config-backup",
520
+ });
521
+ }
522
+ const backupMutations = await archiveMutations(backups, scope, cwd, home, requirePackageVersion(), new Date().toISOString());
523
+ await applyTransaction(root, stateRoot, [...built.mutations, ...backupMutations], {
418
524
  validateFinal: async () => {
419
525
  for (const mutation of built.mutations) {
420
526
  if (mutation.operation !== "write")
package/dist/doctor.js CHANGED
@@ -505,6 +505,31 @@ export async function collectDoctorFacts(scope, cwd = process.cwd(), home = home
505
505
  status: dependency.status,
506
506
  }, ["Run config with the core-plugin fragment to provision the persistent npm dependency."]));
507
507
  }
508
+ const archiveIndexPath = join(archiveRoot(scope, project, homeRoot), "index.json");
509
+ const archiveIndexRaw = await regular(archiveIndexPath);
510
+ let backupEntries = [];
511
+ if (archiveIndexRaw) {
512
+ try {
513
+ const parsed = JSON.parse(archiveIndexRaw.raw.toString("utf8"));
514
+ if (Array.isArray(parsed.entries))
515
+ backupEntries = parsed.entries.filter((item) => Boolean(item &&
516
+ typeof item === "object" &&
517
+ item.kind === "config-backup"));
518
+ }
519
+ catch {
520
+ partial.push("config.backups");
521
+ }
522
+ }
523
+ const latestBackup = backupEntries
524
+ .map((item) => String(item.timestamp ?? ""))
525
+ .sort()
526
+ .at(-1);
527
+ checks.push(check("config.backups", archiveIndexRaw ? "pass" : "incomplete", "User configuration snapshots are tracked in the archive", {
528
+ archive_index: archiveIndexPath,
529
+ config_backups: backupEntries.length,
530
+ latest_backup_at: latestBackup ?? "never",
531
+ latest_paths: backupEntries.slice(-3).map((item) => String(item.original_path ?? "")),
532
+ }, ["Configuration edits archive the previous content before every apply."]));
508
533
  const rtkWrapper = await regular(join(deployment, "plugins", "rtk.js"));
509
534
  const rtkDeployed = rtkWrapper.status === "present";
510
535
  let rtkStats = undefined;
@@ -31,17 +31,15 @@ export type Plan = {
31
31
  };
32
32
  requires_restart: boolean;
33
33
  };
34
- type ManifestFile = {
35
- sha256: string;
36
- mode: number;
37
- kind: "command" | "agent" | "plugin" | "state";
38
- };
39
34
  export type ArchiveCandidate = {
40
35
  path: string;
41
- record: ManifestFile;
36
+ record: {
37
+ sha256: string;
38
+ mode: number;
39
+ };
42
40
  content: Buffer;
43
41
  reason: string;
44
- kind: "command" | "agent" | "plugin" | "state";
42
+ kind: "command" | "agent" | "plugin" | "state" | "config-backup";
45
43
  };
46
44
  export declare const SELECTABLE_PLUGINS: SelectablePlugin[];
47
45
  export declare const SKILL_COMMANDS: string[];
@@ -49,7 +47,7 @@ export declare function defaultSelection(): InstallerSelection;
49
47
  export declare function normalizeSelection(value?: Partial<InstallerSelection>): InstallerSelection;
50
48
  export declare class InstallerError extends LifecycleError {
51
49
  }
52
- export declare function archiveMutations(candidates: readonly ArchiveCandidate[], scope: Scope, cwd: string, home: string, sourceVersion: string): Promise<FileMutation[]>;
50
+ export declare function archiveMutations(candidates: readonly ArchiveCandidate[], scope: Scope, cwd: string, home: string, sourceVersion: string, timestamp?: string): Promise<FileMutation[]>;
53
51
  export declare function preview(action: Action, scope: Scope, cwd?: string, home?: string, selection?: Partial<InstallerSelection>, provisionDependency?: boolean): Promise<Plan>;
54
52
  export declare function apply(action: Action, scope: Scope, cwd?: string, home?: string, options?: TransactionOptions & {
55
53
  dependencyRunner?: DependencyRunner;
package/dist/installer.js CHANGED
@@ -184,7 +184,7 @@ async function currentManifest(root) {
184
184
  }
185
185
  return {};
186
186
  }
187
- export async function archiveMutations(candidates, scope, cwd, home, sourceVersion) {
187
+ export async function archiveMutations(candidates, scope, cwd, home, sourceVersion, timestamp = new Date(0).toISOString()) {
188
188
  if (!candidates.length)
189
189
  return [];
190
190
  const root = archiveRoot(scope, cwd, home);
@@ -226,7 +226,7 @@ export async function archiveMutations(candidates, scope, cwd, home, sourceVersi
226
226
  const known = new Set(entries.map((entry) => `${String(entry.original_path)}:${String(entry.digest)}`));
227
227
  const mutations = [];
228
228
  const queuedObjects = new Set();
229
- const now = new Date(0).toISOString();
229
+ const now = timestamp;
230
230
  for (const candidate of candidates) {
231
231
  const digestValue = sha256(candidate.content);
232
232
  const key = `${candidate.path}:${digestValue}`;
package/dist/reconcile.js CHANGED
@@ -223,7 +223,7 @@ async function build(scope, cwd = process.cwd(), home = homedir()) {
223
223
  : "state";
224
224
  archiveCandidates.push({
225
225
  path: relativePath(root, target),
226
- record: { sha256: record.sha256, mode: 0o644, kind },
226
+ record: { sha256: record.sha256, mode: 0o644 },
227
227
  content: value.content,
228
228
  reason: "retired inventory asset",
229
229
  kind,
@@ -1,3 +1,5 @@
1
- export declare function selectOption(label: string, options: readonly string[], stdin?: NodeJS.ReadStream, stderr?: NodeJS.WriteStream, initial?: number): Promise<number | null>;
2
- export declare function selectOptions(label: string, options: readonly string[], initialSelected?: readonly string[], stdin?: NodeJS.ReadStream, stderr?: NodeJS.WriteStream): Promise<string[] | null>;
3
- export declare function promptText(label: string, stdin?: NodeJS.ReadStream, stderr?: NodeJS.WriteStream): Promise<string | null>;
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>;
4
+ export declare function promptText(label: string, stdin?: Readable, stderr?: Writable): Promise<string | null>;
5
+ export declare function confirmQuestion(label: string, stdin?: Readable, stderr?: Writable): Promise<boolean | null>;
@@ -1,36 +1,11 @@
1
+ import * as clack from "@clack/prompts";
1
2
  import { InstallerError } from "./installer.js";
2
- function parseKey(buffer) {
3
- const seq = buffer.toString();
4
- if (seq === "\r" || seq === "\n")
5
- return { name: "return", sequence: seq };
6
- if (seq === " ")
7
- return { name: "space", sequence: seq };
8
- if (seq === "a" || seq === "A")
9
- return { name: "all", sequence: seq };
10
- if (seq === "n" || seq === "N")
11
- return { name: "none", sequence: seq };
12
- if (seq === "\x03")
13
- return { name: "ctrl+c", sequence: seq };
14
- if (seq === "\x04")
15
- return { name: "ctrl+d", sequence: seq };
16
- if (seq === "\x1b" || seq === "\x1b\x1b" || seq === "\x1b\x1b\x1b")
17
- return { name: "escape", sequence: seq };
18
- if (seq === "\x1b[A" || seq === "\x1bOA" || seq === "\x1b[OA")
19
- return { name: "up", sequence: seq };
20
- if (seq === "\x1b[B" || seq === "\x1bOB" || seq === "\x1b[OB")
21
- return { name: "down", sequence: seq };
22
- if (seq === "\x1b[C" || seq === "\x1bOC" || seq === "\x1b[OC")
23
- return { name: "right", sequence: seq };
24
- if (seq === "\x1b[D" || seq === "\x1bOD" || seq === "\x1b[OD")
25
- return { name: "left", sequence: seq };
26
- return { name: "unknown", sequence: seq };
3
+ function interactiveStreams(stdin, stderr) {
4
+ if (!isTTY(stdin) || !isTTY(stderr))
5
+ throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
27
6
  }
28
- function nextSequence(remaining) {
29
- if (remaining.startsWith("\x1b[O"))
30
- return remaining.slice(0, 4);
31
- if (remaining.startsWith("\x1b[") || remaining.startsWith("\x1bO"))
32
- return remaining.slice(0, 3);
33
- return remaining[0];
7
+ function isTTY(stream) {
8
+ return Boolean(stream.isTTY);
34
9
  }
35
10
  function validateOptions(options) {
36
11
  if (options.length === 0)
@@ -38,200 +13,52 @@ function validateOptions(options) {
38
13
  if (new Set(options).size !== options.length)
39
14
  throw new InstallerError("invalid_input", "Interactive selector options must be unique");
40
15
  }
41
- function clearLines(stderr, count) {
42
- for (let i = 0; i < count; i++) {
43
- stderr.write("\x1b[A\x1b[K");
44
- }
45
- }
46
16
  export async function selectOption(label, options, stdin = process.stdin, stderr = process.stderr, initial = 0) {
47
- if (!stdin.isTTY || !stderr.isTTY) {
48
- throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
49
- }
17
+ interactiveStreams(stdin, stderr);
50
18
  validateOptions(options);
51
- let selected = Math.max(0, Math.min(initial, options.length - 1));
52
- const lineCount = options.length + 2;
53
- function render() {
54
- stderr.write(`? ${label}\n`);
55
- stderr.write(" Up/Down move | Enter select | Esc cancel\n");
56
- for (let i = 0; i < options.length; i++) {
57
- const cursor = i === selected ? "> " : " ";
58
- stderr.write(`${cursor}${options[i]}\n`);
59
- }
60
- }
61
- render();
62
- return new Promise((resolve) => {
63
- stdin.setRawMode(true);
64
- stdin.resume();
65
- function cleanup() {
66
- stdin.setRawMode(false);
67
- stdin.pause();
68
- stdin.removeListener("data", onData);
69
- }
70
- function onData(data) {
71
- let remaining = data.toString();
72
- while (remaining) {
73
- const sequence = nextSequence(remaining);
74
- remaining = remaining.slice(sequence.length);
75
- const key = parseKey(Buffer.from(sequence));
76
- if (key.name === "ctrl+c" || key.name === "ctrl+d" || key.name === "escape") {
77
- cleanup();
78
- clearLines(stderr, lineCount);
79
- resolve(null);
80
- return;
81
- }
82
- if (key.name === "return") {
83
- cleanup();
84
- clearLines(stderr, lineCount);
85
- resolve(selected);
86
- return;
87
- }
88
- if (key.name === "up") {
89
- clearLines(stderr, lineCount);
90
- selected = (selected - 1 + options.length) % options.length;
91
- render();
92
- }
93
- else if (key.name === "down") {
94
- clearLines(stderr, lineCount);
95
- selected = (selected + 1) % options.length;
96
- render();
97
- }
98
- }
99
- }
100
- stdin.on("data", onData);
19
+ const value = await clack.select({
20
+ message: label,
21
+ options: options.map((option, index) => ({ value: index, label: option })),
22
+ initialValue: Math.max(0, Math.min(initial, options.length - 1)),
23
+ input: stdin,
24
+ output: stderr,
101
25
  });
26
+ if (clack.isCancel(value))
27
+ return null;
28
+ return value;
102
29
  }
103
30
  export async function selectOptions(label, options, initialSelected = [], stdin = process.stdin, stderr = process.stderr) {
104
- if (!stdin.isTTY || !stderr.isTTY) {
105
- throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
106
- }
31
+ interactiveStreams(stdin, stderr);
107
32
  validateOptions(options);
108
33
  const allowed = new Set(options);
109
34
  for (const value of initialSelected) {
110
35
  if (!allowed.has(value))
111
36
  throw new InstallerError("invalid_input", `Unknown initial selector option: ${value}`);
112
37
  }
113
- const selected = new Set(initialSelected);
114
- let active = 0;
115
- const lineCount = options.length + 3;
116
- function render() {
117
- stderr.write(`? ${label}\n`);
118
- stderr.write(" Up/Down move | Space toggle | A all | N none | Enter confirm | Esc cancel\n");
119
- for (let index = 0; index < options.length; index += 1) {
120
- const cursor = index === active ? "> " : " ";
121
- const marker = selected.has(options[index]) ? "[x]" : "[ ]";
122
- stderr.write(`${cursor}${marker} ${options[index]}\n`);
123
- }
124
- stderr.write(` Selected ${selected.size}/${options.length}\n`);
125
- }
126
- render();
127
- return new Promise((resolve) => {
128
- stdin.setRawMode(true);
129
- stdin.resume();
130
- function cleanup() {
131
- stdin.setRawMode(false);
132
- stdin.pause();
133
- stdin.removeListener("data", onData);
134
- }
135
- function finish(value) {
136
- cleanup();
137
- clearLines(stderr, lineCount);
138
- resolve(value);
139
- }
140
- function redraw(update) {
141
- clearLines(stderr, lineCount);
142
- update();
143
- render();
144
- }
145
- function onData(data) {
146
- let remaining = data.toString();
147
- while (remaining) {
148
- const sequence = nextSequence(remaining);
149
- remaining = remaining.slice(sequence.length);
150
- const key = parseKey(Buffer.from(sequence));
151
- if (key.name === "ctrl+c" || key.name === "ctrl+d" || key.name === "escape") {
152
- finish(null);
153
- return;
154
- }
155
- if (key.name === "return") {
156
- finish(options.filter((option) => selected.has(option)));
157
- return;
158
- }
159
- if (key.name === "up") {
160
- redraw(() => {
161
- active = (active - 1 + options.length) % options.length;
162
- });
163
- }
164
- else if (key.name === "down") {
165
- redraw(() => {
166
- active = (active + 1) % options.length;
167
- });
168
- }
169
- else if (key.name === "space") {
170
- redraw(() => {
171
- const option = options[active];
172
- if (selected.has(option))
173
- selected.delete(option);
174
- else
175
- selected.add(option);
176
- });
177
- }
178
- else if (key.name === "all") {
179
- redraw(() => {
180
- for (const option of options)
181
- selected.add(option);
182
- });
183
- }
184
- else if (key.name === "none") {
185
- redraw(() => selected.clear());
186
- }
187
- }
188
- }
189
- stdin.on("data", onData);
38
+ const value = await clack.multiselect({
39
+ message: label,
40
+ options: options.map((option) => ({ value: option, label: option })),
41
+ initialValues: [...initialSelected],
42
+ required: false,
43
+ input: stdin,
44
+ output: stderr,
190
45
  });
46
+ if (clack.isCancel(value))
47
+ return null;
48
+ return value;
191
49
  }
192
50
  export async function promptText(label, stdin = process.stdin, stderr = process.stderr) {
193
- if (!stdin.isTTY || !stderr.isTTY) {
194
- throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
195
- }
196
- stderr.write(`? ${label}\n`);
197
- stderr.write(" Type a value | Enter confirm | Esc cancel\n");
198
- stderr.write("> ");
199
- return new Promise((resolve) => {
200
- let buffer = "";
201
- stdin.setRawMode(true);
202
- stdin.resume();
203
- function cleanup() {
204
- stdin.setRawMode(false);
205
- stdin.pause();
206
- stdin.removeListener("data", onData);
207
- }
208
- function onData(data) {
209
- for (const seq of data.toString()) {
210
- if (seq === "\r" || seq === "\n") {
211
- cleanup();
212
- stderr.write("\n");
213
- resolve(buffer.trim() || null);
214
- return;
215
- }
216
- if (seq === "\x03" || seq === "\x04" || seq === "\x1b") {
217
- cleanup();
218
- stderr.write("\n");
219
- resolve(null);
220
- return;
221
- }
222
- if (seq === "\x7f" || seq === "\b") {
223
- if (buffer.length > 0) {
224
- buffer = buffer.slice(0, -1);
225
- stderr.write("\x1b[D \x1b[D");
226
- }
227
- continue;
228
- }
229
- if (seq >= " ") {
230
- buffer += seq;
231
- stderr.write(seq);
232
- }
233
- }
234
- }
235
- stdin.on("data", onData);
236
- });
51
+ interactiveStreams(stdin, stderr);
52
+ const value = await clack.text({ message: label, input: stdin, output: stderr });
53
+ if (clack.isCancel(value))
54
+ return null;
55
+ const trimmed = value.trim();
56
+ return trimmed.length ? trimmed : null;
57
+ }
58
+ export async function confirmQuestion(label, stdin = process.stdin, stderr = process.stderr) {
59
+ interactiveStreams(stdin, stderr);
60
+ const value = await clack.confirm({ message: label, input: stdin, output: stderr });
61
+ if (clack.isCancel(value))
62
+ return null;
63
+ return value;
237
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/agentomatic",
3
- "version": "11.0.0-dev.57.gb877c35a2446",
3
+ "version": "11.0.0-dev.59.g0506803c61e5",
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": {
@@ -49,8 +49,9 @@
49
49
  "pack:check": "node test/pack-allowlist.mjs"
50
50
  },
51
51
  "dependencies": {
52
- "@kisev/memomatic": "1.0.0-dev.57.gb877c35a2446",
53
- "@kisev/safe-fs": "1.0.0-dev.57.gb877c35a2446"
52
+ "@clack/prompts": "^1.8.1",
53
+ "@kisev/memomatic": "1.0.0-dev.59.g0506803c61e5",
54
+ "@kisev/safe-fs": "1.0.0-dev.59.g0506803c61e5"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@opencode-ai/plugin": "1.18.29",