@tacuchi/agent-workflow-cli 25.3.1 → 25.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // [Skills] — loose-skills manager (skills.sh model): single list with badges
3
- // (installed → unmanaged → registered → recommended), detail with per-status
4
- // actions (unmanaged = informational) and an [a] wizard: source → picker →
5
- // third-party warning → register.
6
- // The list opens PROJECTED to the recommended seed and `t` toggles to every
2
+ // [Skills] — external-skills manager (skills.sh model): one list with badges
3
+ // (installed → unmanaged → registered → recommended), a detail that states
4
+ // INSTALLATION and RECOMMENDATION as separate facts, and one journey for every
5
+ // change: selection → preview → applying → result
6
+ // (DES-001@r7 / SCR-001@r3).
7
+ //
8
+ // Since Spec 043 there is no shortcut that mutates: every action prepares a
9
+ // proposal, the preview shows the WHOLE set with its destinations, `Back` is
10
+ // where the focus starts, and `Apply` sends that proposal's own digest as the
11
+ // approval. The result is a view — per destination, with its verification
12
+ // separate — and not a toast that scrolls a partial failure away.
13
+ //
14
+ // The list opens PROJECTED to the recommended catalog and `t` toggles to every
7
15
  // detected skill (SPEC 019) — a view mode, not a second data source.
8
- // Backed by skills-manager; the `w` bundle administration lives in
9
- // [Workline] (HostAdminSection).
16
+ // Backed by skills-manager (reads), skills-change (prepare) and skills-apply
17
+ // (apply/recover); the `w` bundle administration lives in [Workline].
10
18
  import { Box, Text, useInput, useStdout } from "ink";
11
19
  import { useCallback, useMemo, useRef, useState } from "react";
12
20
  import { formatTuiEvent } from "../../../application/logging/log-events.js";
13
- import { REPLICA_HOST_KEYS, REPLICA_HOST_LABELS, canonicalSkillsRoot, installSkill, listSkills, probeSkillSource, registerSkill, reinstallSkill, removeSkill, resolveSkillSource, uninstallSkill, updateSkill, } from "../../../application/self/skills-manager.js";
21
+ import { applySkillChange, readSkillJournal, recoverSkillJournal, } from "../../../application/self/skills-apply.js";
22
+ import { prepareSkillChange, } from "../../../application/self/skills-change.js";
23
+ import { REPLICA_HOST_KEYS, REPLICA_HOST_LABELS, canonicalSkillsRoot, listSkills, resolveSkillSource, } from "../../../application/self/skills-manager.js";
14
24
  import { ConfirmBanner } from "../components/confirm-banner.js";
15
25
  import { DetailPanel } from "../components/detail-panel.js";
16
26
  import { InputPrompt } from "../components/input-prompt.js";
@@ -19,7 +29,7 @@ import { notificationStackRows } from "../components/notification-stack.js";
19
29
  import { PageHead } from "../components/page-head.js";
20
30
  import { QuickActions } from "../components/quick-actions.js";
21
31
  import { SectionHead } from "../components/section-head.js";
22
- import { RECOMMENDED_SKILLS } from "../data/recommended-skills.js";
32
+ import { RECOMMENDED_SKILLS, SKILL_CATALOG } from "../data/recommended-skills.js";
23
33
  import { useLockWhile } from "../input-lock.js";
24
34
  import { useNotificationItems } from "../notification-center.js";
25
35
  import { rowWidth } from "../row-width.js";
@@ -27,8 +37,6 @@ import { colors, icons } from "../theme.js";
27
37
  import { useListDetailKeys } from "../use-list-detail-keys.js";
28
38
  import { useListWindow, windowRangeHint } from "../use-list-window.js";
29
39
  import { useOnMount } from "../use-on-mount.js";
30
- // Derived from the engine's own replica list — the four places that used to
31
- // spell "Claude, Gemini" by hand went stale the moment a replica host changed.
32
40
  const REPLICA_LABELS = REPLICA_HOST_LABELS.join(", ");
33
41
  // Rows the chrome consumes around the skills list; the window (useListWindow)
34
42
  // gets what remains of the viewport:
@@ -38,10 +46,28 @@ const REPLICA_LABELS = REPLICA_HOST_LABELS.join(", ");
38
46
  // this tab 6 = PageHead 2 (row + marginBottom) · SectionHead 1 ·
39
47
  // QuickActions 3 (marginTop + rule + keys)
40
48
  const SKILLS_LIST_RESERVED_ROWS = 22;
41
- // The `filtered` mode's projection: the seed's names, whatever their status.
42
- // A Set because the seed is scanned once per rendered list.
49
+ // The `filtered` mode's projection: the names of the HABITUAL set, whatever
50
+ // their status — a withdrawn entry leaves it and stays reachable under `all`.
43
51
  const RECOMMENDED_NAMES = new Set(RECOMMENDED_SKILLS.map((s) => s.name));
44
- /** Rows visible in the current mode: the whole list, or just the seed's. */
52
+ // What `Recommendation` reads for each verdict, and which ones earn a badge in
53
+ // the list: `keep`/`conditional` are the ordinary cases and stay quiet, while
54
+ // the three that ask for a decision are visible without opening the detail.
55
+ const DISPOSITION_LABEL = {
56
+ keep: "keep",
57
+ conditional: "conditional",
58
+ repair: "repair/replace",
59
+ candidate: "candidate",
60
+ withdrawn: "withdrawn",
61
+ };
62
+ const BADGED_DISPOSITIONS = {
63
+ repair: "warn",
64
+ candidate: "info",
65
+ withdrawn: "warn",
66
+ };
67
+ // Said, never inferred: a field the review did not record is unknown, and
68
+ // unknown is not "compatible".
69
+ const UNKNOWN = "unknown";
70
+ /** Rows visible in the current mode: the whole list, or just the habitual set's. */
45
71
  function projectSkills(all, showAll) {
46
72
  return showAll ? all : all.filter((s) => RECOMMENDED_NAMES.has(s.name));
47
73
  }
@@ -51,30 +77,26 @@ const STATUS_GLYPH = {
51
77
  registered: { glyph: "◇", active: false, tone: "dim" },
52
78
  recommended: { glyph: "·", active: false, tone: "info" },
53
79
  };
