@iseoane/agent-depot 0.5.2 → 0.6.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 +47 -0
- package/README.es.md +137 -34
- package/README.md +120 -29
- package/dist/src/app-flow.js +56 -24
- package/dist/src/profile-export.js +72 -9
- package/dist/src/profile-import.js +3 -2
- package/dist/src/profile.js +71 -7
- package/dist/src/tui/app-actions.js +82 -33
- package/dist/src/tui/app.js +19 -14
- package/dist/src/tui/catalog-browse.js +6 -1
- package/dist/src/tui/catalog-panel.js +11 -11
- package/dist/src/tui/catalog-state.js +28 -0
- package/dist/src/tui/catalog-tree.js +22 -5
- package/dist/src/tui/catalog-view.js +59 -16
- package/dist/src/tui/file-picker-model.js +87 -0
- package/dist/src/tui/file-picker.js +142 -0
- package/dist/src/tui/header-layout.js +33 -0
- package/dist/src/tui/installations-panel.js +3 -3
- package/dist/src/tui/installations-rows.js +20 -5
- package/dist/src/tui/installations-tree.js +4 -7
- package/dist/src/tui/installations-view.js +15 -6
- package/dist/src/tui/installations.js +9 -7
- package/dist/src/tui/manage-panel.js +7 -6
- package/dist/src/tui/mode-keys.js +9 -0
- package/dist/src/tui/package-rows.js +17 -5
- package/dist/src/tui/panel-parts.js +119 -6
- package/dist/src/tui/preview-window.js +51 -0
- package/dist/src/tui/profile-view.js +394 -136
- package/dist/src/tui/sources-mode.js +3 -1
- package/dist/src/tui/sources-recipes.js +50 -19
- package/dist/src/tui/sources-view.js +50 -14
- package/dist/src/tui/text-wrap.js +53 -0
- package/dist/src/tui/theme.js +14 -7
- package/dist/src/tui/updates-flow.js +2 -2
- package/dist/src/tui/updates-keys.js +34 -5
- package/dist/src/tui/updates-model.js +2 -0
- package/dist/src/tui/updates-panel.js +40 -15
- package/dist/src/tui/updates-preview.js +65 -0
- package/dist/src/tui/updates-view.js +17 -2
- package/dist/src/tui/updates.js +14 -5
- package/dist/src/tui/window.js +8 -6
- package/package.json +6 -6
- package/skills/doctor-md-agents/README.md +3 -1
- package/skills/doctor-md-skill/README.md +2 -2
package/dist/src/app-flow.js
CHANGED
|
@@ -29,18 +29,23 @@ export function createAppOperations(environment = {}) {
|
|
|
29
29
|
});
|
|
30
30
|
const receiptPath = (entry) => path.join(receipts, createHash("sha256").update(entry.canonicalFile).digest("hex") + ".json");
|
|
31
31
|
async function read(file) {
|
|
32
|
+
let canonicalFile;
|
|
33
|
+
let content;
|
|
34
|
+
try {
|
|
35
|
+
canonicalFile = await realpath(file);
|
|
36
|
+
content = await readFile(canonicalFile);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return { file, applicable: true, error: error instanceof Error ? error.message : String(error) };
|
|
40
|
+
}
|
|
41
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
32
42
|
try {
|
|
33
|
-
const canonicalFile = await realpath(file);
|
|
34
|
-
const content = await readFile(canonicalFile);
|
|
35
43
|
const recipe = parseAppRecipe(JSON.parse(content.toString("utf8")));
|
|
36
44
|
const currentPlatform = platform === "win32" ? "windows" : platform;
|
|
37
|
-
return {
|
|
38
|
-
file, canonicalFile, recipe, hash: createHash("sha256").update(content).digest("hex"),
|
|
39
|
-
applicable: recipe.platform === undefined || recipe.platform === currentPlatform,
|
|
40
|
-
};
|
|
45
|
+
return { file, canonicalFile, recipe, hash, applicable: recipe.platform === undefined || recipe.platform === currentPlatform };
|
|
41
46
|
}
|
|
42
47
|
catch (error) {
|
|
43
|
-
return { file, applicable: true, error: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
return { file, canonicalFile, hash, applicable: true, error: error instanceof Error ? error.message : String(error) };
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
51
|
function rejectDuplicates(entries) {
|
|
@@ -82,14 +87,21 @@ export function createAppOperations(environment = {}) {
|
|
|
82
87
|
...Object.entries({
|
|
83
88
|
install: recipe.install, update: recipe.update, uninstall: recipe.uninstall, version: recipe.version,
|
|
84
89
|
}),
|
|
85
|
-
...(recipe.latest
|
|
90
|
+
...(recipe.latest ? [["latest", recipe.latest]] : []),
|
|
86
91
|
...Object.entries(recipe.setup ?? {}).map(([host, step]) => [`setup.${host}`, step]),
|
|
87
92
|
...Object.entries(recipe.teardown ?? {}).map(([host, step]) => [`teardown.${host}`, step]),
|
|
88
93
|
];
|
|
89
94
|
return Promise.all(steps.map(async ([step, value]) => {
|
|
90
|
-
const resolution = value.argv ? await resolve(value.argv[0]) : undefined;
|
|
95
|
+
const resolution = "argv" in value && value.argv ? await resolve(value.argv[0]) : undefined;
|
|
91
96
|
return {
|
|
92
|
-
step,
|
|
97
|
+
step,
|
|
98
|
+
...("argv" in value && value.argv ? { argv: value.argv } : {}),
|
|
99
|
+
...("manual" in value && value.manual ? { manual: value.manual } : {}),
|
|
100
|
+
...("npm" in value ? { declaration: `Latest npm package: ${value.npm}` } : {}),
|
|
101
|
+
...("github" in value ? { declaration: `Latest GitHub repository: ${value.github}` } : {}),
|
|
102
|
+
...("pattern" in value ? { pattern: value.pattern } : {}),
|
|
103
|
+
executable: resolution?.executable, cwd: home,
|
|
104
|
+
environment: isWsl ? "linux (WSL)" : platform,
|
|
93
105
|
warning: resolution?.blocked ? WINDOWS_EXECUTABLE_WARNING : undefined,
|
|
94
106
|
};
|
|
95
107
|
}));
|
|
@@ -138,39 +150,59 @@ export function createAppOperations(environment = {}) {
|
|
|
138
150
|
return { status: approval.status, reason: approval.reason };
|
|
139
151
|
}
|
|
140
152
|
/** A supplied cache checks recipe approval without running version; omit it for a live check. */
|
|
141
|
-
async function
|
|
153
|
+
async function inspectEntry(entry, cached, reconcileTracking) {
|
|
142
154
|
if (entry.error || !entry.recipe)
|
|
143
155
|
return { status: "invalid", reason: entry.error };
|
|
144
156
|
if (!entry.applicable)
|
|
145
157
|
return { status: "not applicable here" };
|
|
158
|
+
if (cached?.installedVersion) {
|
|
159
|
+
return { status: "installed", installedVersion: cached.installedVersion, evidence: "recorded" };
|
|
160
|
+
}
|
|
146
161
|
const approval = await approvalProblem(entry);
|
|
147
162
|
if (approval)
|
|
148
163
|
return approval;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return { status: "not installed", executable, reason: blocked ? WINDOWS_EXECUTABLE_WARNING : undefined };
|
|
164
|
+
let resolution;
|
|
165
|
+
try {
|
|
166
|
+
resolution = await resolve(entry.recipe.version.argv[0]);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return { status: "unknown", reason: error instanceof Error ? error.message : String(error) };
|
|
156
170
|
}
|
|
171
|
+
const { executable, blocked } = resolution;
|
|
172
|
+
if (blocked)
|
|
173
|
+
return { status: "unknown", executable, reason: WINDOWS_EXECUTABLE_WARNING };
|
|
174
|
+
if (!executable)
|
|
175
|
+
return { status: "not installed", reason: "Version executable is unavailable" };
|
|
157
176
|
let installedVersion;
|
|
177
|
+
let result;
|
|
158
178
|
try {
|
|
159
|
-
|
|
179
|
+
result = await runner(executable, entry.recipe.version.argv.slice(1), {
|
|
160
180
|
cwd: home, captureStdout: true, maxOutputBytes: APP_OUTPUT_LIMIT, maxStderrBytes: APP_OUTPUT_LIMIT,
|
|
161
181
|
});
|
|
162
182
|
installedVersion = result.code === 0 && !result.signal && !result.outputTooLarge
|
|
163
183
|
? new RegExp(entry.recipe.version.pattern).exec(result.stdout.toString("utf8"))?.[1]
|
|
164
184
|
: undefined;
|
|
165
185
|
}
|
|
166
|
-
catch {
|
|
167
|
-
return { status: "
|
|
186
|
+
catch (error) {
|
|
187
|
+
return { status: "unknown", executable, reason: error instanceof Error ? error.message : String(error) };
|
|
168
188
|
}
|
|
169
|
-
if (
|
|
189
|
+
if (result.signal)
|
|
190
|
+
return { status: "unknown", executable, reason: `Version command terminated by signal ${result.signal}` };
|
|
191
|
+
if (result.code !== 0)
|
|
170
192
|
return { status: "not installed", executable };
|
|
171
|
-
|
|
193
|
+
if (!installedVersion || result.outputTooLarge) {
|
|
194
|
+
return { status: "unknown", executable, reason: result.outputTooLarge ? "Version output exceeded the limit" : "Version output did not match the recipe" };
|
|
195
|
+
}
|
|
196
|
+
if (reconcileTracking)
|
|
197
|
+
await reconcileTracked(entry, installedVersion);
|
|
172
198
|
return { status: "installed", executable, installedVersion, evidence: "verified" };
|
|
173
199
|
}
|
|
200
|
+
async function inspect(entry, cached) {
|
|
201
|
+
return await inspectEntry(entry, cached, true);
|
|
202
|
+
}
|
|
203
|
+
async function inspectForExport(entry) {
|
|
204
|
+
return await inspectEntry(entry, undefined, false);
|
|
205
|
+
}
|
|
174
206
|
async function checkUpdate(entry) {
|
|
175
207
|
const inspection = await inspect(entry);
|
|
176
208
|
let latestVersion;
|
|
@@ -448,7 +480,7 @@ export function createAppOperations(environment = {}) {
|
|
|
448
480
|
throw error;
|
|
449
481
|
});
|
|
450
482
|
}
|
|
451
|
-
return { directory, approvalStatus, checkUpdate, load, preview, approve, inspect, planLifecycle, executeLifecycle, completeManual, cancelManual, trackedApps, previewForget, forgetApp };
|
|
483
|
+
return { directory, approvalStatus, checkUpdate, load, preview, approve, inspect, inspectForExport, planLifecycle, executeLifecycle, completeManual, cancelManual, trackedApps, previewForget, forgetApp };
|
|
452
484
|
}
|
|
453
485
|
/** Concurrent independent checks; one broken App must not hide the rest of a batch. */
|
|
454
486
|
export async function checkAppUpdates(apps) {
|
|
@@ -4,9 +4,10 @@ import { loadAppOwnedSkillFilter } from "./app-owned-skills.js";
|
|
|
4
4
|
import { sourceIdForUrl } from "./git-source.js";
|
|
5
5
|
import { bundleRoot, describeCoordinates } from "./package-model.js";
|
|
6
6
|
import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
|
|
7
|
-
import { parseProfile, PROFILE_FORMAT, serializeProfile } from "./profile.js";
|
|
7
|
+
import { parseProfile, PROFILE_FORMAT, PROFILE_FORMAT_V2, serializeProfile } from "./profile.js";
|
|
8
8
|
import { formatVersionPolicy } from "./skill-install.js";
|
|
9
9
|
import { scanUserGlobalSkillInventory } from "./user-global-skill-inventory.js";
|
|
10
|
+
export const SKILL_EXPORT_EXPLANATION = "Selected Skills are exported with their version policy; destination install paths and Host links are recreated. Local paths and symlink targets do not travel.";
|
|
10
11
|
export function selectProfileItems(items, requested, disabled, names, block) {
|
|
11
12
|
if (disabled && requested?.length)
|
|
12
13
|
throw new Error(`${block}: filters cannot be combined with --no-${block}`);
|
|
@@ -32,7 +33,24 @@ export function packageFilterNames(selection) {
|
|
|
32
33
|
}
|
|
33
34
|
/** One Package line, shared by the export preview and the import plan label. */
|
|
34
35
|
export function describeProfilePackage(selection) {
|
|
35
|
-
return `Package ${describeCoordinates(selection)} (${formatVersionPolicy(selection.version)})`;
|
|
36
|
+
return `Package ${describeCoordinates(selection)} from ${profileSourceIdentity(selection.source)} (${formatVersionPolicy(selection.version)}; saved selection)`;
|
|
37
|
+
}
|
|
38
|
+
export function describeProfileSkill(skill) {
|
|
39
|
+
return `Skill ${skill.path} from ${profileSourceIdentity(skill.source)} (${skill.version.policy})`;
|
|
40
|
+
}
|
|
41
|
+
export function describeProfileReplica(replica) {
|
|
42
|
+
switch (replica.kind) {
|
|
43
|
+
case "skill": return `Installed Skill ${replica.path} from ${profileSourceIdentity(replica.source)}`;
|
|
44
|
+
case "app": return `Installed App ${replica.name} (${replica.platform ?? "all platforms"})`;
|
|
45
|
+
case "package": return `Installed Package ${describeCoordinates(replica.selection)} from ${profileSourceIdentity(replica.selection.source)}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function profileSourceIdentity(source) {
|
|
49
|
+
if (source.kind === "builtin")
|
|
50
|
+
return "Agent Depot (built-in Source)";
|
|
51
|
+
if ("url" in source)
|
|
52
|
+
return source.url;
|
|
53
|
+
return source.path;
|
|
36
54
|
}
|
|
37
55
|
function validateOne(block, item) {
|
|
38
56
|
const profile = parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
@@ -77,6 +95,7 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
77
95
|
}
|
|
78
96
|
}
|
|
79
97
|
const skills = [];
|
|
98
|
+
const replicas = [];
|
|
80
99
|
for (const skill of installations) {
|
|
81
100
|
if (owned(path.posix.basename(skill.installation?.path ?? skill.path))) {
|
|
82
101
|
exclude("skills", `Skill ${skill.path}`, "App-owned");
|
|
@@ -86,19 +105,44 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
86
105
|
const selection = { source, path: skillPath, version, hosts, ...(methods ? { methods } : {}) };
|
|
87
106
|
try {
|
|
88
107
|
skills.push(validateOne("skills", selection));
|
|
89
|
-
|
|
90
|
-
exclude("skills", `installation location and version evidence for Skill ${skill.path}`, "machine-specific");
|
|
108
|
+
replicas.push({ kind: "skill", source: skill.source, path: skill.path });
|
|
91
109
|
}
|
|
92
110
|
catch (error) {
|
|
93
111
|
exclude("skills", `Skill ${skill.path}`, `not portable (${error instanceof Error ? error.message : String(error)})`);
|
|
94
112
|
}
|
|
95
113
|
}
|
|
96
114
|
const recipes = [];
|
|
115
|
+
let appInstallations = [];
|
|
116
|
+
try {
|
|
117
|
+
appInstallations = await apps.trackedApps(warn);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
warn(`App installation records could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
|
+
}
|
|
97
122
|
for (const entry of entries) {
|
|
98
123
|
try {
|
|
99
124
|
if (entry.error)
|
|
100
125
|
throw new Error(entry.error);
|
|
101
126
|
recipes.push(validateOne("apps", entry.recipe));
|
|
127
|
+
const tracked = entry.recipe && entry.canonicalFile && appInstallations.find(record => record.name === entry.recipe.name && path.resolve(record.recipeFile) === path.resolve(entry.canonicalFile));
|
|
128
|
+
let inspection;
|
|
129
|
+
try {
|
|
130
|
+
inspection = await apps.inspectForExport(entry);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
exclude("replicas", `App ${entry.recipe.name} (${entry.recipe.platform ?? "all platforms"})`, `current installation state could not be read, so it is not marked for replication (${error instanceof Error ? error.message : String(error)})`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (inspection.status === "installed" && entry.recipe) {
|
|
137
|
+
replicas.push({ kind: "app", name: entry.recipe.name,
|
|
138
|
+
...(entry.recipe.platform === undefined ? {} : { platform: entry.recipe.platform }) });
|
|
139
|
+
}
|
|
140
|
+
else if (tracked && inspection.status !== "not installed") {
|
|
141
|
+
exclude("replicas", `App ${entry.recipe.name} (${entry.recipe.platform ?? "all platforms"})`, `a tracked install record exists, but current installation state is ${inspection.status}${inspection.reason ? ` (${inspection.reason})` : ""}; not marked for replication`);
|
|
142
|
+
}
|
|
143
|
+
else if (tracked && inspection.status === "not installed") {
|
|
144
|
+
exclude("replicas", `App ${entry.recipe.name} (${entry.recipe.platform ?? "all platforms"})`, "the current version probe reports not installed; stale tracking does not create replica intent");
|
|
145
|
+
}
|
|
102
146
|
}
|
|
103
147
|
catch (error) {
|
|
104
148
|
exclude("apps", `App recipe ${path.basename(entry.file)}`, error instanceof Error ? error.message : String(error));
|
|
@@ -108,6 +152,17 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
108
152
|
for (const record of selectedPackages) {
|
|
109
153
|
try {
|
|
110
154
|
packages.push(validateOne("packages", record.selection));
|
|
155
|
+
try {
|
|
156
|
+
const inspection = await options.packageOperations?.inspect(record.selection);
|
|
157
|
+
if (inspection?.installed.kind === "installed")
|
|
158
|
+
replicas.push({ kind: "package", selection: record.selection });
|
|
159
|
+
else if (inspection?.installed.kind === "unknown") {
|
|
160
|
+
exclude("replicas", describeProfilePackage(record.selection), `host installation state is unknown, so it is not marked for replication (${inspection.installed.reason})`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
exclude("replicas", describeProfilePackage(record.selection), `host installation state could not be read, so it is not marked for replication (${error instanceof Error ? error.message : String(error)})`);
|
|
165
|
+
}
|
|
111
166
|
}
|
|
112
167
|
catch (error) {
|
|
113
168
|
exclude("packages", describeProfilePackage(record.selection), `not portable (${error instanceof Error ? error.message : String(error)})`);
|
|
@@ -122,8 +177,8 @@ export async function buildProfileExport(operations, apps, options = {}) {
|
|
|
122
177
|
exclude("skills", `unmanaged Skill ${name}`, "not tracked");
|
|
123
178
|
}
|
|
124
179
|
}
|
|
125
|
-
const profile = parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
126
|
-
sources: portableSources, skills, apps: recipes, packages });
|
|
180
|
+
const profile = parseProfile({ format: replicas.length ? PROFILE_FORMAT_V2 : PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
181
|
+
sources: portableSources, skills, apps: recipes, packages, ...(replicas.length ? { replicas } : {}) });
|
|
127
182
|
return {
|
|
128
183
|
profile,
|
|
129
184
|
exclusions: diagnostics.filter((item) => "block" in item),
|
|
@@ -140,14 +195,22 @@ function sourceWarnings(profile) {
|
|
|
140
195
|
...profile.packages.flatMap(selection => missing(selection.source, describeProfilePackage(selection))),
|
|
141
196
|
];
|
|
142
197
|
}
|
|
198
|
+
/** Describes whether an export diagnostic removes a selection or only omits machine-local metadata. */
|
|
199
|
+
export function describeProfileExportExclusion(item) {
|
|
200
|
+
if (item.reason.startsWith("always present"))
|
|
201
|
+
return `Not transferred because it is always present: ${item.label}`;
|
|
202
|
+
return `Excluded ${item.label}: ${item.reason}`;
|
|
203
|
+
}
|
|
143
204
|
/** Shared preview for a frontend-selected subset; Source warnings are recomputed. */
|
|
144
205
|
export function profileExportPreview(profile, diagnostics = []) {
|
|
145
206
|
return ["Preview: export portable user-global profile",
|
|
207
|
+
SKILL_EXPORT_EXPLANATION,
|
|
146
208
|
...profile.sources.map(source => `Source ${sourceIdForUrl(source.url)} ${source.url} (included: ${source.included})`),
|
|
147
209
|
...profile.skills.map(skill => `Skill ${skill.path} [${skill.hosts.join(", ")}] (${skill.version.policy})`),
|
|
148
|
-
...profile.apps.map(recipe => `App recipe ${recipe.name} (${recipe.platform ?? "all platforms"};
|
|
149
|
-
...profile.packages.map(selection => `${describeProfilePackage(selection)}; no receipt
|
|
150
|
-
...
|
|
210
|
+
...profile.apps.map(recipe => `App recipe ${recipe.name} (${recipe.platform ?? "all platforms"}; approval not transferred)`),
|
|
211
|
+
...profile.packages.map(selection => `${describeProfilePackage(selection)}; no receipt or installed evidence`),
|
|
212
|
+
...profile.replicas.map(replica => `Replica intent ${describeProfileReplica(replica)}; no installed state or approval is transferred`),
|
|
213
|
+
...diagnostics.flatMap(item => "block" in item ? [describeProfileExportExclusion(item)] : item.kind === "load" ? [item.message] : []),
|
|
151
214
|
...sourceWarnings(profile).map(warning => warning.message)];
|
|
152
215
|
}
|
|
153
216
|
/** Empty profiles are valid to parse, but are not useful export artifacts. */
|
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { compareAppVersions } from "./app-version.js";
|
|
6
6
|
import { canonicalizeGitSourceUrl, sourceIdForUrl } from "./git-source.js";
|
|
7
7
|
import { selectionKey } from "./package-model.js";
|
|
8
|
-
import { describeProfilePackage, packageFilterNames, selectProfileItems } from "./profile-export.js";
|
|
8
|
+
import { describeProfilePackage, describeProfileReplica, packageFilterNames, selectProfileItems } from "./profile-export.js";
|
|
9
9
|
import { parseProfile, PROFILE_FORMAT } from "./profile.js";
|
|
10
10
|
import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
|
|
11
11
|
import { defaultProjectSkillTreeAccess, executeSingleInstall, formatVersionPolicy, outputSingleInstallPreview, planSingleInstall } from "./skill-install.js";
|
|
@@ -175,7 +175,8 @@ export async function buildProfileImport(input, operations, apps, filters = {},
|
|
|
175
175
|
if (AGENT_DEPOT_PACKAGE_VERSION && (compareAppVersions(profile.agentDepotVersion, AGENT_DEPOT_PACKAGE_VERSION) ?? 0) > 0) {
|
|
176
176
|
preview.push(`WARNING: profile Agent Depot ${profile.agentDepotVersion} is newer than running ${AGENT_DEPOT_PACKAGE_VERSION}`);
|
|
177
177
|
}
|
|
178
|
-
preview.push(
|
|
178
|
+
preview.push(...profile.replicas.map(replica => `Origin replica intent: ${describeProfileReplica(replica)}; importing this intent does not approve or install it`));
|
|
179
|
+
preview.push("Existing choices are never replaced. Source inclusion is a transient discovery choice.", "Replica intent never triggers approval or installation. Apps remain unapproved; Package selection does not install.", "Same-name App recipes conflict only when platforms overlap; disjoint platform recipes can both be added.");
|
|
179
180
|
return { items, packages, preview };
|
|
180
181
|
}
|
|
181
182
|
/** Publish complete JSON exclusively: rename would silently overwrite an existing recipe. */
|
package/dist/src/profile.js
CHANGED
|
@@ -3,7 +3,9 @@ import { parseAppRecipe } from "./app-recipes.js";
|
|
|
3
3
|
import { canonicalizeGitSourceUrl } from "./git-source.js";
|
|
4
4
|
import { selectionKey } from "./package-model.js";
|
|
5
5
|
import { containsCredentialArgument, parseProjectManifest, ProjectManifestError } from "./project-manifest.js";
|
|
6
|
-
export const
|
|
6
|
+
export const PROFILE_FORMAT_V1 = "agent-depot-profile/v1";
|
|
7
|
+
export const PROFILE_FORMAT_V2 = "agent-depot-profile/v2";
|
|
8
|
+
export const PROFILE_FORMAT = PROFILE_FORMAT_V1;
|
|
7
9
|
function object(value, keys, field) {
|
|
8
10
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
9
11
|
throw new Error(`${field}: expected an object`);
|
|
@@ -127,9 +129,12 @@ function parseProfilePackage(value, field) {
|
|
|
127
129
|
}
|
|
128
130
|
/** Portable profile parsing shares the manifest and recipe safety contracts. */
|
|
129
131
|
export function parseProfile(value) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
133
|
+
throw new Error("profile: expected an object");
|
|
134
|
+
const format = value.format;
|
|
135
|
+
if (format !== PROFILE_FORMAT_V1 && format !== PROFILE_FORMAT_V2)
|
|
132
136
|
throw new Error("profile.format: unsupported profile version");
|
|
137
|
+
const root = object(value, ["format", "agentDepotVersion", "sources", "skills", "apps", "packages", "replicas"], "profile");
|
|
133
138
|
if (typeof root.agentDepotVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/u.test(root.agentDepotVersion)) {
|
|
134
139
|
throw new Error("profile.agentDepotVersion: expected a package version");
|
|
135
140
|
}
|
|
@@ -179,8 +184,25 @@ export function parseProfile(value) {
|
|
|
179
184
|
assertPortableText(selection, field);
|
|
180
185
|
return selection;
|
|
181
186
|
});
|
|
187
|
+
const replicas = root.replicas === undefined
|
|
188
|
+
? []
|
|
189
|
+
: list(root.replicas, "replicas").map((value, index) => parseProfileReplica(value, `replicas[${index}]`));
|
|
190
|
+
if (format === PROFILE_FORMAT_V2 && root.replicas === undefined)
|
|
191
|
+
throw new Error("replicas: required for profile v2");
|
|
192
|
+
if (format === PROFILE_FORMAT_V1 && replicas.length > 0)
|
|
193
|
+
throw new Error("replicas: non-empty replica intent requires profile v2");
|
|
182
194
|
rejectDuplicates(sources, source => source.url, "sources");
|
|
183
195
|
rejectDuplicates(packages, selectionKey, "packages");
|
|
196
|
+
rejectDuplicates(replicas, replicaIdentity, "replicas");
|
|
197
|
+
replicas.forEach((replica, index) => {
|
|
198
|
+
const exists = replica.kind === "skill"
|
|
199
|
+
? parsedSkills.some(skill => skillIdentity(skill) === JSON.stringify([replica.source, replica.path]))
|
|
200
|
+
: replica.kind === "app"
|
|
201
|
+
? apps.some(app => app.name === replica.name && app.platform === replica.platform)
|
|
202
|
+
: packages.some(selection => selectionKey(selection) === selectionKey(replica.selection));
|
|
203
|
+
if (!exists)
|
|
204
|
+
throw new Error(`replicas[${index}]: does not reference a profile resource`);
|
|
205
|
+
});
|
|
184
206
|
apps.forEach((app, index) => {
|
|
185
207
|
if (apps.slice(0, index).some(previous => previous.name === app.name &&
|
|
186
208
|
(previous.platform === undefined || app.platform === undefined || previous.platform === app.platform))) {
|
|
@@ -192,14 +214,56 @@ export function parseProfile(value) {
|
|
|
192
214
|
const sortedSkills = [...parsedSkills].sort((a, b) => compare(JSON.stringify([a.source, a.path]), JSON.stringify([b.source, b.path])));
|
|
193
215
|
apps.sort((a, b) => compare(a.name, b.name) || compare(a.platform ?? "", b.platform ?? ""));
|
|
194
216
|
packages.sort((a, b) => compare(selectionKey(a), selectionKey(b)));
|
|
195
|
-
|
|
217
|
+
replicas.sort((a, b) => compare(replicaIdentity(a), replicaIdentity(b)));
|
|
218
|
+
return { format, agentDepotVersion: root.agentDepotVersion, sources, skills: sortedSkills, apps, packages, replicas };
|
|
196
219
|
}
|
|
197
220
|
/**
|
|
198
221
|
* An empty Package block is omitted, so an export with no Packages stays readable
|
|
199
222
|
* by an Agent Depot version that predates the block.
|
|
200
223
|
*/
|
|
224
|
+
function skillIdentity(skill) {
|
|
225
|
+
return JSON.stringify([skill.source, skill.path]);
|
|
226
|
+
}
|
|
227
|
+
function replicaIdentity(replica) {
|
|
228
|
+
switch (replica.kind) {
|
|
229
|
+
case "skill": return JSON.stringify([replica.kind, replica.source, replica.path]);
|
|
230
|
+
case "app": return JSON.stringify([replica.kind, replica.name, replica.platform ?? null]);
|
|
231
|
+
case "package": return JSON.stringify([replica.kind, selectionKey(replica.selection)]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function parseProfileReplica(value, field) {
|
|
235
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
236
|
+
throw new Error(`${field}: expected an object`);
|
|
237
|
+
const kind = value.kind;
|
|
238
|
+
if (kind === "skill") {
|
|
239
|
+
const record = object(value, ["kind", "source", "path"], field);
|
|
240
|
+
const [skill] = parseProjectManifest({ version: 1, skills: [
|
|
241
|
+
{ source: record.source, path: record.path, version: { policy: "latest" }, hosts: ["pi"] },
|
|
242
|
+
] }, "profile").skills;
|
|
243
|
+
return { kind, source: skill.source, path: skill.path };
|
|
244
|
+
}
|
|
245
|
+
if (kind === "app") {
|
|
246
|
+
const record = object(value, ["kind", "name", "platform"], field);
|
|
247
|
+
if (typeof record.name !== "string" || record.name.length === 0)
|
|
248
|
+
throw new Error(`${field}.name: expected a non-empty string`);
|
|
249
|
+
const platform = record.platform;
|
|
250
|
+
if (platform !== undefined && platform !== "linux" && platform !== "windows" && platform !== "darwin") {
|
|
251
|
+
throw new Error(`${field}.platform: unsupported platform`);
|
|
252
|
+
}
|
|
253
|
+
return { kind, name: record.name, ...(platform === undefined ? {} : { platform }) };
|
|
254
|
+
}
|
|
255
|
+
if (kind === "package") {
|
|
256
|
+
const record = object(value, ["kind", "selection"], field);
|
|
257
|
+
return { kind, selection: parseProfilePackage(record.selection, `${field}.selection`) };
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`${field}.kind: expected "skill", "app" or "package"`);
|
|
260
|
+
}
|
|
201
261
|
export function serializeProfile(profile) {
|
|
202
|
-
const
|
|
203
|
-
const {
|
|
204
|
-
|
|
262
|
+
const format = profile.replicas.length ? PROFILE_FORMAT_V2 : PROFILE_FORMAT_V1;
|
|
263
|
+
const parsed = parseProfile({ ...profile, format });
|
|
264
|
+
const { packages, replicas, ...rest } = parsed;
|
|
265
|
+
const output = { ...rest, format,
|
|
266
|
+
...(packages.length ? { packages } : {}),
|
|
267
|
+
...(replicas.length ? { replicas } : {}) };
|
|
268
|
+
return `${JSON.stringify(output, null, 2)}\n`;
|
|
205
269
|
}
|
|
@@ -1,27 +1,67 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text, useWindowSize } from "ink";
|
|
3
3
|
import { PROJECT_HOSTS } from "../project-manifest.js";
|
|
4
4
|
import { errorText, runBatch } from "./batch.js";
|
|
5
|
-
import {
|
|
6
|
-
import { HostChecklist, PreviewConfirm } from "./panel-parts.js";
|
|
7
|
-
|
|
5
|
+
import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
|
|
6
|
+
import { BorderPanel, HostChecklist, PreviewConfirm } from "./panel-parts.js";
|
|
7
|
+
import { previewViewport } from "./preview-window.js";
|
|
8
|
+
export const APP_KINDS = ["app-busy", "app-approve", "app-hosts", "app-confirm", "app-manual"];
|
|
8
9
|
/** Narrows the view's mode without asserting a partial mode is an App mode. */
|
|
9
10
|
export function isAppMode(mode) {
|
|
10
11
|
return APP_KINDS.some(kind => kind === mode.kind);
|
|
11
12
|
}
|
|
12
13
|
/** Recipe name, falling back to the file identity for invalid recipes. */
|
|
13
14
|
export const appName = (entry) => entry.recipe?.name ?? entry.file;
|
|
15
|
+
function appPreviewChrome(mode) {
|
|
16
|
+
const title = mode.kind === "app-approve" ? "Recipe approval · does not install"
|
|
17
|
+
: mode.kind === "app-manual" ? "App manual step" : `App ${mode.plan.action} confirmation`;
|
|
18
|
+
const question = mode.kind === "app-approve" ? `Approve ${appName(mode.entry)}? y/n · recipe approval does not install the App.`
|
|
19
|
+
: mode.kind === "app-manual" ? "Manual step done, check now? y/n"
|
|
20
|
+
: `${mode.plan.action} ${appName(mode.plan.entry)}? y/n`;
|
|
21
|
+
return { title, questions: [question, "Scroll to end before y; n cancels."], footerRows: 2 };
|
|
22
|
+
}
|
|
14
23
|
export function appStepLines(step) {
|
|
24
|
+
const argv = step.argv;
|
|
15
25
|
return [
|
|
16
|
-
...(
|
|
26
|
+
...(argv ? [
|
|
27
|
+
"argv: [",
|
|
28
|
+
...argv.map((argument, index) => ` ${JSON.stringify(argument)}${index < argv.length - 1 ? "," : ""}`),
|
|
29
|
+
"]",
|
|
30
|
+
`Executable: ${step.executable ?? `${JSON.stringify(argv[0])} not found in PATH`}`,
|
|
31
|
+
] : []),
|
|
17
32
|
...(step.manual ? [`Manual: ${step.manual}`] : []),
|
|
33
|
+
...(step.declaration ? [step.declaration] : []),
|
|
34
|
+
...(step.pattern ? [`Version pattern: ${step.pattern}`] : []),
|
|
35
|
+
...(step.environment ? [`Environment: ${step.environment}`] : []),
|
|
18
36
|
`Working directory: ${step.cwd}`,
|
|
19
37
|
...(step.warning ? [step.warning] : []),
|
|
20
38
|
];
|
|
21
39
|
}
|
|
40
|
+
export function appRecipePreviewLines(entry, status, steps) {
|
|
41
|
+
const recipe = entry.recipe;
|
|
42
|
+
return [
|
|
43
|
+
`File: ${entry.file}`,
|
|
44
|
+
`Name: ${recipe?.name ?? entry.file}`,
|
|
45
|
+
`Platform: ${recipe?.platform ?? "all"}`,
|
|
46
|
+
`Status: ${status}`,
|
|
47
|
+
...(recipe?.homepage ? [`Homepage: ${recipe.homepage}`] : []),
|
|
48
|
+
...(recipe?.skills ? [`Owned Skills: ${JSON.stringify(recipe.skills)}`] : []),
|
|
49
|
+
...steps.flatMap(step => [step.step, ...appStepLines(step)]),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
function appPreviewLines(mode) {
|
|
53
|
+
if (mode.kind === "app-approve")
|
|
54
|
+
return mode.lines;
|
|
55
|
+
return [
|
|
56
|
+
`App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
|
|
57
|
+
...(mode.plan.previousVersion ? [`Previous version: ${mode.plan.previousVersion}`] : []),
|
|
58
|
+
...(mode.plan.latestVersion ? [`Latest version: ${mode.plan.latestVersion}`] : []),
|
|
59
|
+
...appStepLines(mode.plan),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
22
62
|
async function prepareNext(context, requests, results = [], failures = 0) {
|
|
23
63
|
const { manage, apps } = context;
|
|
24
|
-
manage.setMode({ kind: "busy", label: "Preparing App step..." });
|
|
64
|
+
manage.setMode({ kind: "app-busy", label: "Preparing App step..." });
|
|
25
65
|
// Independent planning failures never stop the remaining marked Apps.
|
|
26
66
|
const [request, ...remaining] = requests;
|
|
27
67
|
if (!request) {
|
|
@@ -33,7 +73,7 @@ async function prepareNext(context, requests, results = [], failures = 0) {
|
|
|
33
73
|
if (manage.isMounted()) {
|
|
34
74
|
manage.setMode({
|
|
35
75
|
kind: !plan.argv && plan.manual ? "app-manual" : "app-confirm",
|
|
36
|
-
plan, remaining, results, failures,
|
|
76
|
+
plan, remaining, results, failures, cursor: 0,
|
|
37
77
|
});
|
|
38
78
|
}
|
|
39
79
|
}
|
|
@@ -52,10 +92,10 @@ export async function startAppAction(context, entries, input) {
|
|
|
52
92
|
const entry = entries[0];
|
|
53
93
|
if (!entry)
|
|
54
94
|
throw new Error("No App selected");
|
|
55
|
-
manage.setMode({ kind: "busy", label: "Preparing recipe approval..." });
|
|
56
|
-
const steps = await apps.preview(entry);
|
|
95
|
+
manage.setMode({ kind: "app-busy", label: "Preparing recipe approval..." });
|
|
96
|
+
const [steps, approval] = await Promise.all([apps.preview(entry), apps.approvalStatus(entry)]);
|
|
57
97
|
if (manage.isMounted()) {
|
|
58
|
-
manage.setMode({ kind: "app-approve", entry, lines:
|
|
98
|
+
manage.setMode({ kind: "app-approve", entry, lines: appRecipePreviewLines(entry, approval.status, steps), cursor: 0 });
|
|
59
99
|
}
|
|
60
100
|
}
|
|
61
101
|
else if (input === "h") {
|
|
@@ -78,7 +118,7 @@ export async function startAppAction(context, entries, input) {
|
|
|
78
118
|
}
|
|
79
119
|
async function execute(context, mode) {
|
|
80
120
|
const { manage, apps } = context;
|
|
81
|
-
manage.setMode({ kind: "busy", label: "Running App step..." });
|
|
121
|
+
manage.setMode({ kind: "app-busy", label: "Running App step..." });
|
|
82
122
|
let manualRequired = false;
|
|
83
123
|
const [outcome] = await runBatch([mode.plan], async (plan) => {
|
|
84
124
|
const result = mode.kind === "app-manual"
|
|
@@ -102,6 +142,8 @@ async function execute(context, mode) {
|
|
|
102
142
|
/** Applies checklist and y/n keys, continuing independent batch steps after cancellation or failure. */
|
|
103
143
|
export function handleAppKey(context, mode, input, key) {
|
|
104
144
|
const { manage, apps } = context;
|
|
145
|
+
if (mode.kind === "app-busy")
|
|
146
|
+
return;
|
|
105
147
|
const cancel = () => {
|
|
106
148
|
manage.browse();
|
|
107
149
|
manage.setMessage({ kind: "ok", text: "App action cancelled" });
|
|
@@ -123,35 +165,42 @@ export function handleAppKey(context, mode, input, key) {
|
|
|
123
165
|
void prepareNext(context, requests);
|
|
124
166
|
}
|
|
125
167
|
}
|
|
126
|
-
else
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
168
|
+
else {
|
|
169
|
+
const lines = appPreviewLines(mode);
|
|
170
|
+
const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, appPreviewChrome(mode));
|
|
171
|
+
const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
|
|
172
|
+
if (cursor !== undefined) {
|
|
173
|
+
manage.setMode({ ...mode, cursor });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (input === "n" || key.escape) {
|
|
135
177
|
if (mode.kind === "app-confirm" || mode.kind === "app-manual") {
|
|
136
178
|
void prepareNext(context, mode.remaining, [...mode.results, `${appName(mode.plan.entry)}: cancelled`], mode.failures);
|
|
137
179
|
}
|
|
138
180
|
else
|
|
139
181
|
cancel();
|
|
140
|
-
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (input !== "y" || !viewport.reachedEnd)
|
|
185
|
+
return;
|
|
186
|
+
if (mode.kind !== "app-approve") {
|
|
187
|
+
void execute(context, mode);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
manage.setMode({ kind: "app-busy", label: "Approving recipe..." });
|
|
191
|
+
void apps.approve(mode.entry).then(() => manage.finish({ kind: "ok", text: `Approved ${appName(mode.entry)}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
|
|
192
|
+
}
|
|
141
193
|
}
|
|
142
194
|
/** Renders App prompts using the existing checklist and preview-confirm panels. */
|
|
143
195
|
export function AppPanel({ mode }) {
|
|
196
|
+
const { rows, columns } = useWindowSize();
|
|
197
|
+
if (mode.kind === "app-busy")
|
|
198
|
+
return _jsx(BorderPanel, { title: "App lifecycle", children: _jsx(Text, { children: mode.label }) });
|
|
144
199
|
if (mode.kind === "app-hosts") {
|
|
145
|
-
return (_jsxs(
|
|
146
|
-
}
|
|
147
|
-
if (mode.kind === "app-approve") {
|
|
148
|
-
return _jsx(PreviewConfirm, { lines: mode.lines, question: `Approve ${appName(mode.entry)}? y/n` });
|
|
200
|
+
return (_jsxs(BorderPanel, { title: "App Host steps", children: [_jsx(Text, { children: "Checked = setup. Unchecked = teardown. Wiring is not tracked." }), _jsxs(Text, { children: ["Space/1-", mode.choices.length, " toggle \u00B7 j/k move \u00B7 Enter continue \u00B7 Esc cancel"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
|
|
149
201
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
], question: mode.kind === "app-manual"
|
|
155
|
-
? "Manual step done, check now? y/n"
|
|
156
|
-
: `${mode.plan.action} ${appName(mode.plan.entry)}? y/n` }));
|
|
202
|
+
const lines = appPreviewLines(mode);
|
|
203
|
+
const chrome = appPreviewChrome(mode);
|
|
204
|
+
const viewport = previewViewport(lines, mode.cursor, rows || 24, columns || 80, chrome);
|
|
205
|
+
return (_jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome }));
|
|
157
206
|
}
|