@iseoane/agent-depot 0.3.0 → 0.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.
- package/CHANGELOG.md +10 -0
- package/README.md +77 -1
- package/dist/src/app-flow.js +12 -5
- package/dist/src/cli.js +5 -0
- package/dist/src/profile-cli.js +88 -0
- package/dist/src/profile-export.js +135 -0
- package/dist/src/profile-import.js +218 -0
- package/dist/src/profile.js +130 -0
- package/dist/src/project-manifest.js +6 -0
- package/dist/src/tui/app-actions.js +3 -3
- package/dist/src/tui/app.js +10 -4
- package/dist/src/tui/profile-view.js +249 -0
- package/dist/src/tui/sources-recipes.js +216 -0
- package/dist/src/tui/sources-view.js +16 -7
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parseAppRecipe } from "./app-recipes.js";
|
|
3
|
+
import { canonicalizeGitSourceUrl } from "./git-source.js";
|
|
4
|
+
import { containsCredentialArgument, parseProjectManifest, ProjectManifestError } from "./project-manifest.js";
|
|
5
|
+
export const PROFILE_FORMAT = "agent-depot-profile/v1";
|
|
6
|
+
function object(value, keys, field) {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
8
|
+
throw new Error(`${field}: expected an object`);
|
|
9
|
+
const record = value;
|
|
10
|
+
const extra = Object.keys(record).find(key => !keys.includes(key));
|
|
11
|
+
if (extra !== undefined)
|
|
12
|
+
throw new Error(`${field}.${extra}: unsupported profile field`);
|
|
13
|
+
return record;
|
|
14
|
+
}
|
|
15
|
+
function list(value, field) {
|
|
16
|
+
if (!Array.isArray(value))
|
|
17
|
+
throw new Error(`${field}: expected an array`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
/** Recipes can contain free text; portable profiles additionally forbid machine paths and credentials. */
|
|
21
|
+
function assertPortableText(value, field) {
|
|
22
|
+
if (typeof value === "string") {
|
|
23
|
+
const commandPath = /(?:\.argv\[\d+\]|\.cwd)$/u.test(field) &&
|
|
24
|
+
(path.posix.isAbsolute(value) || path.win32.isAbsolute(value));
|
|
25
|
+
const regexPattern = /\.(?:version|latest)\.pattern$/u.test(field);
|
|
26
|
+
if (commandPath || (!regexPattern && /(?:^|[\s="'(])(?:\/(?!\/)[\w.]|[A-Za-z]:[/\\]|\\\\)/u.test(value))) {
|
|
27
|
+
throw new Error(`${field}: absolute local paths are not portable`);
|
|
28
|
+
}
|
|
29
|
+
if (/[a-z][a-z0-9+.-]*:\/\/[^\s/]*@/iu.test(value)) {
|
|
30
|
+
throw new Error(`${field}: credentials are not portable`);
|
|
31
|
+
}
|
|
32
|
+
for (const match of value.matchAll(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu)) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(match[0]);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error(`${field}: invalid URL`);
|
|
39
|
+
}
|
|
40
|
+
const keys = [...url.searchParams.keys(), ...new URLSearchParams(url.hash.slice(1)).keys()];
|
|
41
|
+
if (keys.some(key => /^(?:token|key|apikey|secret|password|passwd|auth|sig|signature|accesstoken|privatetoken)$/u.test(key.replace(/[-_]/gu, "").toLowerCase()))) {
|
|
42
|
+
throw new Error(`${field}: credentials are not portable`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Manual text is not argv: check credentials without forbidding its shell instructions.
|
|
46
|
+
if (containsCredentialArgument(value) || value.split(/\s+/u).some(containsCredentialArgument)) {
|
|
47
|
+
throw new Error(`${field}: credentials are not portable`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (Array.isArray(value)) {
|
|
51
|
+
value.forEach((item, index) => assertPortableText(item, `${field}[${index}]`));
|
|
52
|
+
}
|
|
53
|
+
else if (value && typeof value === "object") {
|
|
54
|
+
for (const [key, item] of Object.entries(value))
|
|
55
|
+
assertPortableText(item, `${field}.${key}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function rejectDuplicates(items, identity, field) {
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
items.forEach((item, index) => {
|
|
61
|
+
const key = identity(item);
|
|
62
|
+
if (seen.has(key))
|
|
63
|
+
throw new Error(`${field}[${index}]: duplicate identity`);
|
|
64
|
+
seen.add(key);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Portable profile parsing shares the manifest and recipe safety contracts. */
|
|
68
|
+
export function parseProfile(value) {
|
|
69
|
+
const root = object(value, ["format", "agentDepotVersion", "sources", "skills", "apps"], "profile");
|
|
70
|
+
if (root.format !== PROFILE_FORMAT)
|
|
71
|
+
throw new Error("profile.format: unsupported profile version");
|
|
72
|
+
if (typeof root.agentDepotVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/u.test(root.agentDepotVersion)) {
|
|
73
|
+
throw new Error("profile.agentDepotVersion: expected a package version");
|
|
74
|
+
}
|
|
75
|
+
const sources = list(root.sources, "sources").map((value, index) => {
|
|
76
|
+
const field = `sources[${index}]`;
|
|
77
|
+
const source = object(value, ["url", "included"], field);
|
|
78
|
+
if (typeof source.included !== "boolean")
|
|
79
|
+
throw new Error(`${field}.included: expected a boolean`);
|
|
80
|
+
try {
|
|
81
|
+
return { url: canonicalizeGitSourceUrl(source.url), included: source.included };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new Error(`${field}.url: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const skills = list(root.skills, "skills");
|
|
88
|
+
skills.forEach((value, index) => object(value, ["source", "path", "version", "hosts", "methods"], `skills[${index}]`));
|
|
89
|
+
let parsedSkills;
|
|
90
|
+
try {
|
|
91
|
+
parsedSkills = parseProjectManifest({ version: 1, skills }, "profile").skills;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!(error instanceof ProjectManifestError))
|
|
95
|
+
throw error;
|
|
96
|
+
const [field, ...reason] = error.reason.split(" ");
|
|
97
|
+
throw new Error(`${field}: ${reason.join(" ")}`);
|
|
98
|
+
}
|
|
99
|
+
parsedSkills.forEach((skill, index) => {
|
|
100
|
+
assertPortableText(skill.path, `skills[${index}].path`);
|
|
101
|
+
assertPortableText(skill.version, `skills[${index}].version`);
|
|
102
|
+
assertPortableText(skill.methods, `skills[${index}].methods`);
|
|
103
|
+
});
|
|
104
|
+
const apps = list(root.apps, "apps").map((value, index) => {
|
|
105
|
+
try {
|
|
106
|
+
const recipe = parseAppRecipe(value);
|
|
107
|
+
assertPortableText(recipe, `apps[${index}]`);
|
|
108
|
+
return recipe;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
112
|
+
throw new Error(reason.startsWith(`apps[${index}]`) ? reason : `apps[${index}].${reason}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
rejectDuplicates(sources, source => source.url, "sources");
|
|
116
|
+
apps.forEach((app, index) => {
|
|
117
|
+
if (apps.slice(0, index).some(previous => previous.name === app.name &&
|
|
118
|
+
(previous.platform === undefined || app.platform === undefined || previous.platform === app.platform))) {
|
|
119
|
+
throw new Error(`apps[${index}]: duplicate applicable App name ${JSON.stringify(app.name)}`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
123
|
+
sources.sort((a, b) => compare(a.url, b.url));
|
|
124
|
+
const sortedSkills = [...parsedSkills].sort((a, b) => compare(JSON.stringify([a.source, a.path]), JSON.stringify([b.source, b.path])));
|
|
125
|
+
apps.sort((a, b) => compare(a.name, b.name) || compare(a.platform ?? "", b.platform ?? ""));
|
|
126
|
+
return { format: PROFILE_FORMAT, agentDepotVersion: root.agentDepotVersion, sources, skills: sortedSkills, apps };
|
|
127
|
+
}
|
|
128
|
+
export function serializeProfile(profile) {
|
|
129
|
+
return `${JSON.stringify(parseProfile(profile), null, 2)}\n`;
|
|
130
|
+
}
|
|
@@ -16,10 +16,12 @@ const EMPTY_PROJECT_MANIFEST = Object.freeze({
|
|
|
16
16
|
});
|
|
17
17
|
export class ProjectManifestError extends Error {
|
|
18
18
|
manifestPath;
|
|
19
|
+
reason;
|
|
19
20
|
constructor(manifestPath, reason) {
|
|
20
21
|
super(`Invalid project manifest ${JSON.stringify(manifestPath)}: ${reason}`);
|
|
21
22
|
this.name = "ProjectManifestError";
|
|
22
23
|
this.manifestPath = manifestPath;
|
|
24
|
+
this.reason = reason;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
function isRecord(value) {
|
|
@@ -135,6 +137,10 @@ function isShellInterpreter(executable) {
|
|
|
135
137
|
"cmd", "cmd.exe", "command.com", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
|
|
136
138
|
]).has(normalized);
|
|
137
139
|
}
|
|
140
|
+
/** Shared conservative credential check for portable declarations, including recipe free text. */
|
|
141
|
+
export function containsCredentialArgument(value) {
|
|
142
|
+
return CREDENTIAL_FLAG.test(value) || containsObviousCredential(value);
|
|
143
|
+
}
|
|
138
144
|
function containsObviousCredential(value) {
|
|
139
145
|
return /^[A-Za-z_][A-Za-z0-9_]*=.+$/u.test(value) ||
|
|
140
146
|
/(?:^|[=:])[A-Za-z_][A-Za-z0-9_]*=.+/u.test(value) ||
|
|
@@ -11,7 +11,7 @@ export function isAppMode(mode) {
|
|
|
11
11
|
}
|
|
12
12
|
/** Recipe name, falling back to the file identity for invalid recipes. */
|
|
13
13
|
export const appName = (entry) => entry.recipe?.name ?? entry.file;
|
|
14
|
-
function
|
|
14
|
+
export function appStepLines(step) {
|
|
15
15
|
return [
|
|
16
16
|
...(step.argv ? [`argv: ${JSON.stringify(step.argv)}`, `Executable: ${step.executable ?? "unavailable"}`] : []),
|
|
17
17
|
...(step.manual ? [`Manual: ${step.manual}`] : []),
|
|
@@ -55,7 +55,7 @@ export async function startAppAction(context, entries, input) {
|
|
|
55
55
|
manage.setMode({ kind: "busy", label: "Preparing recipe approval..." });
|
|
56
56
|
const steps = await apps.preview(entry);
|
|
57
57
|
if (manage.isMounted()) {
|
|
58
|
-
manage.setMode({ kind: "app-approve", entry, lines: steps.flatMap(step => [step.step, ...
|
|
58
|
+
manage.setMode({ kind: "app-approve", entry, lines: steps.flatMap(step => [step.step, ...appStepLines(step)]) });
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
else if (input === "h") {
|
|
@@ -150,7 +150,7 @@ export function AppPanel({ mode }) {
|
|
|
150
150
|
return (_jsx(PreviewConfirm, { lines: [
|
|
151
151
|
`Environment: ${mode.plan.environment}`,
|
|
152
152
|
`App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
|
|
153
|
-
...
|
|
153
|
+
...appStepLines(mode.plan),
|
|
154
154
|
], question: mode.kind === "app-manual"
|
|
155
155
|
? "Manual step done, check now? y/n"
|
|
156
156
|
: `${mode.plan.action} ${appName(mode.plan.entry)}? y/n` }));
|
package/dist/src/tui/app.js
CHANGED
|
@@ -4,25 +4,29 @@ import { useCallback, useRef, useState } from "react";
|
|
|
4
4
|
import { AGENT_DEPOT_PACKAGE_VERSION } from "../project-manifest.js";
|
|
5
5
|
import { CatalogView } from "./catalog-view.js";
|
|
6
6
|
import { InstallationsView } from "./installations-view.js";
|
|
7
|
+
import { useKeys } from "./keys.js";
|
|
8
|
+
import { ProfileView } from "./profile-view.js";
|
|
7
9
|
import { SourcesView } from "./sources-view.js";
|
|
8
10
|
import { separatorLine, theme } from "./theme.js";
|
|
9
11
|
import { UpdatesView } from "./updates-view.js";
|
|
10
|
-
import { useKeys } from "./keys.js";
|
|
11
12
|
const TABS = [
|
|
12
13
|
["sources", "1 Sources"],
|
|
13
14
|
["catalog", "2 Catalog"],
|
|
14
15
|
["installations", "3 Installations"],
|
|
15
16
|
["updates", "4 Updates"],
|
|
17
|
+
["profile", "5 Import/Export"],
|
|
16
18
|
];
|
|
17
19
|
/** Compact keys of the current view; the tab bar already shows how to switch views. */
|
|
18
20
|
function hintsOf(view, catalogFocusedOnSource) {
|
|
19
21
|
switch (view) {
|
|
20
22
|
case "sources":
|
|
21
|
-
return "j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
|
|
23
|
+
return "Tab section · j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
|
|
22
24
|
case "catalog":
|
|
23
25
|
return `j/k move · Enter expand · space mark · a all · i install · / filter${catalogFocusedOnSource ? " · s all sources" : ""} · q quit`;
|
|
24
26
|
case "installations":
|
|
25
27
|
return "j/k move · Enter expand · space mark · a all · u uninstall · h add hosts · A adopt · q quit";
|
|
28
|
+
case "profile":
|
|
29
|
+
return "e export · i import · q quit";
|
|
26
30
|
case "updates":
|
|
27
31
|
return "j/k move · space mark · a all · Enter preview · r check again · q quit";
|
|
28
32
|
}
|
|
@@ -55,9 +59,11 @@ export function App({ operations, environment, onExit }) {
|
|
|
55
59
|
showView("installations");
|
|
56
60
|
else if (input === "4")
|
|
57
61
|
showView("updates");
|
|
62
|
+
else if (input === "5")
|
|
63
|
+
showView("profile");
|
|
58
64
|
});
|
|
59
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
|
|
65
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
|
|
60
66
|
setCatalogSourceId(source.id);
|
|
61
67
|
setView("catalog");
|
|
62
|
-
} })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
|
|
68
|
+
} })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
|
|
63
69
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { createAppOperations } from "../app-flow.js";
|
|
6
|
+
import { buildProfileExport, profileExportPreview, writeProfileExport, } from "../profile-export.js";
|
|
7
|
+
import { applyProfileImport, buildProfileImport, } from "../profile-import.js";
|
|
8
|
+
import { PROFILE_FORMAT } from "../profile.js";
|
|
9
|
+
import { useKeys } from "./keys.js";
|
|
10
|
+
import { answerYesNo, moveCursor, toggleChoice } from "./mode-keys.js";
|
|
11
|
+
import { ChecklistLines, PreviewConfirm, PreviewLines } from "./panel-parts.js";
|
|
12
|
+
import { computeWindow, useListHeight } from "./window.js";
|
|
13
|
+
function rowsFromExport(plan) {
|
|
14
|
+
return [
|
|
15
|
+
...plan.profile.sources.map((value, index) => ({
|
|
16
|
+
block: "sources", index, label: `Sources: ${value.url}`, selectable: true,
|
|
17
|
+
})),
|
|
18
|
+
...plan.profile.skills.map((value, index) => ({
|
|
19
|
+
block: "skills", index, label: `Skills: ${value.path}`, selectable: true,
|
|
20
|
+
})),
|
|
21
|
+
...plan.profile.apps.map((value, index) => ({
|
|
22
|
+
block: "apps", index, label: `Apps: ${value.name} (${value.platform ?? "all platforms"})`, selectable: true,
|
|
23
|
+
})),
|
|
24
|
+
...plan.exclusions.map((item, index) => ({
|
|
25
|
+
block: "excluded", index, label: `Excluded ${item.label}: ${item.reason}`, selectable: false,
|
|
26
|
+
})),
|
|
27
|
+
...plan.warnings.filter(warning => warning.kind === "load").map((warning, index) => ({
|
|
28
|
+
block: "excluded", index: plan.exclusions.length + index, label: warning.message, selectable: false,
|
|
29
|
+
})),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
function rowsFromPlan(plan) {
|
|
33
|
+
return plan.items.map((item, index) => ({
|
|
34
|
+
block: item.block, index,
|
|
35
|
+
label: `${item.status}: ${item.label}${item.difference ? `; ${item.difference}` : ""}`,
|
|
36
|
+
selectable: item.status === "add",
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
function selectedPlanItems(plan, selected) {
|
|
40
|
+
return plan.items.filter((item, index) => item.status !== "add" || selected.some(row => row.index === index));
|
|
41
|
+
}
|
|
42
|
+
/** Collect choices, display shared-core previews and confirm portable Profile transfers. */
|
|
43
|
+
export function ProfileView({ operations, environment = {}, onCapturingChange, listHeight }) {
|
|
44
|
+
const apps = useMemo(() => createAppOperations({
|
|
45
|
+
homeDirectory: environment.homeDirectory, ...environment.appEnvironment,
|
|
46
|
+
}), [environment]);
|
|
47
|
+
const [mode, setModeState] = useState("browse");
|
|
48
|
+
const modeRef = useRef("browse");
|
|
49
|
+
const mounted = useRef(true);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
mounted.current = true;
|
|
52
|
+
return () => { mounted.current = false; };
|
|
53
|
+
}, []);
|
|
54
|
+
const setMode = (next) => {
|
|
55
|
+
modeRef.current = next;
|
|
56
|
+
onCapturingChange?.(next !== "browse" && next !== "results");
|
|
57
|
+
setModeState(next);
|
|
58
|
+
};
|
|
59
|
+
const [rows, setRows] = useState([]);
|
|
60
|
+
const [selected, setSelectedState] = useState([]);
|
|
61
|
+
const selectedRef = useRef([]);
|
|
62
|
+
const select = (next) => {
|
|
63
|
+
selectedRef.current = next;
|
|
64
|
+
setSelectedState(next);
|
|
65
|
+
};
|
|
66
|
+
const [cursor, setCursorState] = useState(0);
|
|
67
|
+
const cursorRef = useRef(0);
|
|
68
|
+
const cursorTo = (next) => {
|
|
69
|
+
cursorRef.current = next;
|
|
70
|
+
setCursorState(next);
|
|
71
|
+
};
|
|
72
|
+
const [text, setText] = useState("");
|
|
73
|
+
const typed = useRef("");
|
|
74
|
+
const [lines, setLines] = useState([]);
|
|
75
|
+
const [profile, setProfile] = useState();
|
|
76
|
+
const [plan, setPlan] = useState();
|
|
77
|
+
const diagnostics = useRef([]);
|
|
78
|
+
const action = useRef("export");
|
|
79
|
+
const profileVersion = useRef("");
|
|
80
|
+
const height = useListHeight(listHeight, 9);
|
|
81
|
+
const showLines = (next, nextMode) => {
|
|
82
|
+
setLines(next);
|
|
83
|
+
cursorTo(0);
|
|
84
|
+
setMode(nextMode);
|
|
85
|
+
};
|
|
86
|
+
const run = async (work) => {
|
|
87
|
+
setMode("busy");
|
|
88
|
+
try {
|
|
89
|
+
await work();
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (mounted.current) {
|
|
93
|
+
showLines([`Error: ${error instanceof Error ? error.message : String(error)}`], "results");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const prompt = (next) => {
|
|
98
|
+
typed.current = "";
|
|
99
|
+
setText("");
|
|
100
|
+
setMode(next);
|
|
101
|
+
};
|
|
102
|
+
const loadRows = (next, nextMode) => {
|
|
103
|
+
setRows(next);
|
|
104
|
+
select(next.filter(row => row.selectable));
|
|
105
|
+
cursorTo(0);
|
|
106
|
+
setMode(nextMode);
|
|
107
|
+
};
|
|
108
|
+
const chosenProfile = () => {
|
|
109
|
+
const chosen = selectedRef.current;
|
|
110
|
+
const isChosen = (block, index) => chosen.some(row => row.block === block && row.index === index);
|
|
111
|
+
return {
|
|
112
|
+
...profile,
|
|
113
|
+
sources: profile.sources.filter((_, index) => isChosen("sources", index)),
|
|
114
|
+
skills: profile.skills.filter((_, index) => isChosen("skills", index)),
|
|
115
|
+
apps: profile.apps.filter((_, index) => isChosen("apps", index)),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
useKeys((input, key) => {
|
|
119
|
+
const current = modeRef.current;
|
|
120
|
+
if (current === "busy")
|
|
121
|
+
return;
|
|
122
|
+
if (current === "browse" || current === "results") {
|
|
123
|
+
if (input === "e") {
|
|
124
|
+
action.current = "export";
|
|
125
|
+
void run(async () => {
|
|
126
|
+
const exported = await buildProfileExport(operations, apps, { homeDirectory: environment.homeDirectory });
|
|
127
|
+
if (!mounted.current)
|
|
128
|
+
return;
|
|
129
|
+
setProfile(exported.profile);
|
|
130
|
+
diagnostics.current = [...exported.exclusions, ...exported.warnings];
|
|
131
|
+
loadRows(rowsFromExport(exported), "export-list");
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else if (input === "i") {
|
|
135
|
+
action.current = "import";
|
|
136
|
+
prompt("import-path");
|
|
137
|
+
}
|
|
138
|
+
else if (current === "results") {
|
|
139
|
+
const next = moveCursor(cursorRef.current, lines.length, input, key);
|
|
140
|
+
if (next !== undefined)
|
|
141
|
+
cursorTo(Math.max(0, next));
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (current === "export-path" || current === "import-path") {
|
|
146
|
+
if (key.escape) {
|
|
147
|
+
setMode("browse");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (key.return) {
|
|
151
|
+
if (!typed.current.trim())
|
|
152
|
+
return;
|
|
153
|
+
if (current === "export-path") {
|
|
154
|
+
showLines(profileExportPreview(chosenProfile(), diagnostics.current), "confirm");
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
void run(async () => {
|
|
158
|
+
const raw = JSON.parse(await readFile(typed.current, "utf8"));
|
|
159
|
+
const imported = await buildProfileImport(raw, operations, apps);
|
|
160
|
+
profileVersion.current = raw.agentDepotVersion;
|
|
161
|
+
if (!mounted.current)
|
|
162
|
+
return;
|
|
163
|
+
setPlan(imported);
|
|
164
|
+
loadRows(rowsFromPlan(imported), "import-list");
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else if (key.backspace || key.delete) {
|
|
168
|
+
typed.current = typed.current.slice(0, -1);
|
|
169
|
+
setText(typed.current);
|
|
170
|
+
}
|
|
171
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
172
|
+
typed.current += input;
|
|
173
|
+
setText(typed.current);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (current === "confirm") {
|
|
178
|
+
answerYesNo(input, key, () => {
|
|
179
|
+
void run(async () => {
|
|
180
|
+
let result;
|
|
181
|
+
if (action.current === "export") {
|
|
182
|
+
await writeProfileExport(typed.current, chosenProfile());
|
|
183
|
+
result = [`Exported profile to ${typed.current}`];
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const confirmed = { ...plan, items: selectedPlanItems(plan, selectedRef.current) };
|
|
187
|
+
const outcomes = await applyProfileImport(confirmed, operations, apps, true, () => undefined, { homeDirectory: environment.homeDirectory, ...environment.installationOptions });
|
|
188
|
+
result = outcomes.map(outcome => `${outcome.status}: ${outcome.item.label}${outcome.detail ? `; ${outcome.detail}` : ""}`);
|
|
189
|
+
}
|
|
190
|
+
if (mounted.current) {
|
|
191
|
+
showLines(result.length ? result : ["Nothing selected; nothing written."], "results");
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}, () => setMode("browse"));
|
|
195
|
+
if (input === "y" || input === "n" || key.escape)
|
|
196
|
+
return;
|
|
197
|
+
const next = moveCursor(cursorRef.current, lines.length, input, key);
|
|
198
|
+
if (next !== undefined)
|
|
199
|
+
cursorTo(Math.max(0, next));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (key.escape) {
|
|
203
|
+
setMode("browse");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Export diagnostics follow the checklist, but never own its cursor.
|
|
207
|
+
// Import same/conflict rows still draw a cursor so their differences can be reviewed.
|
|
208
|
+
const checklistLength = rows.filter(row => row.block !== "excluded").length;
|
|
209
|
+
const next = moveCursor(cursorRef.current, checklistLength, input, key);
|
|
210
|
+
if (next !== undefined)
|
|
211
|
+
cursorTo(Math.max(0, next));
|
|
212
|
+
else if (input === " " && rows[cursorRef.current]?.selectable) {
|
|
213
|
+
select(toggleChoice(rows, selectedRef.current, rows[cursorRef.current]));
|
|
214
|
+
}
|
|
215
|
+
else if (input === "a")
|
|
216
|
+
select(selectedRef.current.length ? [] : rows.filter(row => row.selectable));
|
|
217
|
+
else if (key.return) {
|
|
218
|
+
if (current === "export-list") {
|
|
219
|
+
if (!selectedRef.current.length) {
|
|
220
|
+
showLines(["Nothing selected; nothing written."], "results");
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
prompt("export-path");
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
void run(async () => {
|
|
227
|
+
// Reuse the CLI's core preview for the exact selected subset, including argv and Source notes.
|
|
228
|
+
const items = selectedPlanItems(plan, selectedRef.current);
|
|
229
|
+
const preview = await buildProfileImport({
|
|
230
|
+
format: PROFILE_FORMAT, agentDepotVersion: profileVersion.current,
|
|
231
|
+
sources: items.filter(item => item.block === "sources").map(item => item.value),
|
|
232
|
+
skills: items.filter(item => item.block === "skills").map(item => item.value),
|
|
233
|
+
apps: items.filter(item => item.block === "apps").map(item => item.value),
|
|
234
|
+
}, operations, apps);
|
|
235
|
+
if (mounted.current)
|
|
236
|
+
showLines(preview.preview, "confirm");
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const listing = mode === "export-list" || mode === "import-list";
|
|
241
|
+
const window = computeWindow(listing ? rows.length : lines.length, cursor, height);
|
|
242
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Portable user-global Profile \u00B7 e export \u00B7 i import" }), mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode.endsWith("-path") ? _jsxs(Text, { children: ["Profile path: ", text, "_"] }) : null, listing ? ["sources", "skills", "apps", "excluded"].map(block => {
|
|
243
|
+
const choices = rows.slice(window.start, window.end).filter(row => row.block === block);
|
|
244
|
+
if (!choices.length)
|
|
245
|
+
return null;
|
|
246
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: block === "excluded" ? "Excluded" : block[0].toUpperCase() + block.slice(1) }), block === "excluded" ? _jsx(PreviewLines, { lines: choices.map(row => `${row.label} (not selectable)`) }) :
|
|
247
|
+
_jsx(ChecklistLines, { choices: choices, cursor: choices.indexOf(rows[cursor]), isSelected: row => selected.includes(row), keyOf: row => `${row.block}:${row.index}`, label: row => `${row.label}${row.selectable ? "" : " (not selectable)"}` })] }, block);
|
|
248
|
+
}) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines.slice(window.start, window.end), question: "Apply Profile? (y/n) \u00B7 j/k scroll" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && (listing || mode === "confirm" || mode === "results") ? _jsx(Text, { children: window.indicator }) : null, listing ? _jsx(Text, { children: "j/k move \u00B7 space toggle \u00B7 a all \u00B7 Enter preview \u00B7 Esc cancel" }) : null] });
|
|
249
|
+
}
|