@iseoane/agent-depot 0.5.2 → 0.6.1

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