@iseoane/agent-depot 0.2.1 → 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 +32 -0
- package/README.md +186 -1
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +464 -0
- package/dist/src/app-latest.js +63 -0
- package/dist/src/app-owned-skills.js +42 -0
- package/dist/src/app-recipes.js +116 -0
- package/dist/src/app-schema.js +45 -0
- package/dist/src/app-version.js +46 -0
- package/dist/src/atomic-file.js +26 -0
- package/dist/src/cli.js +117 -20
- package/dist/src/process-runner.js +10 -1
- 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/skill-install.js +12 -0
- package/dist/src/source-state.js +3 -28
- package/dist/src/tui/app-actions.js +157 -0
- package/dist/src/tui/app.js +10 -4
- package/dist/src/tui/catalog-installs.js +5 -1
- package/dist/src/tui/installations-browse.js +24 -2
- package/dist/src/tui/installations-tree.js +13 -1
- package/dist/src/tui/installations-view.js +46 -6
- package/dist/src/tui/installations.js +26 -3
- package/dist/src/tui/manage-actions.js +2 -0
- package/dist/src/tui/manage-panel.js +6 -0
- 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/dist/src/tui/unmanaged-actions.js +2 -2
- package/dist/src/tui/updates-flow.js +18 -3
- package/dist/src/tui/updates-keys.js +9 -0
- package/dist/src/tui/updates-model.js +3 -3
- package/dist/src/tui/updates-panel.js +4 -2
- package/dist/src/tui/updates-view.js +1 -1
- package/dist/src/tui/updates.js +76 -5
- package/dist/src/unmanaged-removal.js +3 -0
- package/dist/src/user-global-skill-inventory.js +7 -1
- package/package.json +4 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadAppOwnedSkillFilter } from "./app-owned-skills.js";
|
|
4
|
+
import { sourceIdForUrl } from "./git-source.js";
|
|
5
|
+
import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
|
|
6
|
+
import { parseProfile, PROFILE_FORMAT, serializeProfile } from "./profile.js";
|
|
7
|
+
import { scanUserGlobalSkillInventory } from "./user-global-skill-inventory.js";
|
|
8
|
+
export function selectProfileItems(items, requested, disabled, names, block) {
|
|
9
|
+
if (disabled && requested?.length)
|
|
10
|
+
throw new Error(`${block}: filters cannot be combined with --no-${block}`);
|
|
11
|
+
for (const name of requested ?? []) {
|
|
12
|
+
if (!items.some(item => names(item).includes(name)))
|
|
13
|
+
throw new Error(`${block}: unknown selection ${JSON.stringify(name)}`);
|
|
14
|
+
}
|
|
15
|
+
return disabled ? [] : items.filter(item => !requested?.length || names(item).some(name => requested.includes(name)));
|
|
16
|
+
}
|
|
17
|
+
function validateOne(block, item) {
|
|
18
|
+
const profile = parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
19
|
+
sources: [], skills: [], apps: [], [block]: [item] });
|
|
20
|
+
return profile[block][0];
|
|
21
|
+
}
|
|
22
|
+
/** Read-only export plan shared by terminal frontends; never refreshes Sources or executes recipes. */
|
|
23
|
+
export async function buildProfileExport(operations, apps, options = {}) {
|
|
24
|
+
if (!operations.listUserGlobalInstallations)
|
|
25
|
+
throw new Error("Profile export requires user-global installation listing");
|
|
26
|
+
if (!AGENT_DEPOT_PACKAGE_VERSION)
|
|
27
|
+
throw new Error("Unable to determine the Agent Depot package version");
|
|
28
|
+
const diagnostics = [];
|
|
29
|
+
const exclude = (block, label, reason) => {
|
|
30
|
+
diagnostics.push({ block, label, reason });
|
|
31
|
+
};
|
|
32
|
+
const warn = (message) => diagnostics.push({ kind: "load", message });
|
|
33
|
+
const sources = selectProfileItems(await operations.listSources(), options.sources, options.noSources, source => source.kind === "git" ? [source.id, source.url] : [source.id], "sources");
|
|
34
|
+
const allInstallations = await operations.listUserGlobalInstallations();
|
|
35
|
+
const installations = selectProfileItems(allInstallations, options.skills, options.noSkills, skill => [skill.path, path.posix.basename(skill.path)], "skills");
|
|
36
|
+
const entries = selectProfileItems(await apps.load(), options.apps, options.noApps, entry => [entry.recipe?.name ?? path.basename(entry.file)], "apps");
|
|
37
|
+
const owned = await loadAppOwnedSkillFilter({ appEnvironment: { recipesDirectory: apps.directory },
|
|
38
|
+
reportWarning: warn });
|
|
39
|
+
const portableSources = [];
|
|
40
|
+
for (const source of sources) {
|
|
41
|
+
if (source.kind === "builtin") {
|
|
42
|
+
exclude("sources", "built-in Source", "always present (built-in Skill selections remain portable)");
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
portableSources.push(validateOne("sources", {
|
|
47
|
+
url: source.url, included: options.includedSourceIds?.includes(source.id) ?? true,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
exclude("sources", `Source ${source.id}`, `local-path or invalid Source, not portable (${error instanceof Error ? error.message : String(error)})`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const skills = [];
|
|
55
|
+
for (const skill of installations) {
|
|
56
|
+
if (owned(path.posix.basename(skill.installation?.path ?? skill.path))) {
|
|
57
|
+
exclude("skills", `Skill ${skill.path}`, "App-owned");
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const { source, path: skillPath, version, hosts, methods } = skill;
|
|
61
|
+
const selection = { source, path: skillPath, version, hosts, ...(methods ? { methods } : {}) };
|
|
62
|
+
try {
|
|
63
|
+
skills.push(validateOne("skills", selection));
|
|
64
|
+
if (skill.installation)
|
|
65
|
+
exclude("skills", `installation location and version evidence for Skill ${skill.path}`, "machine-specific");
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
exclude("skills", `Skill ${skill.path}`, `not portable (${error instanceof Error ? error.message : String(error)})`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const recipes = [];
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
try {
|
|
74
|
+
if (entry.error)
|
|
75
|
+
throw new Error(entry.error);
|
|
76
|
+
recipes.push(validateOne("apps", entry.recipe));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
exclude("apps", `App recipe ${path.basename(entry.file)}`, error instanceof Error ? error.message : String(error));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!options.noSkills) {
|
|
83
|
+
const inventory = await scanUserGlobalSkillInventory({
|
|
84
|
+
homeDirectory: options.homeDirectory, managedInstallations: allInstallations,
|
|
85
|
+
appEnvironment: { recipesDirectory: apps.directory }, reportWarning: warn,
|
|
86
|
+
});
|
|
87
|
+
for (const name of new Set([...inventory.unmanaged, ...inventory.symlinks].map(skill => skill.name))) {
|
|
88
|
+
exclude("skills", `unmanaged Skill ${name}`, "not tracked");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const profile = parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
92
|
+
sources: portableSources, skills, apps: recipes });
|
|
93
|
+
return {
|
|
94
|
+
profile,
|
|
95
|
+
exclusions: diagnostics.filter((item) => "block" in item),
|
|
96
|
+
warnings: [...diagnostics.filter((item) => "kind" in item), ...sourceWarnings(profile)],
|
|
97
|
+
preview: profileExportPreview(profile, diagnostics),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function sourceWarnings(profile) {
|
|
101
|
+
return profile.skills.flatMap(skill => {
|
|
102
|
+
const identity = skill.source;
|
|
103
|
+
if (identity.kind === "external" && "url" in identity && !profile.sources.some(source => source.url === identity.url)) {
|
|
104
|
+
return [{ kind: "source", message: `WARNING: Skill ${skill.path} references Source ${identity.url} not included in profile` }];
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Shared preview for a frontend-selected subset; Source warnings are recomputed. */
|
|
110
|
+
export function profileExportPreview(profile, diagnostics = []) {
|
|
111
|
+
return ["Preview: export portable user-global profile",
|
|
112
|
+
...profile.sources.map(source => `Source ${sourceIdForUrl(source.url)} ${source.url} (included: ${source.included})`),
|
|
113
|
+
...profile.skills.map(skill => `Skill ${skill.path} [${skill.hosts.join(", ")}] (${skill.version.policy})`),
|
|
114
|
+
...profile.apps.map(recipe => `App recipe ${recipe.name} (${recipe.platform ?? "all platforms"}; no approval)`),
|
|
115
|
+
...diagnostics.flatMap(item => "block" in item ? [`Excluded ${item.label}: ${item.reason}`] : item.kind === "load" ? [item.message] : []),
|
|
116
|
+
...sourceWarnings(profile).map(warning => warning.message)];
|
|
117
|
+
}
|
|
118
|
+
/** Empty profiles are valid to parse, but are not useful export artifacts. */
|
|
119
|
+
export function requireProfileExportSelection(profile) {
|
|
120
|
+
if (!profile.sources.length && !profile.skills.length && !profile.apps.length) {
|
|
121
|
+
throw new Error("Nothing selected; nothing written.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Exclusively create an export artifact; never replace a file or follow a symlink. */
|
|
125
|
+
export async function writeProfileExport(file, profile) {
|
|
126
|
+
requireProfileExportSelection(profile);
|
|
127
|
+
try {
|
|
128
|
+
await writeFile(file, serializeProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error.code === "EEXIST")
|
|
132
|
+
throw new Error(`${file} already exists; choose another path`);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { compareAppVersions } from "./app-version.js";
|
|
6
|
+
import { canonicalizeGitSourceUrl, sourceIdForUrl } from "./git-source.js";
|
|
7
|
+
import { selectProfileItems } from "./profile-export.js";
|
|
8
|
+
import { parseProfile, PROFILE_FORMAT } from "./profile.js";
|
|
9
|
+
import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
|
|
10
|
+
import { defaultProjectSkillTreeAccess, executeSingleInstall, formatVersionPolicy, outputSingleInstallPreview, planSingleInstall } from "./skill-install.js";
|
|
11
|
+
import { resolveProjectSource } from "./sources.js";
|
|
12
|
+
// Compare declarations, not installation evidence or JSON object insertion order.
|
|
13
|
+
function canonical(value) {
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
16
|
+
if (value && typeof value === "object")
|
|
17
|
+
return `{${Object.entries(value).filter(([, v]) => v !== undefined)
|
|
18
|
+
.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
19
|
+
return JSON.stringify(value) ?? "null";
|
|
20
|
+
}
|
|
21
|
+
function skillIdentity(skill) {
|
|
22
|
+
const source = skill.source;
|
|
23
|
+
return canonical([source.kind === "external" && "url" in source
|
|
24
|
+
? { ...source, url: canonicalizeGitSourceUrl(source.url) } : source, skill.path]);
|
|
25
|
+
}
|
|
26
|
+
function skillChoices(skill) {
|
|
27
|
+
return { hosts: [...skill.hosts].sort(), version: skill.version, methods: skill.methods };
|
|
28
|
+
}
|
|
29
|
+
function recipeIdentity(recipe) {
|
|
30
|
+
return canonical([recipe.name, recipe.platform]);
|
|
31
|
+
}
|
|
32
|
+
function fieldDifferences(existing, incoming) {
|
|
33
|
+
const left = new Map(Object.entries(existing)), right = new Map(Object.entries(incoming));
|
|
34
|
+
return [...new Set([...left.keys(), ...right.keys()])].sort().flatMap(key => canonical(left.get(key)) === canonical(right.get(key)) ? []
|
|
35
|
+
: [`${key}: existing ${canonical(left.get(key))} -> incoming ${canonical(right.get(key))}`]);
|
|
36
|
+
}
|
|
37
|
+
function comparison(existing, incoming) {
|
|
38
|
+
if (existing === undefined)
|
|
39
|
+
return { status: "add" };
|
|
40
|
+
const differences = fieldDifferences(existing, incoming);
|
|
41
|
+
return differences.length ? { status: "conflict", difference: differences.join("; ") } : { status: "same" };
|
|
42
|
+
}
|
|
43
|
+
async function loadImportRecipes(apps) {
|
|
44
|
+
const entries = await apps.load();
|
|
45
|
+
return Promise.all(entries.map(async (entry) => {
|
|
46
|
+
let content = entry.recipe;
|
|
47
|
+
let error = entry.error;
|
|
48
|
+
let name = entry.recipe?.name;
|
|
49
|
+
let platform = entry.recipe?.platform;
|
|
50
|
+
if (!content) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(await readFile(entry.file, "utf8"));
|
|
53
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
54
|
+
content = raw;
|
|
55
|
+
const rawName = raw.name;
|
|
56
|
+
if (typeof rawName === "string")
|
|
57
|
+
name = rawName;
|
|
58
|
+
platform = raw.platform;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
63
|
+
error = `${entry.error && entry.error !== reason ? `${entry.error}; ` : ""}raw JSON read/parse failed: ${reason}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { file: entry.file, name, platform, content, error };
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
function classifySource(value, sources) {
|
|
70
|
+
const exists = sources.some(source => source.kind === "git" && canonicalizeGitSourceUrl(source.url) === value.url);
|
|
71
|
+
return { block: "sources", value, label: `Source ${value.url} (included: ${value.included})`, status: exists ? "same" : "add",
|
|
72
|
+
...(exists && !value.included ? { difference: "included: existing true -> incoming false (discovery inclusion is not imported)" } : {}) };
|
|
73
|
+
}
|
|
74
|
+
function classifySkill(value, skills) {
|
|
75
|
+
const existing = skills.find(skill => skillIdentity(skill) === skillIdentity(value));
|
|
76
|
+
return { block: "skills", value, label: `Skill ${value.path}`, ...comparison(existing && skillChoices(existing), skillChoices(value)) };
|
|
77
|
+
}
|
|
78
|
+
function classifyRecipe(value, recipes) {
|
|
79
|
+
const existing = recipes.filter(entry => entry.name === value.name &&
|
|
80
|
+
(entry.platform === undefined || value.platform === undefined || entry.platform === value.platform));
|
|
81
|
+
if (existing.length === 1 && !existing[0].error &&
|
|
82
|
+
existing[0].platform === value.platform &&
|
|
83
|
+
fieldDifferences(existing[0].content ?? {}, value).length === 0) {
|
|
84
|
+
return { block: "apps", value, label: `App recipe ${value.name} (${value.platform ?? "all platforms"}; unapproved; Apps are not installed)`, status: "same" };
|
|
85
|
+
}
|
|
86
|
+
return { block: "apps", value, label: `App recipe ${value.name} (${value.platform ?? "all platforms"}; unapproved; Apps are not installed)`,
|
|
87
|
+
status: existing.length ? "conflict" : "add",
|
|
88
|
+
...(existing.length ? { difference: existing.map(entry => `${entry.file}: name ${JSON.stringify(value.name)} already present with overlapping platforms; ${fieldDifferences(entry.content ?? {}, value).join("; ") || "content identical"}`).join("; ") } : {}) };
|
|
89
|
+
}
|
|
90
|
+
/** Load only this block; retained across skips and invalidated after every attempted write. */
|
|
91
|
+
async function loadImportClassifier(block, operations, apps) {
|
|
92
|
+
if (block === "sources") {
|
|
93
|
+
const sources = await operations.listSources();
|
|
94
|
+
return (item) => item.block === "sources" ? classifySource(item.value, sources) : item;
|
|
95
|
+
}
|
|
96
|
+
if (block === "skills") {
|
|
97
|
+
if (!operations.listUserGlobalInstallations)
|
|
98
|
+
throw new Error("Profile import requires user-global installation listing");
|
|
99
|
+
const skills = await operations.listUserGlobalInstallations();
|
|
100
|
+
return (item) => item.block === "skills" ? classifySkill(item.value, skills) : item;
|
|
101
|
+
}
|
|
102
|
+
const recipes = await loadImportRecipes(apps);
|
|
103
|
+
return (item) => item.block === "apps" ? classifyRecipe(item.value, recipes) : item;
|
|
104
|
+
}
|
|
105
|
+
/** Read-only classification. Inclusion is a transient frontend choice, not persisted Source state. */
|
|
106
|
+
export async function buildProfileImport(input, operations, apps, filters = {}) {
|
|
107
|
+
const profile = parseProfile(input);
|
|
108
|
+
if (!operations.listUserGlobalInstallations)
|
|
109
|
+
throw new Error("Profile import requires user-global installation listing");
|
|
110
|
+
const sources = await operations.listSources();
|
|
111
|
+
const skills = await operations.listUserGlobalInstallations();
|
|
112
|
+
const recipes = await loadImportRecipes(apps);
|
|
113
|
+
const items = [];
|
|
114
|
+
for (const value of selectProfileItems(profile.sources, filters.sources, filters.noSources, source => [source.url, sourceIdForUrl(source.url)], "sources"))
|
|
115
|
+
items.push(classifySource(value, sources));
|
|
116
|
+
for (const value of selectProfileItems(profile.skills, filters.skills, filters.noSkills, skill => [skill.path, path.posix.basename(skill.path)], "skills"))
|
|
117
|
+
items.push(classifySkill(value, skills));
|
|
118
|
+
for (const value of selectProfileItems(profile.apps, filters.apps, filters.noApps, recipe => [recipe.name], "apps")) {
|
|
119
|
+
items.push(classifyRecipe(value, recipes));
|
|
120
|
+
}
|
|
121
|
+
const preview = ["Preview: import portable user-global profile", ...items.flatMap(item => {
|
|
122
|
+
const status = item.block === "sources" && item.status === "same" && !item.value.included
|
|
123
|
+
? "same (discovery inclusion is not imported)" : item.status;
|
|
124
|
+
const lines = [`${status}: ${item.label}${item.difference ? `; ${item.difference}` : ""}`];
|
|
125
|
+
if (item.block === "skills" && item.status === "add") {
|
|
126
|
+
const skill = item.value;
|
|
127
|
+
lines.push(` Source ${canonical(skill.source)}`, ` hosts: ${skill.hosts.join(",")}; version policy: ${formatVersionPolicy(skill.version)}`);
|
|
128
|
+
for (const [action, method] of Object.entries(skill.methods ?? {})) {
|
|
129
|
+
lines.push(` ${action}: argv=${JSON.stringify(method.argv)} (user-provided method from the profile, runs only after --yes)`);
|
|
130
|
+
}
|
|
131
|
+
const source = skill.source;
|
|
132
|
+
if (source.kind === "external" && "url" in source &&
|
|
133
|
+
!sources.some(registered => registered.kind === "git" && canonicalizeGitSourceUrl(registered.url) === canonicalizeGitSourceUrl(source.url)) &&
|
|
134
|
+
!items.some(candidate => candidate.block === "sources" && canonicalizeGitSourceUrl(candidate.value.url) === canonicalizeGitSourceUrl(source.url) && candidate.status === "add")) {
|
|
135
|
+
lines.push(` Source ${source.url} will be fetched but not registered`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return lines;
|
|
139
|
+
})];
|
|
140
|
+
preview.push(...recipes.filter(entry => entry.error).map(entry => `WARNING: ${entry.file}: ${entry.error}${entry.name === undefined ? "; name unknown; unrelated names are not blocked" : ""}`));
|
|
141
|
+
if (AGENT_DEPOT_PACKAGE_VERSION && (compareAppVersions(profile.agentDepotVersion, AGENT_DEPOT_PACKAGE_VERSION) ?? 0) > 0) {
|
|
142
|
+
preview.push(`WARNING: profile Agent Depot ${profile.agentDepotVersion} is newer than running ${AGENT_DEPOT_PACKAGE_VERSION}`);
|
|
143
|
+
}
|
|
144
|
+
preview.push("Existing choices are never replaced. Source inclusion is a transient discovery choice.", "Same-name App recipes conflict only when platforms overlap; disjoint platform recipes can both be added.");
|
|
145
|
+
return { items, preview };
|
|
146
|
+
}
|
|
147
|
+
/** Publish complete JSON exclusively: rename would silently overwrite an existing recipe. */
|
|
148
|
+
async function writeRecipe(directory, recipe) {
|
|
149
|
+
await mkdir(directory, { recursive: true });
|
|
150
|
+
// A new canonical path cannot inherit a receipt left by a deleted recipe.
|
|
151
|
+
const filename = `profile-${createHash("sha256").update(recipeIdentity(recipe)).digest("hex")}-${randomUUID()}.json`;
|
|
152
|
+
const destination = path.join(directory, filename);
|
|
153
|
+
const temporary = path.join(directory, `.${randomUUID()}.tmp`);
|
|
154
|
+
try {
|
|
155
|
+
await writeFile(temporary, `${JSON.stringify(recipe, null, 2)}\n`, { flag: "wx" });
|
|
156
|
+
await link(temporary, destination);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await unlink(temporary).catch(error => { if (error.code !== "ENOENT")
|
|
160
|
+
throw error; });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Apply only confirmed additions, rechecking destination choices per item. Failures remain independent. */
|
|
164
|
+
export async function applyProfileImport(plan, operations, apps, confirmed, output, environment = {}) {
|
|
165
|
+
const results = [];
|
|
166
|
+
for (const block of ["sources", "skills", "apps"]) {
|
|
167
|
+
let classify;
|
|
168
|
+
for (const item of plan.items.filter(item => item.block === block)) {
|
|
169
|
+
if (!confirmed || item.status !== "add") {
|
|
170
|
+
results.push({ item, status: "skipped" });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
parseProfile({ format: PROFILE_FORMAT, agentDepotVersion: AGENT_DEPOT_PACKAGE_VERSION,
|
|
175
|
+
sources: [], skills: [], apps: [], [block]: [item.value] });
|
|
176
|
+
classify ??= await loadImportClassifier(block, operations, apps);
|
|
177
|
+
const recheck = classify(item);
|
|
178
|
+
if (recheck.status !== "add") {
|
|
179
|
+
results.push({ item, status: "skipped", detail: `${recheck.status}: ${recheck.label}${recheck.difference ? `; ${recheck.difference}` : ""}` });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// Even a failing install method can persist state or change recipe files.
|
|
183
|
+
classify = undefined;
|
|
184
|
+
if (item.block === "sources")
|
|
185
|
+
await operations.addGitSource(item.value.url);
|
|
186
|
+
else if (item.block === "apps")
|
|
187
|
+
await writeRecipe(apps.directory, item.value);
|
|
188
|
+
else {
|
|
189
|
+
await installImportedSkill(item.value, operations, apps, output, environment);
|
|
190
|
+
}
|
|
191
|
+
results.push({ item, status: "added" });
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
results.push({ item, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
async function installImportedSkill(selection, operations, apps, output, environment) {
|
|
201
|
+
if (!operations.addUserGlobalInstallation || !operations.listUserGlobalInstallations)
|
|
202
|
+
throw new Error("Cannot persist user-global Skills");
|
|
203
|
+
const source = selection.source;
|
|
204
|
+
// A Skill's Source identity is self-contained even when its Source block was filtered out.
|
|
205
|
+
const resolvedSource = await (operations.resolveProjectSource ? operations.resolveProjectSource(source) : resolveProjectSource(source));
|
|
206
|
+
const install = await planSingleInstall({ scope: "user-global", sourceId: resolvedSource.id, skillPath: selection.path,
|
|
207
|
+
version: selection.version, hosts: selection.hosts, ref: source.kind === "external" ? source.ref : undefined,
|
|
208
|
+
method: selection.methods?.install, portableV1: true, confirmed: true, overwrite: false, confirmAdditionalHostExposure: false }, { operations: { ...operations, listSources: async () => [resolvedSource] }, sourceAccess: operations.readSkillTreeSnapshot ? {
|
|
209
|
+
readSkillTree: async (source, skillPath) => (await operations.readSkillTreeSnapshot(source, skillPath)).files,
|
|
210
|
+
readSkillTreeSnapshot: (source, skillPath) => operations.readSkillTreeSnapshot(source, skillPath),
|
|
211
|
+
} : defaultProjectSkillTreeAccess(),
|
|
212
|
+
environment: { ...environment, appEnvironment: { recipesDirectory: apps.directory } }, root: path.resolve(environment.homeDirectory ?? homedir()),
|
|
213
|
+
existing: await operations.listUserGlobalInstallations(), recordedIn: "user-global state" });
|
|
214
|
+
// Preserve both user methods and the exact recorded Source ref/version choices.
|
|
215
|
+
const exactPlan = { ...install, selection, resolved: { ...install.resolved, selection } };
|
|
216
|
+
outputSingleInstallPreview(exactPlan, output);
|
|
217
|
+
await executeSingleInstall(exactPlan, record => operations.addUserGlobalInstallation(record), output);
|
|
218
|
+
}
|
|
@@ -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) ||
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { assertSkillNotAppOwned } from "./app-owned-skills.js";
|
|
2
3
|
import { pathsOverlap } from "./path-safety.js";
|
|
3
4
|
import { inspectProjectSkillInstallation, installProjectSkillTransaction, validateProjectSkillInstallationPreview, } from "./project-installation.js";
|
|
4
5
|
import { AGENT_DEPOT_PACKAGE_VERSION, parseProjectManifest, } from "./project-manifest.js";
|
|
@@ -311,6 +312,7 @@ export async function planSingleInstall(request, context) {
|
|
|
311
312
|
throw new CliUsageError(`Skill selection ${JSON.stringify(selection.path)} is already recorded in ${context.recordedIn}; already managed Skills must be changed with update apply`);
|
|
312
313
|
}
|
|
313
314
|
const { resolvedSource, resolved, installationInspection } = await resolveInstallInspection(selection, source, projectSource, context.root, request, context.operations, context.environment, context.sourceAccess);
|
|
315
|
+
await assertInstallNotAppOwned(request, context, installationInspection);
|
|
314
316
|
return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection };
|
|
315
317
|
}
|
|
316
318
|
/** Prints the preview the user confirms before {@link executeSingleInstall}. */
|
|
@@ -324,6 +326,7 @@ export function outputSingleInstallPreview(plan, output) {
|
|
|
324
326
|
*/
|
|
325
327
|
export async function executeSingleInstall(plan, persist, output) {
|
|
326
328
|
const { request, context, selection, resolvedSource, resolved, inspection } = plan;
|
|
329
|
+
await assertInstallNotAppOwned(request, context, inspection);
|
|
327
330
|
const protectedPaths = assertNoManagedInstallationOverlap(context.existing, selection, inspection, context.root, request.overwrite);
|
|
328
331
|
rejectInstallationCollision(inspection, request.overwrite);
|
|
329
332
|
const transaction = await installOne(selection, resolvedSource, context.sourceAccess, context.root, context.environment, resolved.method, request.portableV1, resolved.files, resolved.resolvedVersion, request.confirmAdditionalHostExposure, request.overwrite, inspection, protectedPaths);
|
|
@@ -360,3 +363,12 @@ export function needsAdditionalHostExposure(inspection, hosts) {
|
|
|
360
363
|
const missingCanonical = hosts.some((host) => host !== "claude") && !identical.includes(inspection.canonicalPath);
|
|
361
364
|
return missingClaude || missingCanonical;
|
|
362
365
|
}
|
|
366
|
+
async function assertInstallNotAppOwned(request, context, inspection) {
|
|
367
|
+
if (request.scope !== "user-global")
|
|
368
|
+
return;
|
|
369
|
+
for (const location of inspection.locations) {
|
|
370
|
+
if (location.status === "missing")
|
|
371
|
+
continue;
|
|
372
|
+
await assertSkillNotAppOwned(path.basename(location.path), { ...context.environment, homeDirectory: context.root });
|
|
373
|
+
}
|
|
374
|
+
}
|
package/dist/src/source-state.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdir, readFile,
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { mkdir, readFile, rmdir, unlink } from "node:fs/promises";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
6
|
import { canonicalizeGitSourceUrl, sourceIdForUrl } from "./git-source.js";
|
|
7
7
|
import { parseProjectManifest } from "./project-manifest.js";
|
|
8
8
|
const EMPTY_STATE = Object.freeze({
|
|
@@ -204,28 +204,7 @@ export class SourceStateStore {
|
|
|
204
204
|
const normalized = parseState(state, this.path);
|
|
205
205
|
await mkdir(path.dirname(this.path), { recursive: true });
|
|
206
206
|
const contents = `${JSON.stringify(normalized, null, 2)}\n`;
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
|
|
210
|
-
try {
|
|
211
|
-
await rename(temporaryPath, this.path);
|
|
212
|
-
}
|
|
213
|
-
catch (error) {
|
|
214
|
-
if (!isWindowsReplaceError(error)) {
|
|
215
|
-
throw error;
|
|
216
|
-
}
|
|
217
|
-
// Windows may not replace an existing file with rename. The lock makes
|
|
218
|
-
// this fallback safe from concurrent read-modify-write operations.
|
|
219
|
-
await writeFile(this.path, contents, "utf8");
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
finally {
|
|
223
|
-
await unlink(temporaryPath).catch((error) => {
|
|
224
|
-
if (!isMissingFile(error)) {
|
|
225
|
-
throw error;
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
}
|
|
207
|
+
await writeFileAtomically(this.path, contents, { allowLockedReplaceFallback: true });
|
|
229
208
|
}
|
|
230
209
|
async acquireLock() {
|
|
231
210
|
const lockPath = `${this.path}.lock`;
|
|
@@ -259,7 +238,3 @@ function isMissingFile(error) {
|
|
|
259
238
|
function isAlreadyExists(error) {
|
|
260
239
|
return errorCode(error) === "EEXIST";
|
|
261
240
|
}
|
|
262
|
-
function isWindowsReplaceError(error) {
|
|
263
|
-
const code = errorCode(error);
|
|
264
|
-
return code === "EEXIST" || code === "EPERM" || code === "ENOTEMPTY";
|
|
265
|
-
}
|