@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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-10-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Profiles**: a portable, versioned `agent-depot-profile/v1` JSON file to carry a user-global setup to another Agent Depot installation (ADR 0008). It holds Git Sources, user-global Skill selections (Source, path, version policy, Hosts, methods) and App recipe contents, plus the producing `agentDepotVersion`. Local-path and built-in Sources, unmanaged and App-owned Skills, approvals and installed state are never exported; credentials in URLs and absolute paths are rejected, and output is canonically ordered.
|
|
13
|
+
- `agent-depot export [--out <file>]` with `--no-sources`, `--no-skills`, `--no-apps` and repeatable `--source`, `--skill`, `--app`; refuses an existing output file and an empty selection.
|
|
14
|
+
- `agent-depot import <file> [--yes]`: an add-only plan (add / same / conflict with per-field differences) that never removes or changes anything present. Without `--yes` nothing is written and every Skill's Hosts, version policy and method argv are shown. Imported App recipes arrive unapproved and Apps are never installed; a recipe conflicts only with a same-name recipe whose platform overlaps.
|
|
15
|
+
- TUI: a fifth tab **5 Import/Export** with checklists for both directions, previews, y/n confirmation and per-item results.
|
|
16
|
+
- TUI: an **App recipes** section in Sources (`Tab` to switch) lists every recipe with its status (approved, needs approval, invalid, not applicable), previews and approves it with Enter, and `n` shows how to add one: install the `agent-depot-apprecipe` skill from the TUI and paste the suggested prompt, or write the recipe by hand in the shown `apps/` directory.
|
|
17
|
+
|
|
8
18
|
## [0.3.0] - 2026-10-01
|
|
9
19
|
|
|
10
20
|
### Added
|
package/README.md
CHANGED
|
@@ -380,7 +380,7 @@ agent-depot tui
|
|
|
380
380
|
The TUI is an [Ink](https://github.com/vadimdemedes/ink) (React for terminals)
|
|
381
381
|
front end over the same operations as the CLI. It needs Node.js >= 24 and an
|
|
382
382
|
interactive terminal; otherwise `tui` fails with an error. Switch views with
|
|
383
|
-
`1`-`
|
|
383
|
+
`1`-`5`, quit with `q` (not while a prompt is open). Every list scrolls when it is
|
|
384
384
|
taller than the terminal. `j`/`k` or the arrow keys move.
|
|
385
385
|
|
|
386
386
|
| View | Purpose |
|
|
@@ -389,6 +389,7 @@ taller than the terminal. `j`/`k` or the arrow keys move.
|
|
|
389
389
|
| 2 Catalog | Skills that can be installed: those not installed yet, as Source, then Skills. |
|
|
390
390
|
| 3 Installations | What is on disk: managed installations (user-global and project) and unmanaged user-global skills. |
|
|
391
391
|
| 4 Updates | Managed skills with a newer version available, for user-global and project scope. |
|
|
392
|
+
| 5 Import/Export | Transfer portable user-global Source choices, Skill selections and App recipes. |
|
|
392
393
|
|
|
393
394
|
Keys per view (prompts also accept `Esc` to cancel and `y`/`n` to confirm):
|
|
394
395
|
|
|
@@ -399,6 +400,8 @@ Keys per view (prompts also accept `Esc` to cancel and `y`/`n` to confirm):
|
|
|
399
400
|
| Installations | `Enter`/arrows expand and collapse, `u` uninstall (all Hosts or chosen Hosts) or remove an unmanaged skill, `h` or `i` add Hosts to an installation, `A` or `Enter` adopt an unmanaged skill, `space` marks a leaf and `a` marks all visible leaves: with marks, `u` and `h` act on all of them with one combined preview and one confirmation (project installations are skipped with a reason) |
|
|
400
401
|
| Updates | `space` mark, `a` mark all, `t`/`p`/`g` show all, project or user-global, `Enter` preview the marks, `r` check again, arrows fold the "cannot assess" line |
|
|
401
402
|
|
|
403
|
+
| Import/Export | `e` export, `i` import; enter a file path, `space` toggle choices, `a` toggle all, `Enter` preview, `y` apply; `j`/`k` scroll previews and results |
|
|
404
|
+
|
|
402
405
|
Safety model:
|
|
403
406
|
|
|
404
407
|
- Nothing is written before a preview and an explicit `y`. Exposing a skill to an
|
|
@@ -577,3 +580,76 @@ are previewed before execution; each Host gets an outcome and failures do not st
|
|
|
577
580
|
remaining Hosts. For manual completion, rerun the selected Host with
|
|
578
581
|
`--manual-done --yes`. This checks the App version, not Host wiring. Host actions
|
|
579
582
|
never change installed-App tracking.
|
|
583
|
+
|
|
584
|
+
## Export a user-global Profile
|
|
585
|
+
|
|
586
|
+
Export portable choices to another machine or keep them in git:
|
|
587
|
+
|
|
588
|
+
```sh
|
|
589
|
+
agent-depot export > profile.json
|
|
590
|
+
agent-depot export --out profile.json
|
|
591
|
+
agent-depot export --no-apps --source <source-id> --skill doctor-md-agents
|
|
592
|
+
agent-depot export --no-sources --no-skills --app my-tool --app another-tool
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Everything portable is selected by default. `--no-sources`, `--no-skills` and
|
|
596
|
+
`--no-apps` exclude blocks; repeatable `--source <id|url>`, `--skill <path|name>`
|
|
597
|
+
and `--app <name>` restrict each block independently. Unknown selections and
|
|
598
|
+
combining a block's inclusion filter with its `--no-*` flag are errors.
|
|
599
|
+
|
|
600
|
+
Stdout contains JSON only; stderr shows the preview and exclusion reasons.
|
|
601
|
+
`--out` requires a new file (existing files and symlinks are not replaced).
|
|
602
|
+
Profiles contain canonical Git URLs with discovery inclusion, recorded Skill
|
|
603
|
+
version policies/Hosts/user methods, and complete App recipes, including recipes
|
|
604
|
+
for other platforms. CLI export always writes `included: true`; discovery choices are transient, while
|
|
605
|
+
the TUI may pass explicit inclusion choices to the shared export core.
|
|
606
|
+
Export neither refreshes Sources nor runs App commands.
|
|
607
|
+
|
|
608
|
+
Local paths and obvious credentials are not portable. Nonportable/invalid items
|
|
609
|
+
are excluded with reasons, as are unmanaged/App-owned and project Skills,
|
|
610
|
+
installation locations/evidence, approval receipts, installed-App state and
|
|
611
|
+
pending updates. Free-text recipe portability checks are conservative; review
|
|
612
|
+
profiles before sharing them—arbitrary secrets cannot be detected automatically.
|
|
613
|
+
|
|
614
|
+
## Import a user-global Profile
|
|
615
|
+
|
|
616
|
+
Preview first, then explicitly confirm the same selection:
|
|
617
|
+
|
|
618
|
+
```sh
|
|
619
|
+
agent-depot import profile.json
|
|
620
|
+
agent-depot import profile.json --yes
|
|
621
|
+
agent-depot import profile.json --no-apps --skill doctor-md-agents --yes
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Import uses the same independent block/name filters as export. It reports **add**,
|
|
625
|
+
**same** (skipped), and **conflict** (difference shown, skipped). Without `--yes`,
|
|
626
|
+
nothing is written. Existing choices and conflicting files are never replaced.
|
|
627
|
+
A profile produced by a newer Agent Depot version gives a warning, not an error.
|
|
628
|
+
|
|
629
|
+
Confirmed additions run in order: Source registration, user-global Skills through
|
|
630
|
+
normal refreshed install previews and safety checks, then App recipes. Recorded
|
|
631
|
+
Hosts, version policies and user methods are retained. Even without `--yes`,
|
|
632
|
+
each add-Skill preview shows its Source, Hosts, version policy and full argv for
|
|
633
|
+
every user-provided method, marked as running only after confirmation. The normal
|
|
634
|
+
install preview still appears before execution. Each failure is reported independently and remaining items
|
|
635
|
+
continue; any failed item makes the command exit non-zero.
|
|
636
|
+
|
|
637
|
+
Recipes, including those for other platforms, arrive **unapproved** in `apps/`.
|
|
638
|
+
A same-name destination recipe blocks import only when its platform overlaps the
|
|
639
|
+
incoming recipe: equal platforms overlap, and a missing platform overlaps every
|
|
640
|
+
platform. Disjoint platform recipes can both be added. Invalid recipe JSON uses
|
|
641
|
+
its raw name and platform for the same check; unknown-name files are reported
|
|
642
|
+
with read/parse details without blocking unrelated Apps. A profile cannot contain
|
|
643
|
+
same-name recipes whose platform applicability overlaps. Import never installs Apps or runs
|
|
644
|
+
recipe commands. Review and approve applicable
|
|
645
|
+
recipes with `agent-depot app approve <name> --yes` before using their lifecycle
|
|
646
|
+
commands. Source discovery inclusion remains a transient frontend choice, not a
|
|
647
|
+
persisted change to an existing Source. Skills carry their own Source identity,
|
|
648
|
+
so omitting their Source block does not force registration; the preview names
|
|
649
|
+
unregistered Sources that will be fetched without registering. Existing Source
|
|
650
|
+
rows explain when discovery inclusion differs but is not imported.
|
|
651
|
+
|
|
652
|
+
The **5 Import/Export** TUI tab offers grouped checklists and the same shared-core
|
|
653
|
+
previews as the CLI. Exclusions and conflicts cannot be selected. Export creates
|
|
654
|
+
a new file only; empty exports write nothing (CLI reports an error). Import adds missing choices without replacing existing ones.
|
|
655
|
+
Imported recipes remain unapproved and Apps are never installed by import.
|
package/dist/src/app-flow.js
CHANGED
|
@@ -137,7 +137,8 @@ export function createAppOperations(environment = {}) {
|
|
|
137
137
|
await chmod(receipts, 0o700);
|
|
138
138
|
await writeFileAtomically(receiptPath(entry), JSON.stringify({ hash: entry.hash }), { mode: 0o600 });
|
|
139
139
|
}
|
|
140
|
-
|
|
140
|
+
/** Reads recipe identity and approval receipts only; never resolves or runs commands. */
|
|
141
|
+
async function approvalStatus(entry) {
|
|
141
142
|
if (entry.error || !entry.recipe)
|
|
142
143
|
return { status: "invalid", reason: entry.error };
|
|
143
144
|
if (!entry.applicable)
|
|
@@ -154,11 +155,17 @@ export function createAppOperations(environment = {}) {
|
|
|
154
155
|
if (error.code !== "ENOENT")
|
|
155
156
|
throw error;
|
|
156
157
|
}
|
|
157
|
-
|
|
158
|
+
return { status: approved ? "approved" : "needs approval" };
|
|
159
|
+
}
|
|
160
|
+
async function approvalProblem(entry) {
|
|
161
|
+
const approval = await approvalStatus(entry);
|
|
162
|
+
if (approval.status === "approved")
|
|
163
|
+
return undefined;
|
|
164
|
+
if (approval.status === "needs approval") {
|
|
158
165
|
const { executable } = await resolve(entry.recipe.version.argv[0]);
|
|
159
|
-
return { status:
|
|
166
|
+
return { status: approval.status, reason: approval.reason, executable };
|
|
160
167
|
}
|
|
161
|
-
return
|
|
168
|
+
return { status: approval.status, reason: approval.reason };
|
|
162
169
|
}
|
|
163
170
|
/** A supplied cache checks recipe approval without running version; omit it for a live check. */
|
|
164
171
|
async function inspect(entry, cached) {
|
|
@@ -440,7 +447,7 @@ export function createAppOperations(environment = {}) {
|
|
|
440
447
|
throw error;
|
|
441
448
|
});
|
|
442
449
|
}
|
|
443
|
-
return { checkUpdate, load, preview, approve, inspect, planLifecycle, executeLifecycle, completeManual, cancelManual, trackedApps, previewForget, forgetApp };
|
|
450
|
+
return { directory, approvalStatus, checkUpdate, load, preview, approve, inspect, planLifecycle, executeLifecycle, completeManual, cancelManual, trackedApps, previewForget, forgetApp };
|
|
444
451
|
}
|
|
445
452
|
/** Concurrent independent checks; one broken App must not hide the rest of a batch. */
|
|
446
453
|
export async function checkAppUpdates(apps) {
|
package/dist/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { checkAppUpdates, createAppOperations } from "./app-flow.js";
|
|
7
7
|
import { describeAppResult, describeManualCompletion, runAppCommand } from "./app-cli.js";
|
|
8
|
+
import { EXPORT_USAGE, IMPORT_USAGE, runProfileExport, runProfileImport } from "./profile-cli.js";
|
|
8
9
|
import { pathsOverlap } from "./path-safety.js";
|
|
9
10
|
import { skillTreeBaseline } from "./skill-discovery.js";
|
|
10
11
|
import { BuiltInSourceError, createSourceOperations, resolveProjectSource, SourceMetadataError, SourceNotFoundError, } from "./sources.js";
|
|
@@ -41,6 +42,8 @@ const USAGE = [
|
|
|
41
42
|
" agent-depot app approve <name> [--yes]",
|
|
42
43
|
" agent-depot app install|update|uninstall <name> [--yes] [--manual-done]",
|
|
43
44
|
" agent-depot app uninstall <name> --forget [--yes]",
|
|
45
|
+
` ${EXPORT_USAGE}`,
|
|
46
|
+
` ${IMPORT_USAGE}`,
|
|
44
47
|
" agent-depot tui",
|
|
45
48
|
" agent-depot --version",
|
|
46
49
|
].join("\n");
|
|
@@ -68,6 +71,8 @@ const SOURCE_SUBCOMMANDS = new Map([
|
|
|
68
71
|
["migrate", (values, { operations, dependencies, output }) => runSourceMigration(values, operations, dependencies, output)],
|
|
69
72
|
]);
|
|
70
73
|
const COMMANDS = new Map([
|
|
74
|
+
["import", (values, { operations, dependencies, output }) => runProfileImport(values, operations, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output, dependencies)],
|
|
75
|
+
["export", (values, { operations, dependencies, output }) => runProfileExport(values, operations, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output, dependencies.stderr ?? (line => console.error(line)), dependencies.homeDirectory)],
|
|
71
76
|
["app", (values, { dependencies, output }) => runAppCommand(values, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output)],
|
|
72
77
|
["install", async (values, { operations, dependencies, output }) => {
|
|
73
78
|
await runInstall(values, operations, dependencies, output);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { buildProfileExport, requireProfileExportSelection, writeProfileExport } from "./profile-export.js";
|
|
3
|
+
import { applyProfileImport, buildProfileImport } from "./profile-import.js";
|
|
4
|
+
import { serializeProfile } from "./profile.js";
|
|
5
|
+
import { CliUsageError } from "./usage-error.js";
|
|
6
|
+
export const EXPORT_USAGE = "agent-depot export [--out <file>] [--no-sources] [--no-skills] [--no-apps] [--source <id|url>...] [--skill <path|name>...] [--app <name>...]";
|
|
7
|
+
export const IMPORT_USAGE = "agent-depot import <file> [--yes] [--no-sources] [--no-skills] [--no-apps] [--source <id|url>...] [--skill <path|name>...] [--app <name>...]";
|
|
8
|
+
export async function runProfileExport(values, operations, apps, output, diagnostic, homeDirectory) {
|
|
9
|
+
const { filters, out } = parseProfileOptions(values, false);
|
|
10
|
+
const plan = await buildProfileExport(operations, apps, { ...filters, homeDirectory });
|
|
11
|
+
for (const line of plan.preview)
|
|
12
|
+
diagnostic(line);
|
|
13
|
+
requireProfileExportSelection(plan.profile);
|
|
14
|
+
const content = serializeProfile(plan.profile);
|
|
15
|
+
if (out === undefined)
|
|
16
|
+
output(content.trimEnd());
|
|
17
|
+
else {
|
|
18
|
+
await writeProfileExport(out, plan.profile);
|
|
19
|
+
diagnostic(`Exported profile to ${out}`);
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
function parseProfileOptions(values, importing) {
|
|
24
|
+
const sources = [], skills = [], appNames = [];
|
|
25
|
+
const filters = { sources, skills, apps: appNames };
|
|
26
|
+
let out;
|
|
27
|
+
let confirmed = false;
|
|
28
|
+
for (let index = 0; index < values.length; index++) {
|
|
29
|
+
const option = values[index];
|
|
30
|
+
if (option === "--yes" && importing) {
|
|
31
|
+
if (confirmed)
|
|
32
|
+
throw new CliUsageError("Duplicate --yes");
|
|
33
|
+
confirmed = true;
|
|
34
|
+
}
|
|
35
|
+
else if (option === "--no-sources")
|
|
36
|
+
filters.noSources = true;
|
|
37
|
+
else if (option === "--no-skills")
|
|
38
|
+
filters.noSkills = true;
|
|
39
|
+
else if (option === "--no-apps")
|
|
40
|
+
filters.noApps = true;
|
|
41
|
+
else if (["--out", "--source", "--skill", "--app"].includes(option)) {
|
|
42
|
+
const value = values[++index];
|
|
43
|
+
if (!value || value.startsWith("--"))
|
|
44
|
+
throw new CliUsageError(`${option} requires a value`);
|
|
45
|
+
if (option === "--out") {
|
|
46
|
+
if (importing)
|
|
47
|
+
throw new CliUsageError(`Usage: ${IMPORT_USAGE}`);
|
|
48
|
+
if (out !== undefined)
|
|
49
|
+
throw new CliUsageError("Duplicate --out");
|
|
50
|
+
out = value;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const names = option === "--source" ? sources : option === "--skill" ? skills : appNames;
|
|
54
|
+
if (names.includes(value))
|
|
55
|
+
throw new CliUsageError(`Duplicate ${option} ${JSON.stringify(value)}`);
|
|
56
|
+
names.push(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
throw new CliUsageError(`Usage: ${importing ? IMPORT_USAGE : EXPORT_USAGE}`);
|
|
61
|
+
}
|
|
62
|
+
return { filters, out, confirmed };
|
|
63
|
+
}
|
|
64
|
+
export async function runProfileImport(values, operations, apps, output, environment = {}) {
|
|
65
|
+
const file = values[0];
|
|
66
|
+
if (!file || file.startsWith("--"))
|
|
67
|
+
throw new CliUsageError(`Usage: ${IMPORT_USAGE}`);
|
|
68
|
+
const { filters, confirmed } = parseProfileOptions(values.slice(1), true);
|
|
69
|
+
const content = await readFile(file, "utf8");
|
|
70
|
+
let input;
|
|
71
|
+
try {
|
|
72
|
+
input = JSON.parse(content);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new Error(`${file}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
76
|
+
}
|
|
77
|
+
const plan = await buildProfileImport(input, operations, apps, filters);
|
|
78
|
+
for (const line of plan.preview)
|
|
79
|
+
output(line);
|
|
80
|
+
if (!confirmed) {
|
|
81
|
+
output("Import not confirmed; rerun with --yes. Nothing was written.");
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
const results = await applyProfileImport(plan, operations, apps, confirmed, output, environment);
|
|
85
|
+
for (const result of results)
|
|
86
|
+
output(`${result.status}: ${result.item.label}${result.detail ? `; ${result.detail}` : ""}`);
|
|
87
|
+
return results.some(result => result.status === "failed") ? 1 : 0;
|
|
88
|
+
}
|
|
@@ -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
|
+
}
|