@iseoane/agent-depot 0.2.0 → 0.2.1
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/CHANGELOG.md +25 -0
- package/README.md +8 -3
- package/dist/src/cli.js +1164 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +32 -0
- package/dist/src/project-installation.js +1411 -0
- package/dist/src/project-manifest.js +511 -0
- package/dist/src/skill-adoption.js +77 -0
- package/dist/src/skill-discovery.js +467 -0
- package/dist/src/skill-hosts.js +103 -0
- package/dist/src/skill-install.js +362 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +265 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app.js +63 -0
- package/dist/src/tui/batch.js +17 -0
- package/dist/src/tui/bulk-actions.js +243 -0
- package/dist/src/tui/catalog-actions.js +9 -0
- package/dist/src/tui/catalog-browse.js +59 -0
- package/dist/src/tui/catalog-filter.js +20 -0
- package/dist/src/tui/catalog-flow.js +107 -0
- package/dist/src/tui/catalog-installs.js +189 -0
- package/dist/src/tui/catalog-panel.js +47 -0
- package/dist/src/tui/catalog-state.js +92 -0
- package/dist/src/tui/catalog-tree.js +35 -0
- package/dist/src/tui/catalog-view.js +89 -0
- package/dist/src/tui/environment.js +1 -0
- package/dist/src/tui/installations-browse.js +114 -0
- package/dist/src/tui/installations-mode.js +1 -0
- package/dist/src/tui/installations-panel.js +27 -0
- package/dist/src/tui/installations-rows.js +11 -0
- package/dist/src/tui/installations-tree.js +68 -0
- package/dist/src/tui/installations-view.js +114 -0
- package/dist/src/tui/installations.js +149 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +172 -0
- package/dist/src/tui/manage-panel.js +59 -0
- package/dist/src/tui/mode-keys.js +61 -0
- package/dist/src/tui/panel-parts.js +23 -0
- package/dist/src/tui/render.js +8 -0
- package/dist/src/tui/sources-mode.js +17 -0
- package/dist/src/tui/sources-view.js +276 -0
- package/dist/src/tui/theme.js +29 -0
- package/dist/src/tui/tree-navigation.js +68 -0
- package/dist/src/tui/tree.js +53 -0
- package/dist/src/tui/unmanaged-actions.js +178 -0
- package/dist/src/tui/updates-flow.js +74 -0
- package/dist/src/tui/updates-keys.js +95 -0
- package/dist/src/tui/updates-mode.js +1 -0
- package/dist/src/tui/updates-model.js +48 -0
- package/dist/src/tui/updates-panel.js +42 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +159 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +57 -0
- package/dist/src/update-batch.js +376 -0
- package/dist/src/update-flow.js +135 -0
- package/dist/src/usage-error.js +7 -0
- package/dist/src/user-global-skill-inventory.js +559 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +4 -1
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
- package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
- package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { answerYesNo } from "./mode-keys.js";
|
|
2
|
+
import { pageStep } from "./window.js";
|
|
3
|
+
const SCOPE_KEYS = new Map([["t", "all"], ["p", "project"], ["g", "user-global"]]);
|
|
4
|
+
/** Moves the cursor with the arrows, j/k and paging; returns whether the key was one of them. */
|
|
5
|
+
function moveCursorKey(context, snapshot, input, key) {
|
|
6
|
+
const last = Math.max(snapshot.entries.length - 1, 0);
|
|
7
|
+
const { at } = snapshot;
|
|
8
|
+
if (key.downArrow || input === "j")
|
|
9
|
+
context.setCursor(Math.min(at + 1, last));
|
|
10
|
+
else if (key.upArrow || input === "k")
|
|
11
|
+
context.setCursor(Math.max(at - 1, 0));
|
|
12
|
+
else if (key.pageDown)
|
|
13
|
+
context.setCursor(Math.min(at + pageStep(context.height), last));
|
|
14
|
+
else if (key.pageUp)
|
|
15
|
+
context.setCursor(Math.max(at - pageStep(context.height), 0));
|
|
16
|
+
else
|
|
17
|
+
return false;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
/** Space toggles the highlighted update, `a` selects them all. */
|
|
21
|
+
function selectKey(context, snapshot, input) {
|
|
22
|
+
if (input === " ") {
|
|
23
|
+
const entry = snapshot.entries[snapshot.at];
|
|
24
|
+
if (entry?.kind !== "update")
|
|
25
|
+
return true;
|
|
26
|
+
const checked = context.checked();
|
|
27
|
+
context.setChecked(checked.includes(entry.row.key) ? checked.filter((id) => id !== entry.row.key) : [...checked, entry.row.key]);
|
|
28
|
+
}
|
|
29
|
+
else if (input === "a")
|
|
30
|
+
context.setChecked(snapshot.updatable.map((row) => row.key));
|
|
31
|
+
else
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
/** Right/left unfold and fold the "cannot be checked" rows; Enter toggles the fold or previews the selection. */
|
|
36
|
+
function foldKey(context, snapshot, key) {
|
|
37
|
+
const entry = snapshot.entries[snapshot.at];
|
|
38
|
+
if (key.rightArrow) {
|
|
39
|
+
if (entry?.kind === "summary")
|
|
40
|
+
context.setShowUnknown(true);
|
|
41
|
+
}
|
|
42
|
+
else if (key.leftArrow) {
|
|
43
|
+
if (entry?.kind === "unknown")
|
|
44
|
+
context.setCursor(snapshot.updatable.length);
|
|
45
|
+
if (entry?.kind === "summary" || entry?.kind === "unknown")
|
|
46
|
+
context.setShowUnknown(false);
|
|
47
|
+
}
|
|
48
|
+
else if (key.return) {
|
|
49
|
+
if (entry?.kind === "summary")
|
|
50
|
+
context.setShowUnknown(!context.unknownShown());
|
|
51
|
+
else if (entry?.kind !== "unknown" && snapshot.data)
|
|
52
|
+
context.startPreview();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function handleBrowseKey(context, input, key) {
|
|
56
|
+
const snapshot = context.latest();
|
|
57
|
+
if (moveCursorKey(context, snapshot, input, key) || selectKey(context, snapshot, input))
|
|
58
|
+
return;
|
|
59
|
+
const scope = SCOPE_KEYS.get(input);
|
|
60
|
+
if (scope !== undefined)
|
|
61
|
+
context.changeScope(scope);
|
|
62
|
+
else if (input === "r")
|
|
63
|
+
context.recheck();
|
|
64
|
+
else
|
|
65
|
+
foldKey(context, snapshot, key);
|
|
66
|
+
}
|
|
67
|
+
function handlePreviewKey(context, mode, input, key) {
|
|
68
|
+
if (mode.prepared.applicable.length === 0) {
|
|
69
|
+
if (key.escape || key.return || input === "n")
|
|
70
|
+
context.cancel();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
answerYesNo(input, key, () => {
|
|
74
|
+
if (mode.prepared.nonCanonicalPaths.length > 0)
|
|
75
|
+
context.setMode({ ...mode, kind: "confirm-paths" });
|
|
76
|
+
else
|
|
77
|
+
context.apply(mode.prepared, []);
|
|
78
|
+
}, context.cancel);
|
|
79
|
+
}
|
|
80
|
+
/** Handles a key in the current Updates mode; `busy` ignores keys. */
|
|
81
|
+
export function handleUpdatesKey(context, mode, input, key) {
|
|
82
|
+
switch (mode.kind) {
|
|
83
|
+
case "browse":
|
|
84
|
+
handleBrowseKey(context, input, key);
|
|
85
|
+
return;
|
|
86
|
+
case "preview":
|
|
87
|
+
handlePreviewKey(context, mode, input, key);
|
|
88
|
+
return;
|
|
89
|
+
case "confirm-paths":
|
|
90
|
+
answerYesNo(input, key, () => context.apply(mode.prepared, mode.prepared.nonCanonicalPaths), context.cancel);
|
|
91
|
+
return;
|
|
92
|
+
default:
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const BROWSE = { kind: "browse" };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const REFRESHING = "Refreshing sources...";
|
|
2
|
+
export const CHECKING = "Checking for updates...";
|
|
3
|
+
export const PHASE_LABEL = { refreshing: REFRESHING, checking: CHECKING };
|
|
4
|
+
/** Names the reasons worth knowing before expanding the collapsed "cannot be checked" row. */
|
|
5
|
+
export function describeUnknownReasons(rows) {
|
|
6
|
+
const newer = rows.filter((row) => /^installed by a newer agent depot/i.test(row.reason)).length;
|
|
7
|
+
const pinned = rows.filter((row) => /^pinned to agent depot/i.test(row.reason)).length;
|
|
8
|
+
const parts = [
|
|
9
|
+
...(newer > 0 ? [`${newer} installed by a newer Agent Depot`] : []),
|
|
10
|
+
...(pinned > 0 ? [`${pinned} pinned to another Agent Depot`] : []),
|
|
11
|
+
];
|
|
12
|
+
return parts.length === 0 ? "" : ` (${parts.join(", ")})`;
|
|
13
|
+
}
|
|
14
|
+
const matchesScope = (row, filter) => filter === "all" || row.scope === filter;
|
|
15
|
+
/** The rows the list shows for a scope filter and fold state, and the cursor positions they make up. */
|
|
16
|
+
export function viewOf(data, scope, showUnknown) {
|
|
17
|
+
const shown = (data?.rows ?? []).filter((row) => matchesScope(row, scope));
|
|
18
|
+
const updatable = shown.filter((row) => row.item.status === "updateable");
|
|
19
|
+
const unknown = shown.filter((row) => row.item.status === "unknown");
|
|
20
|
+
const upToDate = shown.filter((row) => row.item.status === "current").length;
|
|
21
|
+
const entries = [
|
|
22
|
+
...updatable.map((row) => ({ kind: "update", row })),
|
|
23
|
+
...(unknown.length > 0 ? [{ kind: "summary" }] : []),
|
|
24
|
+
...(unknown.length > 0 && showUnknown ? unknown.map((row) => ({ kind: "unknown", row })) : []),
|
|
25
|
+
];
|
|
26
|
+
return { shown, updatable, unknown, upToDate, entries };
|
|
27
|
+
}
|
|
28
|
+
export function describeRefresh(data) {
|
|
29
|
+
const { attempted, refreshed, failed, unreadable } = data.refresh;
|
|
30
|
+
if (!attempted)
|
|
31
|
+
return "sources not fetched again";
|
|
32
|
+
if (unreadable.length > 0) {
|
|
33
|
+
return `could not refresh: ${unreadable.map(({ scope, reason }) => `${reason} (${scope})`).join("; ")}; cached data was used`;
|
|
34
|
+
}
|
|
35
|
+
if (failed.length > 0) {
|
|
36
|
+
return `${failed.length} of ${failed.length + refreshed.length} sources could not be refreshed; their current data was used`;
|
|
37
|
+
}
|
|
38
|
+
return refreshed.length > 0 ? "sources refreshed" : "no Git sources to refresh";
|
|
39
|
+
}
|
|
40
|
+
/** Data for a check that failed as a whole, so the view can show the reason. */
|
|
41
|
+
export function emptyData(reason, checkedAt) {
|
|
42
|
+
return {
|
|
43
|
+
results: [{ scope: "project", error: reason }],
|
|
44
|
+
rows: [],
|
|
45
|
+
refresh: { attempted: false, refreshed: [], failed: [], unreadable: [] },
|
|
46
|
+
checkedAt,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { rowStyle, theme } from "./theme.js";
|
|
4
|
+
import { CHECKING, describeRefresh, describeUnknownReasons, REFRESHING } from "./updates-model.js";
|
|
5
|
+
import { formatAgo } from "./updates.js";
|
|
6
|
+
import { PreviewLines } from "./panel-parts.js";
|
|
7
|
+
/** What the last check found out: its age, how the sources were refreshed, and what failed. */
|
|
8
|
+
export function CheckStatus({ data, now }) {
|
|
9
|
+
const errors = data.results.filter((result) => result.error !== undefined);
|
|
10
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["checked ", formatAgo(now - data.checkedAt), " \u00B7 ", describeRefresh(data)] }), data.refresh.failed.map((failure) => (_jsxs(Text, { color: theme.warning, children: ["refresh failed for ", failure.source, ": ", failure.reason] }, failure.source))), errors.map((result) => _jsxs(Text, { color: theme.error, children: ["Error (", result.scope, "): ", result.error] }, result.scope))] }));
|
|
11
|
+
}
|
|
12
|
+
/** One line of the list: an update, the fold of unassessable items, or one of those when unfolded. */
|
|
13
|
+
export function EntryLine({ entry, selected, checked, view, showUnknown }) {
|
|
14
|
+
const cursor = selected ? "> " : " ";
|
|
15
|
+
if (entry.kind === "summary") {
|
|
16
|
+
return (_jsxs(Text, { ...rowStyle(selected), color: theme.inactive, children: [cursor, view.unknown.length, " cannot be checked ", showUnknown ? "▾" : "▸", describeUnknownReasons(view.unknown)] }));
|
|
17
|
+
}
|
|
18
|
+
const { row } = entry;
|
|
19
|
+
if (entry.kind === "unknown") {
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...rowStyle(selected), children: [cursor, " ", row.path, " ", row.scope] }), _jsxs(Text, { color: theme.muted, children: [" ", row.reason] })] }));
|
|
21
|
+
}
|
|
22
|
+
return (_jsxs(Text, { ...rowStyle(selected), children: [cursor, checked ? "[x]" : "[ ]", " ", row.path, " ", row.scope, " ", row.policy, " ", row.installed, " ", "->", " ", row.available] }));
|
|
23
|
+
}
|
|
24
|
+
/** Under the list: the window indicator, what is up to date or selected, and the last result. */
|
|
25
|
+
export function UpdatesFooter({ indicator, upToDate, updatableCount, checkedCount, message }) {
|
|
26
|
+
return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, upToDate > 0 ? _jsxs(Text, { color: theme.muted, children: [upToDate, " up to date"] }) : null, updatableCount > 0 ? _jsxs(Text, { color: theme.muted, children: [checkedCount, " selected"] }) : null, message ? (_jsx(Box, { flexDirection: "column", children: message.lines.map((line, position) => _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: line }, position)) })) : null] }));
|
|
27
|
+
}
|
|
28
|
+
/** The prompt of the preview and confirmation modes; browsing and the check itself show nothing. */
|
|
29
|
+
export function ModePanel({ mode }) {
|
|
30
|
+
switch (mode.kind) {
|
|
31
|
+
case "browse":
|
|
32
|
+
return null;
|
|
33
|
+
case "busy":
|
|
34
|
+
return mode.label === REFRESHING || mode.label === CHECKING ? null : _jsx(Text, { children: mode.label });
|
|
35
|
+
case "preview":
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.prepared.lines }), mode.prepared.failures.map(({ item, message }) => (_jsxs(Text, { color: theme.error, children: ["Preview failed for ", JSON.stringify(item.id), ": ", message] }, item.id))), mode.prepared.applicable.length === 0
|
|
37
|
+
? _jsx(Text, { children: "Nothing can be applied. Enter/Esc to go back" })
|
|
38
|
+
: _jsxs(Text, { children: ["Apply ", mode.prepared.applicable.length, " ", mode.prepared.applicable.length === 1 ? "update" : "updates", "? y/n"] })] }));
|
|
39
|
+
case "confirm-paths":
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "Confirm replacing non-canonical locations (outside .agents/skills and .claude/skills):" }), mode.prepared.nonCanonicalPaths.map((location, position) => _jsxs(Text, { color: theme.warning, children: [" [", location.scope, "] ", JSON.stringify(location.path)] }, position)), _jsx(Text, { children: "Replace exactly these paths? y/n" })] }));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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?.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,159 @@
|
|
|
1
|
+
import { formatVersionPolicy } from "../skill-install.js";
|
|
2
|
+
import { applyConfirmedUpdates, describeUpdatePreview, loadUpdates, previewUpdateCandidates, readScopeInstallations, } from "../update-flow.js";
|
|
3
|
+
import { formatVersionEvidence } from "./installations.js";
|
|
4
|
+
/** Every scope the view can assess, in display order. */
|
|
5
|
+
export const UPDATE_SCOPES = ["project", "user-global"];
|
|
6
|
+
const STATUS_LABEL = {
|
|
7
|
+
updateable: "update available",
|
|
8
|
+
current: "up to date",
|
|
9
|
+
unknown: "cannot assess",
|
|
10
|
+
};
|
|
11
|
+
function errorText(error) {
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
}
|
|
14
|
+
function scopeInput(operations, environment, scope) {
|
|
15
|
+
return {
|
|
16
|
+
scope,
|
|
17
|
+
operations,
|
|
18
|
+
...(environment.homeDirectory === undefined ? {} : { homeDirectory: environment.homeDirectory }),
|
|
19
|
+
...(environment.projectRoot === undefined ? {} : { projectRoot: environment.projectRoot }),
|
|
20
|
+
...(environment.sourceAccess === undefined ? {} : { sourceAccess: environment.sourceAccess }),
|
|
21
|
+
...(environment.projectManifestStore === undefined ? {} : { projectManifestStore: environment.projectManifestStore }),
|
|
22
|
+
...(environment.installationOptions?.fileSystem === undefined ? {} : { installationFileSystem: environment.installationOptions.fileSystem }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Refreshes through the project-source operation the install flow uses; a registered Source is refreshed by id otherwise. */
|
|
26
|
+
async function refreshOne(operations, source) {
|
|
27
|
+
if (operations.refreshProjectSource) {
|
|
28
|
+
await operations.refreshProjectSource(source);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const registered = (await operations.listSources()).find((candidate) => candidate.kind === "git" && candidate.url === source.url);
|
|
32
|
+
if (!registered)
|
|
33
|
+
throw new Error("the Source is not registered, so it cannot be refreshed here");
|
|
34
|
+
await operations.refreshSource(registered.id);
|
|
35
|
+
}
|
|
36
|
+
/** Refreshes every distinct Git source the installations use, one at a time; the built-in source is skipped. */
|
|
37
|
+
async function refreshSources(operations, selections) {
|
|
38
|
+
const distinct = new Map();
|
|
39
|
+
for (const { source } of selections) {
|
|
40
|
+
if (source.kind === "external" && "url" in source)
|
|
41
|
+
distinct.set(`${source.url}\u0000${source.ref ?? ""}`, source);
|
|
42
|
+
}
|
|
43
|
+
const refreshed = [];
|
|
44
|
+
const failed = [];
|
|
45
|
+
for (const source of distinct.values()) {
|
|
46
|
+
const label = source.ref === undefined ? source.url : `${source.url}@${source.ref}`;
|
|
47
|
+
try {
|
|
48
|
+
await refreshOne(operations, source);
|
|
49
|
+
refreshed.push(label);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
failed.push({ source: label, reason: errorText(error) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { attempted: true, refreshed, failed, unreadable: [] };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Refreshes the Git sources involved (unless told otherwise), then runs the
|
|
59
|
+
* same assessment `update check` performs for every scope. A source that
|
|
60
|
+
* fails to refresh is reported and the check still uses its current data.
|
|
61
|
+
* Changes nothing but the Source caches.
|
|
62
|
+
*/
|
|
63
|
+
export async function loadUpdateRows(operations, environment, options) {
|
|
64
|
+
let refresh = { attempted: false, refreshed: [], failed: [], unreadable: [] };
|
|
65
|
+
if (options.refresh) {
|
|
66
|
+
options.onPhase?.("refreshing");
|
|
67
|
+
const unreadable = [];
|
|
68
|
+
const installations = await Promise.all(UPDATE_SCOPES.map((scope) => readScopeInstallations(scopeInput(operations, environment, scope)).catch((error) => {
|
|
69
|
+
unreadable.push({ scope, reason: errorText(error) });
|
|
70
|
+
return [];
|
|
71
|
+
})));
|
|
72
|
+
refresh = { ...(await refreshSources(operations, installations.flat())), unreadable };
|
|
73
|
+
}
|
|
74
|
+
options.onPhase?.("checking");
|
|
75
|
+
const results = await Promise.all(UPDATE_SCOPES.map(async (scope) => {
|
|
76
|
+
try {
|
|
77
|
+
return { scope, loaded: await loadUpdates(scopeInput(operations, environment, scope)) };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return { scope, error: errorText(error) };
|
|
81
|
+
}
|
|
82
|
+
}));
|
|
83
|
+
const rows = results.flatMap(({ scope, loaded }) => (loaded?.assessment.items ?? []).map((item) => ({
|
|
84
|
+
key: `${scope}:${item.id}`,
|
|
85
|
+
scope,
|
|
86
|
+
item,
|
|
87
|
+
path: item.selection.path,
|
|
88
|
+
policy: formatVersionPolicy(item.selection.version),
|
|
89
|
+
installed: formatVersionEvidence(item.selection.installation?.resolvedVersion),
|
|
90
|
+
available: item.currentVersion === undefined ? "?" : formatVersionEvidence(item.currentVersion),
|
|
91
|
+
status: STATUS_LABEL[item.status],
|
|
92
|
+
reason: item.reason,
|
|
93
|
+
})));
|
|
94
|
+
return { results, rows, refresh, checkedAt: (options.now ?? Date.now)() };
|
|
95
|
+
}
|
|
96
|
+
/** `just now`, `42s ago`, `5 min ago`, `2 h ago`. */
|
|
97
|
+
export function formatAgo(elapsedMs) {
|
|
98
|
+
const seconds = Math.floor(Math.max(elapsedMs, 0) / 1000);
|
|
99
|
+
if (seconds < 5)
|
|
100
|
+
return "just now";
|
|
101
|
+
if (seconds < 60)
|
|
102
|
+
return `${seconds}s ago`;
|
|
103
|
+
const minutes = Math.floor(seconds / 60);
|
|
104
|
+
if (minutes < 60)
|
|
105
|
+
return `${minutes} min ago`;
|
|
106
|
+
return `${Math.floor(minutes / 60)} h ago`;
|
|
107
|
+
}
|
|
108
|
+
/** Plan step: previews the selection from the assessed snapshots, scope by scope. Changes nothing. */
|
|
109
|
+
export async function prepareUpdates(data, selected) {
|
|
110
|
+
const groups = [];
|
|
111
|
+
const lines = [];
|
|
112
|
+
const failures = [];
|
|
113
|
+
for (const { scope, loaded } of data.results) {
|
|
114
|
+
const items = selected.filter((row) => row.scope === scope).map((row) => row.item);
|
|
115
|
+
if (!loaded || items.length === 0)
|
|
116
|
+
continue;
|
|
117
|
+
const { context } = loaded;
|
|
118
|
+
const outcome = await previewUpdateCandidates(items, context);
|
|
119
|
+
lines.push(...outcome.plans.flatMap((plan) => describeUpdatePreview(plan, scope, context.projectRoot, "prompt")));
|
|
120
|
+
failures.push(...outcome.failures);
|
|
121
|
+
groups.push({
|
|
122
|
+
scope,
|
|
123
|
+
loaded,
|
|
124
|
+
applicable: outcome.plans.map((plan) => plan.item),
|
|
125
|
+
nonCanonicalPaths: outcome.nonCanonicalPaths,
|
|
126
|
+
overwriteShown: new Set(outcome.plans.filter((plan) => plan.overwriteRequired).map((plan) => plan.item.id)),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
groups,
|
|
131
|
+
lines,
|
|
132
|
+
applicable: groups.flatMap((group) => group.applicable),
|
|
133
|
+
failures,
|
|
134
|
+
nonCanonicalPaths: groups.flatMap((group) => group.nonCanonicalPaths.map((location) => ({ scope: group.scope, path: location }))),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Applies exactly the previewed items, scope by scope. Confirming the preview
|
|
139
|
+
* confirms overwriting and external commands; a non-canonical path is applied
|
|
140
|
+
* only when the caller passes it (with its scope) after the separate confirmation.
|
|
141
|
+
* A Skill that became locally modified after the preview fails instead of being overwritten.
|
|
142
|
+
*/
|
|
143
|
+
export async function runUpdates(prepared, confirmedPaths) {
|
|
144
|
+
const updated = [];
|
|
145
|
+
const failedLines = [];
|
|
146
|
+
for (const group of prepared.groups) {
|
|
147
|
+
const result = await applyConfirmedUpdates(group.applicable, group.loaded.context, confirmedPaths
|
|
148
|
+
.filter((confirmed) => confirmed.scope === group.scope && group.nonCanonicalPaths.includes(confirmed.path))
|
|
149
|
+
.map((confirmed) => confirmed.path), group.overwriteShown);
|
|
150
|
+
updated.push(...result.updated.map((item) => `Updated ${item.selection.path}`));
|
|
151
|
+
failedLines.push(...result.failed.map((item) => `Failed ${item.selection.path}: ${item.error ?? "unknown error"}`));
|
|
152
|
+
}
|
|
153
|
+
const previewFailed = prepared.failures.map(({ item, message }) => `Failed ${item.selection.path}: preview failed: ${message}`);
|
|
154
|
+
const failed = failedLines.length + previewFailed.length;
|
|
155
|
+
return {
|
|
156
|
+
failed,
|
|
157
|
+
lines: [...updated, ...failedLines, ...previewFailed, `Update summary: ${updated.length} updated, ${failed} failed`],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -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,57 @@
|
|
|
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
|
+
homeDirectory,
|
|
25
|
+
managedInstallations: latestManagedInstallations,
|
|
26
|
+
});
|
|
27
|
+
assertUnmanagedInspectionUnchanged(inspection, latestInspection);
|
|
28
|
+
await removeUserGlobalSkill(inspection, {
|
|
29
|
+
homeDirectory,
|
|
30
|
+
managedInstallations: latestManagedInstallations,
|
|
31
|
+
readManagedInstallations: async () => operations.listUserGlobalInstallations(),
|
|
32
|
+
expectedManagedInstallations,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Rechecks the records and the inspected link, then unlinks only the link. */
|
|
36
|
+
export async function removeInspectedUnmanagedSymlink(context, inspection, expectedManagedInstallations) {
|
|
37
|
+
const { operations, homeDirectory } = context;
|
|
38
|
+
const latestManagedInstallations = await operations.listUserGlobalInstallations();
|
|
39
|
+
assertManagedInstallationRecordsUnchanged(expectedManagedInstallations, latestManagedInstallations);
|
|
40
|
+
await removeUserGlobalSymlink(inspection, {
|
|
41
|
+
homeDirectory,
|
|
42
|
+
managedInstallations: latestManagedInstallations,
|
|
43
|
+
readManagedInstallations: async () => operations.listUserGlobalInstallations(),
|
|
44
|
+
expectedManagedInstallations,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Removes the inspected unmanaged Skills in order; the first failure stops the run, as in the CLI. */
|
|
48
|
+
export async function removeInspectedUnmanagedSkills(context, inspections, expectedManagedInstallations) {
|
|
49
|
+
for (const inspection of inspections) {
|
|
50
|
+
await removeInspectedUnmanagedSkill(context, inspection, expectedManagedInstallations);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function assertUnmanagedInspectionUnchanged(expected, actual) {
|
|
54
|
+
if (expected.name !== actual.name || expected.path !== actual.path || expected.identity !== actual.identity || expected.digest !== actual.digest) {
|
|
55
|
+
throw new Error(`The inspected unmanaged Skill changed before deletion: ${JSON.stringify(expected.path)}; no path was changed`);
|
|
56
|
+
}
|
|
57
|
+
}
|