80
+ /** The colour each per-destination status reads in: `applied` is the only one
81
+ * that means "done", and `failed` must never look like it. */
82
+ const RESULT_COLOR = {
83
+ applied: colors.ok,
84
+ pending: colors.warn,
85
+ failed: colors.err,
86
+ unchanged: colors.dim,
87
+ restored: colors.accent,
88
+ };
54
89
  export function SkillsTab({ ctx, isActive, onToast }) {
55
90
  const [items, setItems] = useState([]);
56
91
  const [mode, setMode] = useState({ kind: "list" });
57
- // View mode, local and unpersisted: every mount opens on the seed (SPEC 019).
92
+ // View mode, local and unpersisted: every mount opens on the catalog (SPEC 019).
58
93
  const [showAll, setShowAll] = useState(false);
59
- // Mirror of the VISIBLE rows to preserve the selection BY NAME when they
60
- // change — a refresh re-orders the list (installed→registered→recommended)
61
- // and the toggle re-projects it, so a numeric cursor would land on another
62
- // skill. Assigned during render, so a callback reads the rows the user is
63
- // actually looking at.
64
94
  const visibleRef = useRef([]);
65
95
  const { stdout } = useStdout();
66
- // Rows the active mode shows. `items` keeps the FULL list so the PageHead
67
- // totals stay global — only the section's rows follow the mode.
68
96
  const visible = useMemo(() => projectSkills(items, showAll), [items, showAll]);
69
97
  visibleRef.current = visible;
70
98
  useLockWhile(mode.kind !== "list" && mode.kind !== "detail");
71
- // `detailActions` needs `current` (→ the hook's cursor), so its length
72
- // reaches the hook one render late via this ref. Safe: the list cannot
73
- // change while the detail is open, so the value is always fresh by the
74
- // time the detail phase reads it.
75
99
  const actionsLenRef = useRef(0);
76
- // Shared list/detail/confirm keys (↑↓ · ⏎ · esc · a add · y/n). The
77
- // callbacks close over consts declared below — they only run on keystrokes.
78
100
  const { cursor, setCursor, actionCursor } = useListDetailKeys({
79
101
  isActive,
80
102
  phase: mode.kind === "list" || mode.kind === "detail"
@@ -84,7 +106,10 @@ export function SkillsTab({ ctx, isActive, onToast }) {
84
106
  : "off",
85
107
  listLen: visible.length,
86
108
  actionsLen: actionsLenRef.current,
87
- onAdd: () => setMode({ kind: "wizard-source" }),
109
+ onAdd: () => setMode({
110
+ kind: "wizard-source",
111
+ intent: { operation: "install", source: "", withdraw: [] },
112
+ }),
88
113
  onOpenDetail: () => setMode({ kind: "detail" }),
89
114
  onCloseDetail: () => setMode({ kind: "list" }),
90
115
  onRunAction: (i) => {
@@ -97,26 +122,11 @@ export function SkillsTab({ ctx, isActive, onToast }) {
97
122
  return;
98
123
  if (!yes)
99
124
  return setMode({ kind: "detail" });
100
- const name = current.name;
101
- if (mode.action === "uninstall") {
102
- void runAction(`uninstalling ${name}…`, `Uninstalled · ${name}`, () => uninstallSkill(ctx, name));
103
- }
104
- else {
105
- void runAction(`removing ${name}…`, `Removed · ${name}`, () => removeSkill(ctx, name));
106
- }
125
+ void prepare({ operation: mode.action, name: current.name }, `preparing ${mode.action} of ${current.name}…`);
107
126
  },
108
127
  });
109
- // Windowed slice of the skills list (shared hook): renders only the rows
110
- // that fit the viewport, following the cursor at the edges. Non-TTY
111
- // (unknown height) → the whole list renders, as before. The window derives
112
- // from the cursor only — the by-name selection across refresh is untouched.
113
- // The NotificationStack height joins the reservation so a visible banner
114
- // can't clip the active row.
115
128
  const notifItems = useNotificationItems();
116
129
  const listWindow = useListWindow(visible.length, cursor, SKILLS_LIST_RESERVED_ROWS + notificationStackRows(notifItems));
117
- // Re-anchors the cursor when the visible rows change (refresh or mode
118
- // toggle): the same skill stays selected if it survives, otherwise the
119
- // cursor clamps to the new list.
120
130
  const reanchorCursor = useCallback((next) => {
121
131
  setCursor((c) => {
122
132
  const prevName = visibleRef.current[c]?.name;
@@ -126,7 +136,7 @@ export function SkillsTab({ ctx, isActive, onToast }) {
126
136
  }, [setCursor]);
127
137
  const refresh = useCallback(async () => {
128
138
  try {
129
- const next = await listSkills(ctx, RECOMMENDED_SKILLS);
139
+ const next = await listSkills(ctx, SKILL_CATALOG);
130
140
  reanchorCursor(projectSkills(next, showAll));
131
141
  setItems(next);
132
142
  }
@@ -134,72 +144,183 @@ export function SkillsTab({ ctx, isActive, onToast }) {
134
144
  onToast?.({ tone: "err", title: "Error loading skills", body: err.message });
135
145
  }
136
146
  }, [ctx, onToast, reanchorCursor, showAll]);
137
- useOnMount(() => void refresh());
147
+ // On mount the tab also asks whether a previous run left an operation
148
+ // half-applied: finding it is what makes the journal worth writing.
149
+ useOnMount(() => {
150
+ void (async () => {
151
+ await refresh();
152
+ const journal = await readSkillJournal(ctx);
153
+ if (journal !== null)
154
+ setMode({ kind: "recovery", journal, cursor: 0 });
155
+ })();
156
+ });
138
157
  const current = visible[cursor] ?? null;
139
158
  const installedCount = items.filter((s) => s.status === "installed").length;
140
159
  const unmanagedCount = items.filter((s) => s.status === "unmanaged").length;
141
160
  const registeredCount = items.filter((s) => s.status === "registered").length;
142
161
  const recommendedCount = items.filter((s) => s.status === "recommended").length;
143
- // Detail actions per status (SPEC 003): recommended/registered → Install;
144
- // installed → Update (git only) / Reinstall / Uninstall; Remove for
145
- // everything registered (a recommended one returns to `recommended`).
146
- // `unmanaged` (outside the registry) is not operable: the engine's
147
- // ownership guard rejects register/uninstall on foreign dirs —
148
- // informational row.
162
+ /**
163
+ * Prepares a change and shows its preview. Nothing is applied here: this is
164
+ * the only door to a mutation, and it opens onto a preview.
165
+ */
166
+ const prepare = useCallback(async (request, label, intent) => {
167
+ setMode({ kind: "busy", label });
168
+ try {
169
+ const outcome = await prepareSkillChange(ctx, request);
170
+ if (outcome.status === "rejected") {
171
+ onToast?.({
172
+ tone: "err",
173
+ title: "Change refused",
174
+ body: `${outcome.rejection.code}: ${outcome.rejection.message}`,
175
+ });
176
+ setMode({ kind: "list" });
177
+ return;
178
+ }
179
+ if (outcome.status === "needs-choice") {
180
+ setMode({
181
+ kind: "selection",
182
+ intent: intent ?? {
183
+ operation: "install",
184
+ source: request.source ?? "",
185
+ withdraw: request.withdraw ?? [],
186
+ },
187
+ inventory: outcome.inventory,
188
+ candidates: outcome.candidates,
189
+ chosen: [],
190
+ cursor: 0,
191
+ note: null,
192
+ });
193
+ await outcome.release();
194
+ return;
195
+ }
196
+ if (outcome.status === "needs-expansion") {
197
+ await outcome.release();
198
+ setMode(expansionSelection(request, outcome.expansions, intent));
199
+ return;
200
+ }
201
+ setMode({
202
+ kind: "preview",
203
+ prepared: { proposal: outcome.proposal, release: outcome.release },
204
+ // `Back` is where the focus starts: Apply is an explicit move.
205
+ cursor: 0,
206
+ });
207
+ }
208
+ catch (err) {
209
+ onToast?.({ tone: "err", title: "Error", body: err.message });
210
+ setMode({ kind: "list" });
211
+ }
212
+ }, [ctx, onToast]);
213
+ /** Re-opens the selection with the expansion the payload requires. */
214
+ const expansionSelection = useCallback((request, expansions, intent) => {
215
+ const chosen = [...(request.paths ?? [])];
216
+ const candidates = expansions.map((expansion) => ({
217
+ path: expansion.path,
218
+ name: expansion.name,
219
+ directory: expansion.path.split("/").pop() ?? expansion.name,
220
+ }));
221
+ return {
222
+ kind: "selection",
223
+ intent: intent ?? {
224
+ operation: request.operation,
225
+ source: request.source ?? "",
226
+ withdraw: request.withdraw ?? [],
227
+ },
228
+ inventory: {
229
+ source: request.source ?? "",
230
+ kind: "git",
231
+ requestedRef: null,
232
+ resolvedRef: null,
233
+ candidates,
234
+ truncated: false,
235
+ limits: null,
236
+ },
237
+ candidates,
238
+ chosen,
239
+ cursor: 0,
240
+ note: expansions.map((expansion) => expansion.reason).join(" · "),
241
+ };
242
+ }, []);
243
+ const applyPrepared = useCallback(async (prepared) => {
244
+ setMode({ kind: "applying", label: `applying ${prepared.proposal.operation}…` });
245
+ try {
246
+ const outcome = await applySkillChange(ctx, prepared.proposal, prepared.proposal.digest);
247
+ if (outcome.status === "refused") {
248
+ onToast?.({
249
+ tone: "err",
250
+ title: "Apply refused",
251
+ body: `${outcome.refusal.code}: ${outcome.refusal.message}`,
252
+ });
253
+ setMode({ kind: "list" });
254
+ }
255
+ else {
256
+ void ctx.logger?.info(formatTuiEvent(`skills ${prepared.proposal.operation}`, "ok"));
257
+ setMode({ kind: "result", result: outcome.result });
258
+ }
259
+ }
260
+ catch (err) {
261
+ onToast?.({ tone: "err", title: "Error", body: err.message });
262
+ setMode({ kind: "list" });
263
+ }
264
+ finally {
265
+ await prepared.release();
266
+ await refresh();
267
+ }
268
+ }, [ctx, onToast, refresh]);
269
+ // Detail actions per status. `unmanaged` (outside the registry) is not
270
+ // operable: the ownership guard rejects it, so the row is informational.
149
271
  const detailActions = useMemo(() => {
150
272
  if (!current || current.status === "unmanaged")
151
273
  return [];
152
- if (current.status === "recommended") {
153
- return [
274
+ if (current.status === "recommended" || current.status === "registered") {
275
+ const entries = [
154
276
  {
155
277
  id: "install",
156
278
  action: {
157
279
  name: "Install",
158
- description: `Register + install (canonical + host replicas: ${REPLICA_LABELS}).`,
280
+ description: `Prepare, preview, then materialize (canonical + ${REPLICA_LABELS}).`,
159
281
  },
160
282
  },
161
283
  ];
162
- }
163
- if (current.status === "registered") {
164
- return [
165
- {
166
- id: "install",
167
- action: {
168
- name: "Install",
169
- description: `Materialize canonical + host replicas (${REPLICA_LABELS}).`,
170
- },
171
- },
172
- {
284
+ if (current.status === "registered") {
285
+ entries.push({
173
286
  id: "remove",
174
287
  action: { name: "Remove", description: "Drop from the registry.", danger: true },
175
- },
176
- ];
288
+ });
289
+ }
290
+ return entries;
177
291
  }
178
- // The engine's canonical classifier (isAbsolute covers Windows paths like
179
- // C:\… that a startsWith("/") would misclassify as git).
180
292
  const resolved = resolveSkillSource(current.source, current.ref);
181
293
  const gitSource = !("error" in resolved) && resolved.kind === "git";
294
+ const proposed = current.curation?.proposedSource;
182
295
  return [
183
296
  ...(gitSource
184
297
  ? [
185
298
  {
186
299
  id: "update",
300
+ action: { name: "Update", description: "Re-fetch the registered ref." },
301
+ },
302
+ ]
303
+ : []),
304
+ ...(proposed !== undefined
305
+ ? [
306
+ {
307
+ id: "replace",
187
308
  action: {
188
- name: "Update",
189
- description: "Re-fetch the registered ref (staging + swap).",
309
+ name: "Change source",
310
+ description: `Prepare from ${proposed} and show the differences.`,
190
311
  },
191
312
  },
192
313
  ]
193
314
  : []),
194
315
  {
195
- id: "reinstall",
196
- action: { name: "Reinstall", description: "Repair the host replicas from the canonical." },
316
+ id: "repair",
317
+ action: { name: "Repair", description: "Rebuild the host replicas from the canonical." },
197
318
  },
198
319
  {
199
320
  id: "uninstall",
200
321
  action: {
201
322
  name: "Uninstall",
202
- description: "Delete canonical + replica; keeps the registration.",
323
+ description: "Delete canonical + replicas; keeps the registration.",
203
324
  danger: true,
204
325
  },
205
326
  },
@@ -214,68 +335,21 @@ export function SkillsTab({ ctx, isActive, onToast }) {
214
335
  ];
215
336
  }, [current]);
216
337
  actionsLenRef.current = detailActions.length;
217
- // TUI surface is EN (SPEC 007); the engine's summaries (ES) go in the body.
218
- const runAction = useCallback(async (label, successTitle, op) => {
219
- setMode({ kind: "busy", label });
220
- try {
221
- const result = await op();
222
- if (result.ok) {
223
- onToast?.({ tone: "ok", title: successTitle, body: result.data?.summary ?? "" });
224
- if (result.data?.warning) {
225
- onToast?.({ tone: "info", title: "Notice", body: result.data.warning });
226
- }
227
- void ctx.logger?.info(formatTuiEvent(`skill-manager ${label}`, "ok"));
228
- }
229
- else {
230
- onToast?.({
231
- tone: "err",
232
- title: "Operation refused",
233
- body: result.error?.message ?? "",
234
- });
235
- }
236
- }
237
- catch (err) {
238
- onToast?.({ tone: "err", title: "Error", body: err.message });
239
- }
240
- await refresh();
241
- setMode({ kind: "list" });
242
- }, [ctx, onToast, refresh]);
243
338
  const triggerAction = useCallback((id) => {
244
339
  if (!current)
245
340
  return;
246
- const name = current.name;
247
- switch (id) {
248
- case "install":
249
- if (current.status === "recommended") {
250
- // Register + install in one step (the seed already carries the source).
251
- void runAction(`installing ${name}…`, `Installed · ${name}`, async () => {
252
- const reg = await registerSkill(ctx, { source: current.source, pick: name });
253
- if (!reg.ok)
254
- return reg;
255
- return installSkill(ctx, name);
256
- });
257
- }
258
- else {
259
- void runAction(`installing ${name}…`, `Installed · ${name}`, () => installSkill(ctx, name));
260
- }
261
- return;
262
- case "update":
263
- void runAction(`updating ${name}…`, `Updated · ${name}`, () => updateSkill(ctx, name));
264
- return;
265
- case "reinstall":
266
- void runAction(`reinstalling ${name}…`, `Reinstalled · ${name}`, () => reinstallSkill(ctx, name));
267
- return;
268
- case "uninstall":
269
- setMode({ kind: "confirm", action: "uninstall" });
270
- return;
271
- case "remove":
272
- setMode({ kind: "confirm", action: "remove" });
273
- return;
341
+ if (id === "uninstall" || id === "remove") {
342
+ setMode({ kind: "confirm", action: id });
343
+ return;
274
344
  }
275
- }, [ctx, current, runAction]);
276
- // input — `t` toggles the list mode (seed only ↔ every detected skill).
277
- // Its own handler on purpose: `useListDetailKeys` is the shared machinery of
278
- // the list tabs and this key belongs to this one.
345
+ const request = acquiringRequest(id, current);
346
+ if (request === null)
347
+ return;
348
+ void prepare(request, `preparing ${id} of ${current.name}…`, request.operation === "replace" && request.source !== undefined
349
+ ? { operation: "replace", source: request.source, withdraw: [current.name] }
350
+ : undefined);
351
+ }, [current, prepare]);
352
+ // input — `t` toggles the list mode (catalog only ↔ every detected skill).
279
353
  useInput((input) => {
280
354
  if (!isActive || (mode.kind !== "list" && mode.kind !== "detail"))
281
355
  return;
@@ -284,88 +358,112 @@ export function SkillsTab({ ctx, isActive, onToast }) {
284
358
  reanchorCursor(projectSkills(items, !showAll));
285
359
  setShowAll((v) => !v);
286
360
  }, { isActive });
287
- // input — wizard-source esc
361
+ // input — the source prompt
288
362
  useInput((_input, key) => {
289
363
  if (!isActive || mode.kind !== "wizard-source")
290
364
  return;
291
365
  if (key.escape)
292
366
  setMode({ kind: "list" });
293
367
  }, { isActive });
294
- // input — wizard-pick (↑↓ · ⏎ choose · esc cancel)
295
- useInput((_input, key) => {
296
- if (!isActive || mode.kind !== "wizard-pick")
297
- return;
298
- if (key.escape) {
299
- setMode({ kind: "list" });
368
+ // input — selection (↑↓ · space toggles · ⏎ continue · esc cancel)
369
+ useInput((input, key) => {
370
+ if (!isActive || mode.kind !== "selection")
300
371
  return;
301
- }
302
- if (key.upArrow) {
303
- setMode({ ...mode, cursor: Math.max(0, mode.cursor - 1) });
372
+ if (key.return) {
373
+ // An empty set keeps the selection with its explanation: continuing
374
+ // with nothing chosen would prepare a proposal about nothing.
375
+ if (mode.chosen.length === 0) {
376
+ return void setMode({ ...mode, note: "Choose at least one skill with [space]." });
377
+ }
378
+ void prepare({
379
+ operation: mode.intent.operation,
380
+ source: mode.intent.source,
381
+ paths: mode.chosen,
382
+ ...(mode.intent.withdraw.length > 0 ? { withdraw: mode.intent.withdraw } : {}),
383
+ }, `preparing ${mode.chosen.length} skill(s)…`, mode.intent);
304
384
  return;
305
385
  }
306
- if (key.downArrow) {
307
- setMode({ ...mode, cursor: Math.min(mode.candidates.length - 1, mode.cursor + 1) });
386
+ const next = selectionAfterKey(mode, input, key);
387
+ if (next !== null)
388
+ setMode(next);
389
+ }, { isActive });
390
+ // input — preview (↑↓ Back/Apply · ⏎ run the focused one · esc back)
391
+ useInput((_input, key) => {
392
+ if (!isActive || mode.kind !== "preview")
308
393
  return;
394
+ if (key.escape) {
395
+ void mode.prepared.release();
396
+ return void setMode({ kind: "list" });
309
397
  }
398
+ if (key.upArrow)
399
+ return void setMode({ ...mode, cursor: 0 });
400
+ if (key.downArrow)
401
+ return void setMode({ ...mode, cursor: 1 });
310
402
  if (key.return) {
311
- const pick = mode.candidates[mode.cursor];
312
- if (pick)
313
- setMode({ kind: "wizard-warning", source: mode.source, pick });
403
+ if (mode.cursor === 0) {
404
+ void mode.prepared.release();
405
+ setMode({ kind: "list" });
406
+ return;
407
+ }
408
+ void applyPrepared(mode.prepared);
314
409
  }
315
410
  }, { isActive });
316
- // input — wizard-warning (r register · ⏎ register+install · esc cancel)
317
- useInput((input, key) => {
318
- if (!isActive || mode.kind !== "wizard-warning")
411
+ // input — result: any of the two obvious keys returns to the list
412
+ useInput((_input, key) => {
413
+ if (!isActive || mode.kind !== "result")
319
414
  return;
320
- if (key.escape) {
415
+ if (key.escape || key.return)
321
416
  setMode({ kind: "list" });
417
+ }, { isActive });
418
+ // input — recovery. A screen with no way out is a trap: `esc` leaves the
419
+ // journal pending and the offer comes back next time the tab opens. What it
420
+ // must never do is let the tab be used as if nothing were unfinished —
421
+ // hence the explicit note on the panel.
422
+ useInput((input, key) => {
423
+ if (!isActive || mode.kind !== "recovery")
322
424
  return;
323
- }
324
- const { source, pick } = mode;
325
- if (input === "r" || input === "R") {
326
- void runAction(`registering ${pick}…`, `Registered · ${pick}`, () => registerSkill(ctx, { source, pick }));
327
- return;
328
- }
329
- if (key.return) {
330
- void runAction(`installing ${pick}…`, `Installed · ${pick}`, async () => {
331
- const reg = await registerSkill(ctx, { source, pick });
332
- if (!reg.ok)
333
- return reg;
334
- return installSkill(ctx, pick);
335
- });
336
- }
425
+ if (key.escape)
426
+ return void setMode({ kind: "list" });
427
+ if (key.upArrow)
428
+ return void setMode({ ...mode, cursor: 0 });
429
+ if (key.downArrow)
430
+ return void setMode({ ...mode, cursor: 1 });
431
+ const choice = recoveryChoice(input, key.return, mode.cursor);
432
+ if (choice !== null)
433
+ void resolveJournal(choice);
337
434
  }, { isActive });
338
- const probeSource = useCallback(async (source) => {
339
- setMode({ kind: "busy", label: "inspecting source…" });
435
+ const resolveJournal = useCallback(async (choice) => {
436
+ setMode({ kind: "applying", label: `resolving the pending operation (${choice})…` });
340
437
  try {
341
- const probe = await probeSkillSource(ctx, { source });
342
- if (!probe.ok || !probe.data) {
343
- onToast?.({ tone: "err", title: "Invalid source", body: probe.error?.message ?? "" });
438
+ const outcome = await recoverSkillJournal(ctx, choice);
439
+ if (outcome.status === "refused") {
440
+ onToast?.({
441
+ tone: "err",
442
+ title: "Recovery refused",
443
+ body: `${outcome.refusal.code}: ${outcome.refusal.message}`,
444
+ });
344
445
  setMode({ kind: "list" });
345
- return;
346
446
  }
347
- const candidates = probe.data.candidates;
348
- const single = candidates.length === 1 ? candidates[0] : undefined;
349
- setMode(single !== undefined
350
- ? { kind: "wizard-warning", source, pick: single }
351
- : { kind: "wizard-pick", source, candidates, cursor: 0 });
447
+ else {
448
+ setMode({ kind: "result", result: outcome.result });
449
+ }
352
450
  }
353
451
  catch (err) {
354
452
  onToast?.({ tone: "err", title: "Error", body: err.message });
355
453
  setMode({ kind: "list" });
356
454
  }
357
- }, [ctx, onToast]);
455
+ finally {
456
+ await refresh();
457
+ }
458
+ }, [ctx, onToast, refresh]);
358
459
  const overlayVisible = mode.kind !== "list";
359
460
  const home = ctx.env.homeDir();
360
- // Visible-range indicator for the SectionHead hint slot — only when the
361
- // window hides rows (consumes no extra terminal row); it keeps priority over
362
- // the mode hint, which QuickActions announces anyway.
363
461
  const listRangeHint = windowRangeHint(listWindow, visible.length);
364
462
  const modeHint = showAll ? "all skills · t show recommended" : "recommended only · t show all";
365
463
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PageHead, { title: "Skills", count: {
366
464
  label: `${installedCount} installed${unmanagedCount > 0 ? ` · ${unmanagedCount} unmanaged` : ""} · ${registeredCount} registered · ${recommendedCount} recommended`,
367
465
  tone: installedCount > 0 ? "accent" : "warn",
368
- }, action: _jsx(Text, { color: colors.mute, children: "~/.agents/skills + host replicas" }) }), _jsx(SectionHead, { label: "Skills", count: visible.length, hint: listRangeHint ?? modeHint, ...(mode.kind.startsWith("wizard")
466
+ }, action: _jsx(Text, { color: colors.mute, children: "~/.agents/skills + host replicas" }) }), _jsx(SectionHead, { label: "Skills", count: visible.length, hint: listRangeHint ?? modeHint, ...(mode.kind === "wizard-source" || mode.kind === "selection"
369
467
  ? { rightAction: "esc cancel" }
370
468
  : mode.kind === "detail" || mode.kind === "confirm"
371
469
  ? { rightAction: "esc to close detail" }
@@ -373,18 +471,18 @@ export function SkillsTab({ ctx, isActive, onToast }) {
373
471
  const glyph = STATUS_GLYPH[s.status];
374
472
  return (_jsx(ListRow, { icon: glyph.glyph, iconActive: glyph.active, title: s.name, subtitle: s.status === "unmanaged" && s.source === ""
375
473
  ? "outside the registry"
376
- : `${s.source}${s.ref ? ` #${s.ref}` : ""}`, meta: s.mode === "copy" ? [{ label: "copy", tone: "warn" }] : [], state: { label: s.status, tone: glyph.tone }, chevron: true, active: listWindow.start + i === cursor, dimmed: mode.kind.startsWith("wizard"), widthHint: rowWidth(stdout?.columns, overlayVisible) }, s.name));
474
+ : `${s.source}${s.ref ? ` #${s.ref}` : ""}`, meta: rowMeta(s), state: { label: s.status, tone: glyph.tone }, chevron: true, active: listWindow.start + i === cursor, dimmed: overlayVisible && mode.kind !== "detail" && mode.kind !== "confirm", widthHint: rowWidth(stdout?.columns, overlayVisible) }, s.name));
377
475
  }), mode.kind === "wizard-source" ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(SectionHead, { label: "Add skill", hint: "Step 1 \u00B7 Source", rightAction: "\u23CE inspect \u00B7 esc cancel" }), _jsx(Box, { marginLeft: 2, children: _jsx(InputPrompt, { message: "source (owner/repo \u00B7 git URL \u00B7 absolute path):", onSubmit: (value) => {
378
476
  const source = value.trim();
379
477
  if (!source) {
380
478
  setMode({ kind: "list" });
381
479
  return;
382
480
  }
383
- void probeSource(source);
384
- }, isActive: isActive }) })] })) : null, mode.kind === "wizard-pick" ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(SectionHead, { label: "Add skill", hint: `Step 2 · Pick one of ${mode.candidates.length}`, rightAction: "\u23CE choose \u00B7 esc cancel" }), _jsx(Box, { marginTop: 0, flexDirection: "column", children: mode.candidates.map((name, i) => (_jsx(ListRow, { icon: "\u00B7", title: name, active: mode.cursor === i, widthHint: rowWidth(stdout?.columns, true) }, name))) })] })) : null, mode.kind === "wizard-warning" ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(SectionHead, { label: `Add skill · ${mode.pick}`, hint: "Step 3 \u00B7 Review", rightAction: "esc cancel" }), _jsxs(Box, { marginLeft: 2, marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.warn, children: "\u26A0 A third-party skill runs with your host's permissions \u2014 review it before installing." }), _jsx(Text, { color: colors.dim, wrap: "truncate-end", children: mode.source }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.faint, children: "[\u23CE] register + install \u00B7 [r] register only" }) })] })] })) : null, mode.kind === "busy" ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.warn, children: [icons.spinner, " ", mode.label] }) })) : null] }), current && (mode.kind === "detail" || mode.kind === "confirm") ? (_jsx(DetailPanel, { bordered: true, header: {
385
- name: current.name,
386
- meta: detailMeta(current, home),
387
- }, statePill: {
481
+ void prepare({ operation: "install", source }, "inspecting source…", {
482
+ ...mode.intent,
483
+ source,
484
+ });
485
+ }, isActive: isActive }) })] })) : null, mode.kind === "selection" ? (_jsx(SelectionPanel, { mode: mode, columns: stdout?.columns })) : null, mode.kind === "preview" ? (_jsx(PreviewPanel, { proposal: mode.prepared.proposal, cursor: mode.cursor, home: home })) : null, mode.kind === "result" ? _jsx(ResultPanel, { result: mode.result, home: home }) : null, mode.kind === "recovery" ? (_jsx(RecoveryPanel, { journal: mode.journal, cursor: mode.cursor })) : null, mode.kind === "busy" || mode.kind === "applying" ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.warn, children: [icons.spinner, " ", mode.label] }) })) : null] }), current && (mode.kind === "detail" || mode.kind === "confirm") ? (_jsx(DetailPanel, { bordered: true, header: { name: current.name, meta: detailMeta(current, home) }, statePill: {
388
486
  label: current.status,
389
487
  tone: current.status === "installed"
390
488
  ? "ok"
@@ -392,29 +490,175 @@ export function SkillsTab({ ctx, isActive, onToast }) {
392
490
  ? "warn"
393
491
  : "dim",
394
492
  }, actions: detailActions.map((a) => a.action), focusedAction: actionCursor, banner: mode.kind === "confirm" ? (_jsx(ConfirmBanner, { title: `× ${mode.action === "uninstall" ? "Uninstall" : "Remove"} ${current.name}?`, body: mode.action === "uninstall"
395
- ? "Deletes canonical + replica; the registration stays."
396
- : current.status === "installed"
397
- ? "Uninstalls and drops the registration. A recommended skill returns to the recommended list."
398
- : "Drops the registration. A recommended skill returns to the recommended list." })) : null })) : null] }), _jsx(Box, { marginTop: 1, children: _jsx(QuickActions, { actions: [
493
+ ? "Prepares the removal of canonical + replicas; the registration stays. You still approve the preview."
494
+ : "Prepares the removal and drops the registration. You still approve the preview." })) : null })) : null] }), _jsx(Box, { marginTop: 1, children: _jsx(QuickActions, { actions: [
399
495
  { key: "a", label: "add skill" },
400
496
  { key: "t", label: showAll ? "show recommended" : "show all" },
401
497
  ] }) })] }));
