@iseoane/agent-depot 0.3.0 → 0.4.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.
@@ -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) ||
@@ -11,7 +11,7 @@ export function isAppMode(mode) {
11
11
  }
12
12
  /** Recipe name, falling back to the file identity for invalid recipes. */
13
13
  export const appName = (entry) => entry.recipe?.name ?? entry.file;
14
- function stepLines(step) {
14
+ export function appStepLines(step) {
15
15
  return [
16
16
  ...(step.argv ? [`argv: ${JSON.stringify(step.argv)}`, `Executable: ${step.executable ?? "unavailable"}`] : []),
17
17
  ...(step.manual ? [`Manual: ${step.manual}`] : []),
@@ -55,7 +55,7 @@ export async function startAppAction(context, entries, input) {
55
55
  manage.setMode({ kind: "busy", label: "Preparing recipe approval..." });
56
56
  const steps = await apps.preview(entry);
57
57
  if (manage.isMounted()) {
58
- manage.setMode({ kind: "app-approve", entry, lines: steps.flatMap(step => [step.step, ...stepLines(step)]) });
58
+ manage.setMode({ kind: "app-approve", entry, lines: steps.flatMap(step => [step.step, ...appStepLines(step)]) });
59
59
  }
60
60
  }
61
61
  else if (input === "h") {
@@ -150,7 +150,7 @@ export function AppPanel({ mode }) {
150
150
  return (_jsx(PreviewConfirm, { lines: [
151
151
  `Environment: ${mode.plan.environment}`,
152
152
  `App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
153
- ...stepLines(mode.plan),
153
+ ...appStepLines(mode.plan),
154
154
  ], question: mode.kind === "app-manual"
155
155
  ? "Manual step done, check now? y/n"
156
156
  : `${mode.plan.action} ${appName(mode.plan.entry)}? y/n` }));
@@ -4,25 +4,29 @@ import { useCallback, useRef, useState } from "react";
4
4
  import { AGENT_DEPOT_PACKAGE_VERSION } from "../project-manifest.js";
5
5
  import { CatalogView } from "./catalog-view.js";
6
6
  import { InstallationsView } from "./installations-view.js";
7
+ import { useKeys } from "./keys.js";
8
+ import { ProfileView } from "./profile-view.js";
7
9
  import { SourcesView } from "./sources-view.js";
8
10
  import { separatorLine, theme } from "./theme.js";
9
11
  import { UpdatesView } from "./updates-view.js";
10
- import { useKeys } from "./keys.js";
11
12
  const TABS = [
12
13
  ["sources", "1 Sources"],
13
14
  ["catalog", "2 Catalog"],
14
15
  ["installations", "3 Installations"],
15
16
  ["updates", "4 Updates"],
17
+ ["profile", "5 Import/Export"],
16
18
  ];
17
19
  /** Compact keys of the current view; the tab bar already shows how to switch views. */
18
20
  function hintsOf(view, catalogFocusedOnSource) {
19
21
  switch (view) {
20
22
  case "sources":
21
- return "j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
23
+ return "Tab section · j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
22
24
  case "catalog":
23
25
  return `j/k move · Enter expand · space mark · a all · i install · / filter${catalogFocusedOnSource ? " · s all sources" : ""} · q quit`;
24
26
  case "installations":
25
27
  return "j/k move · Enter expand · space mark · a all · u uninstall · h add hosts · A adopt · q quit";
28
+ case "profile":
29
+ return "e export · i import · q quit";
26
30
  case "updates":
27
31
  return "j/k move · space mark · a all · Enter preview · r check again · q quit";
28
32
  }
@@ -55,9 +59,11 @@ export function App({ operations, environment, onExit }) {
55
59
  showView("installations");
56
60
  else if (input === "4")
57
61
  showView("updates");
62
+ else if (input === "5")
63
+ showView("profile");
58
64
  });
59
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
65
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
60
66
  setCatalogSourceId(source.id);
61
67
  setView("catalog");
62
- } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
68
+ } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
63
69
  }