@iseoane/agent-depot 0.2.0 → 0.3.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +117 -3
  3. package/dist/src/app-cli.js +172 -0
  4. package/dist/src/app-flow.js +457 -0
  5. package/dist/src/app-latest.js +63 -0
  6. package/dist/src/app-owned-skills.js +42 -0
  7. package/dist/src/app-recipes.js +116 -0
  8. package/dist/src/app-schema.js +45 -0
  9. package/dist/src/app-version.js +46 -0
  10. package/dist/src/atomic-file.js +26 -0
  11. package/dist/src/cli.js +1256 -0
  12. package/dist/src/git-source.js +409 -0
  13. package/dist/src/path-safety.js +31 -0
  14. package/dist/src/process-runner.js +41 -0
  15. package/dist/src/project-installation.js +1411 -0
  16. package/dist/src/project-manifest.js +511 -0
  17. package/dist/src/skill-adoption.js +77 -0
  18. package/dist/src/skill-discovery.js +467 -0
  19. package/dist/src/skill-hosts.js +103 -0
  20. package/dist/src/skill-install.js +374 -0
  21. package/dist/src/skill-removal.js +119 -0
  22. package/dist/src/skill-update.js +244 -0
  23. package/dist/src/source-state.js +240 -0
  24. package/dist/src/sources.js +391 -0
  25. package/dist/src/tui/adoption-actions.js +144 -0
  26. package/dist/src/tui/app-actions.js +157 -0
  27. package/dist/src/tui/app.js +63 -0
  28. package/dist/src/tui/batch.js +17 -0
  29. package/dist/src/tui/bulk-actions.js +243 -0
  30. package/dist/src/tui/catalog-actions.js +9 -0
  31. package/dist/src/tui/catalog-browse.js +59 -0
  32. package/dist/src/tui/catalog-filter.js +20 -0
  33. package/dist/src/tui/catalog-flow.js +107 -0
  34. package/dist/src/tui/catalog-installs.js +193 -0
  35. package/dist/src/tui/catalog-panel.js +47 -0
  36. package/dist/src/tui/catalog-state.js +92 -0
  37. package/dist/src/tui/catalog-tree.js +35 -0
  38. package/dist/src/tui/catalog-view.js +89 -0
  39. package/dist/src/tui/environment.js +1 -0
  40. package/dist/src/tui/installations-browse.js +136 -0
  41. package/dist/src/tui/installations-mode.js +1 -0
  42. package/dist/src/tui/installations-panel.js +27 -0
  43. package/dist/src/tui/installations-rows.js +11 -0
  44. package/dist/src/tui/installations-tree.js +80 -0
  45. package/dist/src/tui/installations-view.js +154 -0
  46. package/dist/src/tui/installations.js +172 -0
  47. package/dist/src/tui/keys.js +31 -0
  48. package/dist/src/tui/manage-actions.js +174 -0
  49. package/dist/src/tui/manage-panel.js +65 -0
  50. package/dist/src/tui/mode-keys.js +61 -0
  51. package/dist/src/tui/panel-parts.js +23 -0
  52. package/dist/src/tui/render.js +8 -0
  53. package/dist/src/tui/sources-mode.js +17 -0
  54. package/dist/src/tui/sources-view.js +276 -0
  55. package/dist/src/tui/theme.js +29 -0
  56. package/dist/src/tui/tree-navigation.js +68 -0
  57. package/dist/src/tui/tree.js +53 -0
  58. package/dist/src/tui/unmanaged-actions.js +178 -0
  59. package/dist/src/tui/updates-flow.js +89 -0
  60. package/dist/src/tui/updates-keys.js +104 -0
  61. package/dist/src/tui/updates-mode.js +1 -0
  62. package/dist/src/tui/updates-model.js +48 -0
  63. package/dist/src/tui/updates-panel.js +44 -0
  64. package/dist/src/tui/updates-view.js +101 -0
  65. package/dist/src/tui/updates.js +230 -0
  66. package/dist/src/tui/view-state.js +42 -0
  67. package/dist/src/tui/window.js +31 -0
  68. package/dist/src/unmanaged-removal.js +60 -0
  69. package/dist/src/update-batch.js +376 -0
  70. package/dist/src/update-flow.js +135 -0
  71. package/dist/src/usage-error.js +7 -0
  72. package/dist/src/user-global-skill-inventory.js +565 -0
  73. package/dist/src/user-method.js +14 -0
  74. package/package.json +7 -4
  75. package/skills/agent-depot-apprecipe/SKILL.md +44 -0
  76. package/skills/agent-depot-apprecipe/assets/example.md +25 -0
  77. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  78. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  79. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  80. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  81. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  82. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  83. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  84. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  85. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  86. package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