402
498
  }
403
- function detailMeta(item, home) {
404
- const source = `${item.source}${item.ref ? ` #${item.ref}` : ""}`;
405
- if (item.status === "recommended")
406
- return `${source}\n${item.description ?? ""}`;
407
- const canonical = `${canonicalSkillsRoot(home)}/${item.name}`.replace(home, "~");
408
- // `item.mode` describes the FIRST replica host's materialization (symlink vs
409
- // copy) — the suffix stays on that one, and the host names come from the
410
- // engine's list instead of being spelled here.
411
- const replicas = [
499
+ /** Explicit selection: every eligible skill by name AND location. */
500
+ function SelectionPanel({ mode, columns, }) {
501
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(SectionHead, { label: "Select skills", hint: `${mode.chosen.length} of ${mode.candidates.length} chosen`, rightAction: "space toggle \u00B7 \u23CE continue \u00B7 esc cancel" }), mode.note !== null ? (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.warn, children: mode.note }) })) : null, mode.inventory.truncated ? (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.warn, children: "The source walk hit its limit: this list is a floor, not every skill." }) })) : null, _jsx(Box, { flexDirection: "column", children: mode.candidates.map((candidate, i) => (_jsx(ListRow, { icon: mode.chosen.includes(candidate.path) ? "◆" : "·", iconActive: mode.chosen.includes(candidate.path), title: candidate.name, subtitle: candidate.path === "" ? "(source root)" : candidate.path, meta: candidate.name === candidate.directory
502
+ ? []
503
+ : [{ label: `dir ${candidate.directory}`, tone: "dim" }], state: mode.chosen.includes(candidate.path)
504
+ ? { label: "chosen", tone: "ok" }
505
+ : { label: "available", tone: "dim" }, active: mode.cursor === i, widthHint: rowWidth(columns, true) }, candidate.path))) })] }));
506
+ }
507
+ /** The whole change before Apply: set, sources, destinations and effects. */
508
+ function PreviewPanel({ proposal, cursor, home, }) {
509
+ const short = (path) => path.replace(home, "~");
510
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(SectionHead, { label: `Proposed changes · ${proposal.operation}`, hint: `${proposal.additions.length} added · ${proposal.withdrawals.length} withdrawn`, rightAction: "\u2191\u2193 choose \u00B7 \u23CE run \u00B7 esc back" }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [proposal.additions.map((skill) => (_jsxs(Text, { color: colors.text, children: ["+ ", skill.name, _jsxs(Text, { color: colors.dim, children: [" ", "\u00B7 ", skill.provenance.source, skill.path === "" ? "" : `/${skill.path}`, skill.provenance.resolvedRef === null
511
+ ? ""
512
+ : ` @ ${skill.provenance.resolvedRef.slice(0, 12)}`, " · ", skill.files.length, " files", skill.manifests.length > 1 ? ` · ${skill.manifests.length} SKILL.md` : ""] })] }, skill.name))), proposal.withdrawals.map((name) => (_jsxs(Text, { color: colors.warn, children: ["\u2212 ", name, " ", _jsx(Text, { color: colors.dim, children: "\u00B7 managed installation retired" })] }, name))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.mute, children: "DESTINATIONS" }), proposal.destinations.map((destination) => (_jsxs(Text, { color: colors.dim, children: [destination.action.padEnd(9), " ", destination.host.padEnd(8), " ", short(destination.location), destination.shared ? " · shared" : "", destination.ownership === "foreign" ? " · foreign, preserved" : ""] }, `${destination.host}-${destination.location}`)))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.mute, children: ["EFFECTS \u00B7 ", proposal.effects.join(", ")] }), proposal.notes.map((note) => (_jsxs(Text, { color: colors.faint, children: ["\u00B7 ", note] }, note)))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: cursor === 0 ? colors.bright : colors.dim, children: [cursor === 0 ? icons.focusBar : " ", " Back"] }), _jsxs(Text, { color: cursor === 1 ? colors.err : colors.dim, children: [cursor === 1 ? icons.focusBar : " ", " Apply"] })] })] })] }));
513
+ }
514
+ /** Per destination: what happened, and — separately — what was checked. */
515
+ function ResultPanel({ result, home }) {
516
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(SectionHead, { label: `Result · ${result.operation}`, hint: result.summary, rightAction: "\u23CE back to the list" }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [result.destinations.map((destination) => (_jsxs(Text, { color: colors.dim, children: [_jsx(Text, { color: RESULT_COLOR[destination.status], children: destination.status.padEnd(10) }), destination.host.padEnd(8), " ", destination.location.replace(home, "~"), destination.verification === null
517
+ ? " · not checked"
518
+ : ` · ${destination.verification.passed ? "verified" : "CHECK FAILED"}: ${destination.verification.checked.replace(home, "~")}`, destination.detail === undefined ? "" : ` · ${destination.detail}`] }, destination.location))), result.recovery !== null ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.warn, children: result.recovery.action }) })) : null, result.cleanup !== null ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.faint, children: ["Backups pending removal: ", result.cleanup.pending.join(", ")] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.faint, children: "Applied is not \"the host loaded it\": what is checked above is the bytes this manager wrote in the locations it manages." }) })] })] }));
519
+ }
520
+ /** An operation a previous run left pending: put it back, or accept and retry. */
521
+ function RecoveryPanel({ journal, cursor }) {
522
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(SectionHead, { label: "Unfinished operation", hint: `${journal.operation} · ${journal.destinations.length} destination(s)`, rightAction: "\u2191\u2193 choose \u00B7 \u23CE run \u00B7 esc later" }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { color: colors.warn, children: "A previous run did not finish. Its backups are kept, so the previous state can come back." }), journal.destinations.map((destination) => (_jsxs(Text, { color: colors.dim, children: [destination.status.padEnd(10), " ", destination.location] }, destination.location))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: cursor === 0 ? colors.bright : colors.dim, children: [cursor === 0 ? icons.focusBar : " ", " [r] Restore the previous state"] }), _jsxs(Text, { color: cursor === 1 ? colors.bright : colors.dim, children: [cursor === 1 ? icons.focusBar : " ", " [k] Keep the current state and start over"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.faint, children: "Neither repeats an effect: a new attempt needs a new proposal. `esc` leaves it unfinished \u2014 the backups stay and this offer returns next time." }) })] })] }));
523
+ }
524
+ /** Which way out of a pending operation the keystroke asks for. */
525
+ function recoveryChoice(input, entered, cursor) {
526
+ if (input === "r" || input === "R")
527
+ return "restore";
528
+ if (input === "k" || input === "K")
529
+ return "discard";
530
+ if (!entered)
531
+ return null;
532
+ return cursor === 0 ? "restore" : "discard";
533
+ }
534
+ /** Navigation and toggling inside the selection — the state, not the effect. */
535
+ function selectionAfterKey(mode, input, key) {
536
+ if (key.escape)
537
+ return { kind: "list" };
538
+ if (key.upArrow)
539
+ return { ...mode, cursor: Math.max(0, mode.cursor - 1) };
540
+ if (key.downArrow) {
541
+ return { ...mode, cursor: Math.min(mode.candidates.length - 1, mode.cursor + 1) };
542
+ }
543
+ if (input !== " ")
544
+ return null;
545
+ const path = mode.candidates[mode.cursor]?.path;
546
+ if (path === undefined)
547
+ return null;
548
+ return {
549
+ ...mode,
550
+ chosen: mode.chosen.includes(path)
551
+ ? mode.chosen.filter((p) => p !== path)
552
+ : [...mode.chosen, path],
553
+ };
554
+ }
555
+ /**
556
+ * The request an acquiring action sends to the preparer.
557
+ *
558
+ * The path the catalog reviewed is what makes a NESTED skill reachable; a name
559
+ * is the fallback, and it only resolves while it is unambiguous.
560
+ */
561
+ function acquiringRequest(id, item) {
562
+ const curation = item.curation;
563
+ const where = curation?.path !== undefined ? { paths: [curation.path] } : { pick: item.name };
564
+ // The registered ref travels with EVERY acquisition, not just Update: a row
565
+ // registered at `#v2` that installs from the default branch would silently
566
+ // drop the pin — the entry is rewritten from what the acquisition resolved.
567
+ const pinned = item.ref !== undefined ? { ref: item.ref } : {};
568
+ if (id === "repair")
569
+ return { operation: "repair", name: item.name };
570
+ if (id === "install")
571
+ return { operation: "install", source: item.source, ...pinned, ...where };
572
+ if (id === "update") {
573
+ return { operation: "update", source: item.source, ...pinned, ...where };
574
+ }
575
+ // An explicit source change: the previous installation is retired in the
576
+ // SAME proposal that brings the new one.
577
+ const source = curation?.proposedSource;
578
+ if (source === undefined)
579
+ return null;
580
+ return { operation: "replace", source, withdraw: [item.name], ...where };
581
+ }
582
+ /** Badges the row carries: the degraded replica, plus a verdict that asks for
583
+ * a decision (an ordinary keep/conditional row stays quiet). */
584
+ function rowMeta(item) {
585
+ const meta = [];
586
+ if (item.mode === "copy")
587
+ meta.push({ label: "copy", tone: "warn" });
588
+ const disposition = item.curation?.disposition;
589
+ const tone = disposition ? BADGED_DISPOSITIONS[disposition] : undefined;
590
+ if (disposition && tone)
591
+ meta.push({ label: DISPOSITION_LABEL[disposition], tone });
592
+ return meta;
593
+ }
594
+ /** Replica line of an installed/registered/unmanaged row. */
595
+ function replicaLine(item) {
596
+ return [
412
597
  `agents ${item.replicas.agents ? "✓" : "·"}`,
598
+ // `item.mode` describes the FIRST replica host's materialization (symlink
599
+ // vs copy) — the suffix stays on that one, and the host names come from
600
+ // the engine's list instead of being spelled here.
413
601
  ...REPLICA_HOST_KEYS.map((key, i) => `${key} ${item.replicas[key] ? "✓" : "·"}${i === 0 && item.mode === "copy" ? " (copy)" : ""}`),
414
602
  ].join(" · ");
603
+ }
604
+ /** Short form of an inspected revision — enough to compare, never a claim
605
+ * about what an acquisition would resolve. */
606
+ function shortRef(ref) {
607
+ return ref.length > 12 ? ref.slice(0, 12) : ref;
608
+ }
609
+ /** The reviewed verdict, or the honest absence of one. */
610
+ function recommendationLines(curation) {
611
+ if (curation === undefined) {
612
+ return [
613
+ "Recommendation: not in the reviewed catalog",
614
+ `Use when: ${UNKNOWN}`,
615
+ `Known limits: ${UNKNOWN}`,
616
+ ];
617
+ }
618
+ const verdict = DISPOSITION_LABEL[curation.disposition];
619
+ return [
620
+ `Recommendation: ${curation.reason ? `${verdict} — ${curation.reason}` : verdict}`,
621
+ `Use when: ${curation.useWhen ?? UNKNOWN}`,
622
+ `Known limits: ${curation.knownLimits ?? UNKNOWN}`,
623
+ ];
624
+ }
625
+ /** Where the bytes come from — and, when they differ, where a repair would
626
+ * take them. A divergence is shown, never resolved silently. */
627
+ function sourceLines(item, curation) {
628
+ const source = `${item.source === "" ? UNKNOWN : item.source}${item.ref ? ` #${item.ref}` : ""}`;
629
+ const proposed = curation?.proposedSource;
630
+ const lines = [proposed ? `Installed source: ${source}` : `Source: ${source}`];
631
+ if (proposed)
632
+ lines.push(`Proposed source: ${proposed}`);
633
+ if (curation?.path)
634
+ lines.push(`Path in source: ${curation.path}`);
635
+ if (curation?.skillName && curation.skillName !== item.name) {
636
+ lines.push(`Catalog name: ${item.name}`, `Skill name: ${curation.skillName}`);
637
+ }
638
+ if (curation?.reviewedRef)
639
+ lines.push(`Reviewed rev: ${shortRef(curation.reviewedRef)}`);
640
+ if (curation?.evidence)
641
+ lines.push(`Evidence: ${curation.evidence}`);
642
+ return lines;
643
+ }
644
+ /**
645
+ * Detail body: installation and recommendation as SEPARATE facts (Spec 043 ·
646
+ * DES-001@r7 / SCR-001@r3#detail). Reading it changes nothing.
647
+ */
648
+ function detailMeta(item, home) {
649
+ const lines = [];
650
+ if (item.description)
651
+ lines.push(item.description, "");
652
+ lines.push(item.status === "recommended"
653
+ ? "Status: recommended · not installed"
654
+ : `Status: ${item.status} · ${replicaLine(item)}`);
655
+ lines.push(...recommendationLines(item.curation), ...sourceLines(item, item.curation));
656
+ if (item.status !== "recommended") {
657
+ lines.push(`${canonicalSkillsRoot(home)}/${item.name}`.replace(home, "~"));
658
+ }
415
659
  if (item.status === "unmanaged") {
416
- return `${source === "" ? "unknown source" : source}\n${canonical}\n${replicas}\nInstalled outside the registry (e.g. skills.sh) — not operable from here.`;
660
+ lines.push("Installed outside the registry (e.g. skills.sh) — not operable from here.");
417
661
  }
418
- return `${source}\n${canonical}\n${replicas}`;
662
+ return lines.join("\n");
419
663
  }
420
664
  //# sourceMappingURL=skills-tab.js.map