@@ -0,0 +1,101 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { theme } from "./theme.js";
5
+ import { computeWindow, useListHeight } from "./window.js";
6
+ import { BROWSE } from "./updates-mode.js";
7
+ import { useMirrored } from "./view-state.js";
8
+ import { useUpdatesFlow } from "./updates-flow.js";
9
+ import { handleUpdatesKey } from "./updates-keys.js";
10
+ import { viewOf } from "./updates-model.js";
11
+ import { CheckStatus, EntryLine, ModePanel, UpdatesFooter } from "./updates-panel.js";
12
+ import { useKeys } from "./keys.js";
13
+ const NO_ENVIRONMENT = {};
14
+ /** How often the age of the check is redrawn. */
15
+ const CLOCK_TICK_MS = 10_000;
16
+ export function UpdatesView({ operations, environment, onCapturingChange, listHeight, now }) {
17
+ const height = useListHeight(listHeight, 9);
18
+ const env = environment ?? NO_ENVIRONMENT;
19
+ const clock = now ?? Date.now;
20
+ // Scope, cursor, fold and selection are mirrored in refs updated first, so keys delivered in one burst act on the latest state.
21
+ const [scope, scopeRef, setScope] = useMirrored("all");
22
+ const [cursor, cursorRef, setCursor] = useMirrored(0);
23
+ const [showUnknown, showUnknownRef, setShowUnknown] = useMirrored(false);
24
+ const [checked, checkedRef, setChecked] = useMirrored([]);
25
+ const [mode, setModeState] = useState(BROWSE);
26
+ // Mirrors the mode synchronously so later keystrokes and the shell never see stale state.
27
+ const modeRef = useRef(BROWSE);
28
+ const mounted = useRef(true);
29
+ const [message, setMessage] = useState();
30
+ // Only redraws, so "checked 5 min ago" moves on without a key press.
31
+ const [, setTick] = useState(0);
32
+ useEffect(() => {
33
+ mounted.current = true;
34
+ const timer = setInterval(() => setTick((value) => value + 1), CLOCK_TICK_MS);
35
+ // A redraw timer alone must never keep the process alive.
36
+ timer.unref();
37
+ return () => {
38
+ mounted.current = false;
39
+ clearInterval(timer);
40
+ };
41
+ }, []);
42
+ const setMode = useCallback((next) => {
43
+ modeRef.current = next;
44
+ onCapturingChange?.(next.kind !== "browse");
45
+ setModeState(next);
46
+ }, [onCapturingChange]);
47
+ const dataRef = useRef(undefined);
48
+ /** The list, the cursor and the fold as of the latest keystroke. */
49
+ const latest = () => {
50
+ const view = viewOf(dataRef.current, scopeRef.current, showUnknownRef.current);
51
+ return { ...view, data: dataRef.current, at: Math.min(cursorRef.current, Math.max(view.entries.length - 1, 0)) };
52
+ };
53
+ const changeScope = (next) => {
54
+ if (next === scopeRef.current)
55
+ return;
56
+ // Selected items that the new filter hides would otherwise be applied unseen.
57
+ setChecked([]);
58
+ setCursor(0);
59
+ setMessage(undefined);
60
+ setScope(next);
61
+ };
62
+ const cancel = () => {
63
+ setMessage({ kind: "ok", lines: ["Update cancelled"] });
64
+ setMode(BROWSE);
65
+ };
66
+ const { state, load, startPreview, apply } = useUpdatesFlow({
67
+ operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, setMessage,
68
+ });
69
+ // Entering the view refreshes the Git sources first, like `r`.
70
+ useEffect(() => {
71
+ void load(true);
72
+ }, [load]);
73
+ const data = state.status === "ready" ? state.data : undefined;
74
+ dataRef.current = data;
75
+ const { shown, updatable, unknown, upToDate, entries } = viewOf(data, scope, showUnknown);
76
+ const index = Math.min(cursor, Math.max(entries.length - 1, 0));
77
+ const keyContext = {
78
+ height,
79
+ latest,
80
+ checked: () => checkedRef.current,
81
+ setChecked,
82
+ setCursor,
83
+ unknownShown: () => showUnknownRef.current,
84
+ setShowUnknown,
85
+ changeScope,
86
+ recheck: () => {
87
+ setMessage(undefined);
88
+ setShowUnknown(false);
89
+ void load(true);
90
+ },
91
+ startPreview: () => void startPreview(),
92
+ setMode,
93
+ cancel,
94
+ apply: (prepared, confirmedPaths) => void apply(prepared, confirmedPaths),
95
+ };
96
+ useKeys((input, key) => handleUpdatesKey(keyContext, modeRef.current, input, key));
97
+ const window = computeWindow(entries.length, index, height);
98
+ const view = { shown, updatable, unknown, upToDate, entries };
99
+ const hasErrors = data?.appError !== undefined || (data?.results ?? []).some((result) => result.error !== undefined);
100
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["Updates (scope: ", scope, ") t all \u00B7 p project \u00B7 g user-global"] }), state.status === "loading" ? _jsx(Text, { children: state.label }) : null, data ? _jsx(CheckStatus, { data: data, now: clock() }) : null, data && !hasErrors && shown.length === 0 ? _jsx(Text, { children: "No installations" }) : null, entries.slice(window.start, window.end).map((entry, offset) => (_jsx(EntryLine, { entry: entry, selected: window.start + offset === index, checked: entry.kind === "update" && checked.includes(entry.row.key), view: view, showUnknown: showUnknown }, entry.kind === "summary" ? "summary" : entry.row.key))), _jsx(UpdatesFooter, { indicator: window.indicator, upToDate: upToDate, updatableCount: updatable.length, checkedCount: checked.length, message: message }), _jsx(ModePanel, { mode: mode })] }));
101
+ }
@@ -0,0 +1,230 @@
1
+ import { checkAppUpdates, createAppOperations } from "../app-flow.js";
2
+ import { formatVersionPolicy } from "../skill-install.js";
3
+ import { applyConfirmedUpdates, describeUpdatePreview, loadUpdates, previewUpdateCandidates, readScopeInstallations, } from "../update-flow.js";
4
+ import { formatVersionEvidence } from "./installations.js";
5
+ /** Every scope the view can assess, in display order. */
6
+ export const UPDATE_SCOPES = ["project", "user-global"];
7
+ const APP_STATUS_LABEL = {
8
+ "update available": "update available",
9
+ current: "up to date",
10
+ unknown: "cannot assess",
11
+ };
12
+ const STATUS_LABEL = {
13
+ updateable: "update available",
14
+ current: "up to date",
15
+ unknown: "cannot assess",
16
+ };
17
+ function errorText(error) {
18
+ return error instanceof Error ? error.message : String(error);
19
+ }
20
+ function scopeInput(operations, environment, scope) {
21
+ return {
22
+ scope,
23
+ operations,
24
+ ...(environment.homeDirectory === undefined ? {} : { homeDirectory: environment.homeDirectory }),
25
+ ...(environment.projectRoot === undefined ? {} : { projectRoot: environment.projectRoot }),
26
+ ...(environment.sourceAccess === undefined ? {} : { sourceAccess: environment.sourceAccess }),
27
+ ...(environment.projectManifestStore === undefined ? {} : { projectManifestStore: environment.projectManifestStore }),
28
+ ...(environment.installationOptions?.fileSystem === undefined ? {} : { installationFileSystem: environment.installationOptions.fileSystem }),
29
+ };
30
+ }
31
+ /** Refreshes through the project-source operation the install flow uses; a registered Source is refreshed by id otherwise. */
32
+ async function refreshOne(operations, source) {
33
+ if (operations.refreshProjectSource) {
34
+ await operations.refreshProjectSource(source);
35
+ return;
36
+ }
37
+ const registered = (await operations.listSources()).find((candidate) => candidate.kind === "git" && candidate.url === source.url);
38
+ if (!registered)
39
+ throw new Error("the Source is not registered, so it cannot be refreshed here");
40
+ await operations.refreshSource(registered.id);
41
+ }
42
+ /** Refreshes every distinct Git source the installations use, one at a time; the built-in source is skipped. */
43
+ async function refreshSources(operations, selections) {
44
+ const distinct = new Map();
45
+ for (const { source } of selections) {
46
+ if (source.kind === "external" && "url" in source)
47
+ distinct.set(`${source.url}\u0000${source.ref ?? ""}`, source);
48
+ }
49
+ const refreshed = [];
50
+ const failed = [];
51
+ for (const source of distinct.values()) {
52
+ const label = source.ref === undefined ? source.url : `${source.url}@${source.ref}`;
53
+ try {
54
+ await refreshOne(operations, source);
55
+ refreshed.push(label);
56
+ }
57
+ catch (error) {
58
+ failed.push({ source: label, reason: errorText(error) });
59
+ }
60
+ }
61
+ return { attempted: true, refreshed, failed, unreadable: [] };
62
+ }
63
+ /**
64
+ * Refreshes the Git sources involved (unless told otherwise), then runs the
65
+ * same assessment `update check` performs for every scope. A source that
66
+ * fails to refresh is reported and the check still uses its current data.
67
+ * Changes nothing but the Source caches.
68
+ */
69
+ export async function loadUpdateRows(operations, environment, options) {
70
+ const apps = createAppOperations({ homeDirectory: environment.homeDirectory, ...environment.appEnvironment });
71
+ let refresh = { attempted: false, refreshed: [], failed: [], unreadable: [] };
72
+ if (options.refresh) {
73
+ options.onPhase?.("refreshing");
74
+ const unreadable = [];
75
+ const installations = await Promise.all(UPDATE_SCOPES.map((scope) => readScopeInstallations(scopeInput(operations, environment, scope)).catch((error) => {
76
+ unreadable.push({ scope, reason: errorText(error) });
77
+ return [];
78
+ })));
79
+ refresh = { ...(await refreshSources(operations, installations.flat())), unreadable };
80
+ }
81
+ options.onPhase?.("checking");
82
+ const results = await Promise.all(UPDATE_SCOPES.map(async (scope) => {
83
+ try {
84
+ return { scope, loaded: await loadUpdates(scopeInput(operations, environment, scope)) };
85
+ }
86
+ catch (error) {
87
+ return { scope, error: errorText(error) };
88
+ }
89
+ }));
90
+ const rows = results.flatMap(({ scope, loaded }) => (loaded?.assessment.items ?? []).map((item) => ({
91
+ kind: "skill",
92
+ key: `${scope}:${item.id}`,
93
+ scope,
94
+ item,
95
+ path: item.selection.path,
96
+ policy: formatVersionPolicy(item.selection.version),
97
+ installed: formatVersionEvidence(item.selection.installation?.resolvedVersion),
98
+ available: item.currentVersion === undefined ? "?" : formatVersionEvidence(item.currentVersion),
99
+ status: STATUS_LABEL[item.status],
100
+ reason: item.reason,
101
+ })));
102
+ let appChecks = [];
103
+ let appError;
104
+ try {
105
+ appChecks = await checkAppUpdates(apps);
106
+ }
107
+ catch (error) {
108
+ appError = errorText(error);
109
+ }
110
+ for (const check of appChecks) {
111
+ rows.push({
112
+ kind: "app",
113
+ key: `app:${check.entry.file}`,
114
+ scope: "user-global",
115
+ check,
116
+ path: `App: ${check.entry.recipe?.name ?? check.entry.file}`,
117
+ policy: "latest",
118
+ installed: check.installedVersion ?? "?",
119
+ available: check.latestVersion ?? "?",
120
+ status: APP_STATUS_LABEL[check.status],
121
+ reason: check.reason ?? check.inspection.reason ?? check.inspection.status,
122
+ });
123
+ }
124
+ return { results, rows, refresh, apps, appError, checkedAt: (options.now ?? Date.now)() };
125
+ }
126
+ /** `just now`, `42s ago`, `5 min ago`, `2 h ago`. */
127
+ export function formatAgo(elapsedMs) {
128
+ const seconds = Math.floor(Math.max(elapsedMs, 0) / 1000);
129
+ if (seconds < 5)
130
+ return "just now";
131
+ if (seconds < 60)
132
+ return `${seconds}s ago`;
133
+ const minutes = Math.floor(seconds / 60);
134
+ if (minutes < 60)
135
+ return `${minutes} min ago`;
136
+ return `${Math.floor(minutes / 60)} h ago`;
137
+ }
138
+ /** Plan step: previews the selection from the assessed snapshots, scope by scope. Changes nothing. */
139
+ export async function prepareUpdates(data, selected) {
140
+ const groups = [];
141
+ const appPlans = [];
142
+ const appFailures = [];
143
+ const lines = [];
144
+ const failures = [];
145
+ for (const { scope, loaded } of data.results) {
146
+ const items = selected.filter((row) => row.kind !== "app" && row.scope === scope).map((row) => row.item);
147
+ if (!loaded || items.length === 0)
148
+ continue;
149
+ const { context } = loaded;
150
+ const outcome = await previewUpdateCandidates(items, context);
151
+ lines.push(...outcome.plans.flatMap((plan) => describeUpdatePreview(plan, scope, context.projectRoot, "prompt")));
152
+ failures.push(...outcome.failures);
153
+ groups.push({
154
+ scope,
155
+ loaded,
156
+ applicable: outcome.plans.map((plan) => plan.item),
157
+ nonCanonicalPaths: outcome.nonCanonicalPaths,
158
+ overwriteShown: new Set(outcome.plans.filter((plan) => plan.overwriteRequired).map((plan) => plan.item.id)),
159
+ });
160
+ }
161
+ for (const row of selected) {
162
+ if (row.kind !== "app" || !data.apps)
163
+ continue;
164
+ try {
165
+ const plan = await data.apps.planLifecycle(row.check.entry, "update", undefined, row.check);
166
+ appPlans.push(plan);
167
+ lines.push(`Environment: ${plan.environment}`, row.path, `Version: ${plan.previousVersion} -> ${plan.latestVersion ?? "unknown"}`, ...(plan.argv ? [`argv: ${JSON.stringify(plan.argv)}`, `Executable: ${plan.executable ?? "unavailable"}`] : []), ...(plan.manual ? [`Manual: ${plan.manual}`] : []), `Working directory: ${plan.cwd}`, ...(plan.warning ? [plan.warning] : []));
168
+ }
169
+ catch (error) {
170
+ appFailures.push(`Failed ${row.path}: preview failed: ${errorText(error)}`);
171
+ }
172
+ }
173
+ return {
174
+ groups, apps: data.apps, appPlans, appFailures,
175
+ lines,
176
+ applicable: [...groups.flatMap((group) => group.applicable), ...appPlans],
177
+ failures,
178
+ nonCanonicalPaths: groups.flatMap((group) => group.nonCanonicalPaths.map((location) => ({ scope: group.scope, path: location }))),
179
+ };
180
+ }
181
+ /**
182
+ * Applies exactly the previewed items, scope by scope. Confirming the preview
183
+ * confirms overwriting and external commands; a non-canonical path is applied
184
+ * only when the caller passes it (with its scope) after the separate confirmation.
185
+ * confirmManual pauses for manual completion, showing the fallback reason. A false
186
+ * answer (or absent callback) cancels and clears pending update evidence.
187
+ * A Skill that became locally modified after the preview fails instead of being overwritten.
188
+ */
189
+ export async function runUpdates(prepared, confirmedPaths, confirmManual) {
190
+ const updated = [];
191
+ const failedLines = [];
192
+ for (const group of prepared.groups) {
193
+ const result = await applyConfirmedUpdates(group.applicable, group.loaded.context, confirmedPaths
194
+ .filter((confirmed) => confirmed.scope === group.scope && group.nonCanonicalPaths.includes(confirmed.path))
195
+ .map((confirmed) => confirmed.path), group.overwriteShown);
196
+ updated.push(...result.updated.map((item) => `Updated ${item.selection.path}`));
197
+ failedLines.push(...result.failed.map((item) => `Failed ${item.selection.path}: ${item.error ?? "unknown error"}`));
198
+ }
199
+ for (const plan of prepared.appPlans) {
200
+ const name = `App: ${plan.entry.recipe.name}`;
201
+ try {
202
+ const apps = prepared.apps;
203
+ if (!apps)
204
+ throw new Error("App operations unavailable; check again");
205
+ let result = await apps.executeLifecycle(plan, true);
206
+ if (result.status === "manual required") {
207
+ if (!await confirmManual?.(plan, result.reason)) {
208
+ await apps.cancelManual(plan);
209
+ failedLines.push(`Cancelled ${name}${result.reason ? `: ${result.reason}` : ""}`);
210
+ continue;
211
+ }
212
+ result = await apps.completeManual(plan, true);
213
+ }
214
+ if (result.status === "failed")
215
+ failedLines.push(`Failed ${name}: ${result.reason}`);
216
+ else
217
+ updated.push(`Updated ${name}: installed ${result.previousVersion} -> ${result.installedVersion} (latest ${result.latestVersion ?? "unknown"})`);
218
+ }
219
+ catch (error) {
220
+ failedLines.push(`Failed ${name}: ${errorText(error)}`);
221
+ }
222
+ }
223
+ failedLines.push(...prepared.appFailures);
224
+ const previewFailed = prepared.failures.map(({ item, message }) => `Failed ${item.selection.path}: preview failed: ${message}`);
225
+ const failed = failedLines.length + previewFailed.length;
226
+ return {
227
+ failed,
228
+ lines: [...updated, ...failedLines, ...previewFailed, `Update summary: ${updated.length} updated, ${failed} failed`],
229
+ };
230
+ }
@@ -0,0 +1,42 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ /** True while the view is mounted, so async work never updates an unmounted view. */
3
+ export function useMounted() {
4
+ const mounted = useRef(true);
5
+ useEffect(() => {
6
+ mounted.current = true;
7
+ return () => {
8
+ mounted.current = false;
9
+ };
10
+ }, []);
11
+ return mounted;
12
+ }
13
+ /** Marks by node id. The ref is updated first so keys in one burst see every mark. */
14
+ export function useMarks() {
15
+ const [marked, setMarkedState] = useState(new Set());
16
+ const markedRef = useRef(new Set());
17
+ const setMarked = useCallback((next) => {
18
+ markedRef.current = next;
19
+ setMarkedState(next);
20
+ }, []);
21
+ return { marked, markedRef, setMarked };
22
+ }
23
+ /** `marks` with `id` added, or removed when it was marked. */
24
+ export function toggledMark(marks, id) {
25
+ const next = new Set(marks);
26
+ if (!next.delete(id))
27
+ next.add(id);
28
+ return next;
29
+ }
30
+ /**
31
+ * State mirrored in a ref that is updated first, so keys delivered in one burst
32
+ * act on the latest value. The setter is stable.
33
+ */
34
+ export function useMirrored(initial) {
35
+ const [value, setValue] = useState(initial);
36
+ const ref = useRef(initial);
37
+ const set = useCallback((next) => {
38
+ ref.current = next;
39
+ setValue(next);
40
+ }, []);
41
+ return [value, ref, set];
42
+ }
@@ -0,0 +1,31 @@
1
+ import { useStdout } from "ink";
2
+ /** Rows assumed when the terminal size is unknown (for example under test renderers). */
3
+ export const FALLBACK_TERMINAL_ROWS = 24;
4
+ /** Lines the shell itself uses: title, tab bar, separator and footer hint. */
5
+ export const APP_CHROME_ROWS = 4;
6
+ const MINIMUM_ROWS = 3;
7
+ /** The slice of a list that fits in `height` rows while keeping `selected` visible (roughly centred). */
8
+ export function computeWindow(total, selected, height) {
9
+ const size = Math.max(Math.floor(height), 1);
10
+ if (total <= size)
11
+ return { start: 0, end: total, indicator: undefined };
12
+ const bounded = Math.min(Math.max(selected, 0), total - 1);
13
+ const start = Math.min(Math.max(bounded - Math.floor(size / 2), 0), total - size);
14
+ const end = start + size;
15
+ return { start, end, indicator: `${start + 1}–${end} of ${total}` };
16
+ }
17
+ /** How far PgUp/PgDn move the selection. */
18
+ export function pageStep(height) {
19
+ return Math.max(Math.floor(height), 1);
20
+ }
21
+ /**
22
+ * Rows available to a list: the terminal height minus the shell chrome and the
23
+ * view's own `reserved` lines. `override` fixes the height for tests.
24
+ */
25
+ export function useListHeight(override, reserved) {
26
+ const { stdout } = useStdout();
27
+ if (override !== undefined)
28
+ return Math.max(override, 1);
29
+ const rows = stdout.rows ?? FALLBACK_TERMINAL_ROWS;
30
+ return Math.max(rows - APP_CHROME_ROWS - reserved, MINIMUM_ROWS);
31
+ }
@@ -0,0 +1,60 @@
1
+ import { assertManagedInstallationRecordsUnchanged } from "./skill-removal.js";
2
+ import { inspectUserGlobalSkillRemoval, removeUserGlobalSkill, removeUserGlobalSymlink, } from "./user-global-skill-inventory.js";
3
+ /** The preview lines the CLI prints under each exact unmanaged path it will delete. */
4
+ export function describeUnmanagedRemoval(inspection) {
5
+ return [
6
+ ` remove exact path ${JSON.stringify(inspection.path)}`,
7
+ " WARNING: this is a permanent deletion; Agent Depot will not retain a backup",
8
+ " WARNING: recovery through Agent Depot requires a resolvable Source and is not guaranteed",
9
+ ];
10
+ }
11
+ /** Preview lines for a symbolic link: only the link is removed, never what it points to. */
12
+ export function describeUnmanagedSymlinkRemoval(inspection) {
13
+ return [
14
+ ` remove symbolic link ${JSON.stringify(inspection.path)} -> ${JSON.stringify(inspection.target)}`,
15
+ " only the link is removed; the path it points to is kept",
16
+ ];
17
+ }
18
+ /** Rechecks the records and the inspected tree, then permanently deletes one unmanaged Skill directory. */
19
+ export async function removeInspectedUnmanagedSkill(context, inspection, expectedManagedInstallations) {
20
+ const { operations, homeDirectory } = context;
21
+ const latestManagedInstallations = await operations.listUserGlobalInstallations();
22
+ assertManagedInstallationRecordsUnchanged(expectedManagedInstallations, latestManagedInstallations);
23
+ const latestInspection = await inspectUserGlobalSkillRemoval(inspection.path, {
24
+ ...context,
25
+ homeDirectory,
26
+ managedInstallations: latestManagedInstallations,
27
+ });
28
+ assertUnmanagedInspectionUnchanged(inspection, latestInspection);
29
+ await removeUserGlobalSkill(inspection, {
30
+ ...context,
31
+ homeDirectory,
32
+ managedInstallations: latestManagedInstallations,
33
+ readManagedInstallations: async () => operations.listUserGlobalInstallations(),
34
+ expectedManagedInstallations,
35
+ });
36
+ }
37
+ /** Rechecks the records and the inspected link, then unlinks only the link. */
38
+ export async function removeInspectedUnmanagedSymlink(context, inspection, expectedManagedInstallations) {
39
+ const { operations, homeDirectory } = context;
40
+ const latestManagedInstallations = await operations.listUserGlobalInstallations();
41
+ assertManagedInstallationRecordsUnchanged(expectedManagedInstallations, latestManagedInstallations);
42
+ await removeUserGlobalSymlink(inspection, {
43
+ ...context,
44
+ homeDirectory,
45
+ managedInstallations: latestManagedInstallations,
46
+ readManagedInstallations: async () => operations.listUserGlobalInstallations(),
47
+ expectedManagedInstallations,
48
+ });
49
+ }
50
+ /** Removes the inspected unmanaged Skills in order; the first failure stops the run, as in the CLI. */
51
+ export async function removeInspectedUnmanagedSkills(context, inspections, expectedManagedInstallations) {
52
+ for (const inspection of inspections) {
53
+ await removeInspectedUnmanagedSkill(context, inspection, expectedManagedInstallations);
54
+ }
55
+ }
56
+ function assertUnmanagedInspectionUnchanged(expected, actual) {
57
+ if (expected.name !== actual.name || expected.path !== actual.path || expected.identity !== actual.identity || expected.digest !== actual.digest) {
58
+ throw new Error(`The inspected unmanaged Skill changed before deletion: ${JSON.stringify(expected.path)}; no path was changed`);
59
+ }
60
+ }