@iseoane/agent-depot 0.5.0 → 0.5.2

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.
@@ -38,6 +38,7 @@ export function createPackageOperations(environment = {}) {
38
38
  const readHostView = () => readHostInstallView({
39
39
  ...(environment.homeDirectory === undefined ? {} : { homeDirectory: environment.homeDirectory }),
40
40
  ...(environment.readHostStateFile === undefined ? {} : { readHostStateFile: environment.readHostStateFile }),
41
+ ...(environment.readPiCheckout === undefined ? {} : { readPiCheckout: environment.readPiCheckout }),
41
42
  });
42
43
  async function resolveSourceFor(projectSource) {
43
44
  return sourceOperations.resolveProjectSource
@@ -170,10 +171,15 @@ export function createPackageOperations(environment = {}) {
170
171
  const installed = bundle === undefined
171
172
  ? Object.freeze({ kind: "unknown", reason: drift ?? "the bundle could not be resolved" })
172
173
  : findInstalledBundle(view, bundle.descriptor, selection);
174
+ const installedCommitIsAncestor = bundle?.resolvedCommit === undefined || installed.kind !== "installed" || installed.commit === undefined
175
+ ? undefined
176
+ : await contentAccess.isCommitAncestor?.(bundle.source, installed.commit, bundle.resolvedCommit).catch(() => undefined);
173
177
  return Object.freeze({
174
178
  ...(bundle === undefined ? {} : { descriptor: bundle.descriptor }),
175
179
  installed,
176
180
  ...(bundle?.available === undefined ? {} : { available: bundle.available }),
181
+ ...(bundle?.resolvedCommit === undefined ? {} : { availableCommit: bundle.resolvedCommit }),
182
+ ...(installedCommitIsAncestor === true ? { installedCommitIsAncestor: true } : {}),
177
183
  approval,
178
184
  ...((drift ?? identityDrift) === undefined ? {} : { drift: drift ?? identityDrift }),
179
185
  });
@@ -181,11 +187,12 @@ export function createPackageOperations(environment = {}) {
181
187
  async function checkOne(record) {
182
188
  try {
183
189
  const inspection = await inspect(record.selection);
184
- const status = classifyUpdate(inspection.installed, inspection.available);
190
+ const status = classifyUpdate(inspection.installed, inspection.available, inspection.availableCommit, inspection.installedCommitIsAncestor);
185
191
  return Object.freeze({
186
192
  installed: record,
187
193
  status,
188
194
  ...(inspection.available === undefined ? {} : { available: inspection.available }),
195
+ ...(inspection.availableCommit === undefined ? {} : { availableCommit: inspection.availableCommit }),
189
196
  ...(status === "unknown" ? { reason: inspection.drift ?? describeEvidence(inspection.installed) } : {}),
190
197
  });
191
198
  }
@@ -525,14 +532,14 @@ function installedEvidenceChange(previous, observed) {
525
532
  if (previous.kind === "absent") {
526
533
  return "changed";
527
534
  }
528
- if (previous.version !== undefined && observed.version !== undefined) {
529
- return sameAppVersion(previous.version, observed.version) ? "unchanged" : "changed";
530
- }
531
535
  const previousCommit = previous.commit === undefined ? undefined : gitCommitId(previous.commit);
532
536
  const observedCommit = observed.commit === undefined ? undefined : gitCommitId(observed.commit);
533
537
  if (previousCommit !== undefined && observedCommit !== undefined) {
534
538
  return previousCommit === observedCommit ? "unchanged" : "changed";
535
539
  }
540
+ if (previous.version !== undefined && observed.version !== undefined) {
541
+ return sameAppVersion(previous.version, observed.version) ? "unchanged" : "changed";
542
+ }
536
543
  return "unknown";
537
544
  }
538
545
  /**
@@ -574,7 +581,7 @@ function pinContradiction(selection, observed) {
574
581
  * cannot be attested leaves the field absent.
575
582
  */
576
583
  function verifiedEvidence(observed, available) {
577
- if (observed.kind !== "installed") {
584
+ if (observed.kind !== "installed" || observed.reason !== undefined) {
578
585
  return undefined;
579
586
  }
580
587
  if (observed.commit !== undefined) {
@@ -609,6 +616,8 @@ function hasControlCharacter(value) {
609
616
  function describeEvidence(evidence) {
610
617
  switch (evidence.kind) {
611
618
  case "installed": {
619
+ if (evidence.reason !== undefined)
620
+ return evidence.reason;
612
621
  if (evidence.version !== undefined) {
613
622
  return `installed ${JSON.stringify(evidence.version)}`;
614
623
  }
@@ -124,13 +124,21 @@ export function packageApprovalDigest(declaration) {
124
124
  }
125
125
  /**
126
126
  * Compares installed evidence with available evidence. Manifest versions compare
127
- * through `sameAppVersion` and `compareAppVersions`; commits compare by equality.
128
- * A mixed pair, missing evidence, or an opaque version is `unknown`, never `current`.
127
+ * through `sameAppVersion` and `compareAppVersions`; differing commits compare only
128
+ * when cached Source history proves the installed commit is an ancestor.
129
129
  */
130
- export function classifyUpdate(installed, available) {
131
- if (installed.kind !== "installed" || available === undefined) {
130
+ export function classifyUpdate(installed, available, availableCommit, installedCommitIsAncestor) {
131
+ if (installed.kind !== "installed" || installed.reason !== undefined || available === undefined) {
132
132
  return "unknown";
133
133
  }
134
+ if (installed.commit !== undefined) {
135
+ const sourceCommit = availableCommit ?? (available.kind === "git-commit" ? available.commit : undefined);
136
+ if (sourceCommit === undefined)
137
+ return "unknown";
138
+ if (normalizeCommit(installed.commit) === normalizeCommit(sourceCommit))
139
+ return "current";
140
+ return installedCommitIsAncestor === true ? "update available" : "unknown";
141
+ }
134
142
  if (installed.version !== undefined && available.kind === "manifest-version") {
135
143
  if (sameAppVersion(installed.version, available.version)) {
136
144
  return "current";
@@ -141,11 +149,6 @@ export function classifyUpdate(installed, available) {
141
149
  }
142
150
  return order < 0 ? "update available" : "current";
143
151
  }
144
- if (installed.commit !== undefined && available.kind === "git-commit") {
145
- return normalizeCommit(installed.commit) === normalizeCommit(available.commit)
146
- ? "current"
147
- : "update available";
148
- }
149
152
  return "unknown";
150
153
  }
151
154
  /** Validates one host argv with the shared portable-command rules. */
@@ -1,6 +1,9 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { lstat, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
+ import { canonicalizeGitSourceUrl } from "./git-source.js";
5
+ import { assertNoSymlinkPath } from "./path-safety.js";
6
+ import { runProcess } from "./process-runner.js";
4
7
  /**
5
8
  * Reads the `packages` setting, or a bare `packages` array, into records.
6
9
  * Nothing here throws: an unknown entry shape becomes a drifted record so the
@@ -44,13 +47,15 @@ export function parseClaudeMarketplaceState(value) {
44
47
  */
45
48
  export async function readHostInstallView(env = {}) {
46
49
  const home = env.homeDirectory ?? homedir();
50
+ const piAgentDirectory = path.resolve(env.piAgentDirectory ?? process.env.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent"));
47
51
  const reader = env.readHostStateFile ?? ((filePath) => readFile(filePath, "utf8"));
48
52
  const [pi, claudePlugins, claudeMarketplaces] = await Promise.all([
49
- readSection("pi-settings", path.join(home, ".pi", "agent", "settings.json"), reader, parsePiSettingsFile),
53
+ readSection("pi-settings", path.join(piAgentDirectory, "settings.json"), reader, parsePiSettingsFile),
50
54
  readSection("claude-installed-plugins", path.join(home, ".claude", "plugins", "installed_plugins.json"), reader, parseClaudePluginsFile),
51
55
  readSection("claude-known-marketplaces", path.join(home, ".claude", "plugins", "known_marketplaces.json"), reader, parseClaudeMarketplacesFile),
52
56
  ]);
53
- return Object.freeze({ pi, claudePlugins, claudeMarketplaces });
57
+ const verifiedPi = await attachPiCheckoutEvidence(pi, piAgentDirectory, env.readPiCheckout ?? readPiCheckoutEvidence);
58
+ return Object.freeze({ pi: verifiedPi, claudePlugins, claudeMarketplaces });
54
59
  }
55
60
  /**
56
61
  * What the host says about one bundle. Pi identity is the repository URL without
@@ -62,6 +67,9 @@ export function findInstalledBundle(view, descriptor, selection) {
62
67
  if (descriptor.host !== selection.host) {
63
68
  return unknownEvidence("the bundle descriptor and the selection disagree on the host");
64
69
  }
70
+ if (selection.host === "pi" && (descriptor.coordinates.host !== "pi" || descriptor.coordinates.root !== selection.root)) {
71
+ return unknownEvidence("the Pi bundle root and the selection disagree");
72
+ }
65
73
  return selection.host === "pi"
66
74
  ? findPiInstalled(view.pi, selection)
67
75
  : findClaudeInstalled(view, descriptor, selection);
@@ -126,7 +134,16 @@ function findPiInstalled(section, selection) {
126
134
  const wanted = normalizeRepository(identity);
127
135
  const match = section.records.find((record) => !record.drifted && normalizeRepository(record.identity) === wanted);
128
136
  if (match !== undefined) {
129
- return installedEvidence(undefined, match.commit);
137
+ if (selection.root !== "") {
138
+ return unknownEvidence("Pi project-scoped and subdirectory package checkouts are not supported for version evidence");
139
+ }
140
+ if (match.checkoutIssue !== undefined) {
141
+ return installedEvidence(match.checkoutVersion, undefined, match.checkoutIssue);
142
+ }
143
+ if (match.checkoutCommit === undefined) {
144
+ return installedEvidence(undefined, undefined, `Pi reports this Package installed, but its checkout could not be verified. Version and update assessment are unavailable; inspect the configured checkout before updating.`);
145
+ }
146
+ return installedEvidence(match.checkoutVersion, match.checkoutCommit);
130
147
  }
131
148
  if (section.records.some((record) => record.drifted)) {
132
149
  return unknownEvidence(`${section.path} holds an entry whose shape could not be read, so the install cannot be verified`);
@@ -158,13 +175,124 @@ function findClaudeInstalled(view, descriptor, selection) {
158
175
  }
159
176
  return Object.freeze({ kind: "absent" });
160
177
  }
161
- function installedEvidence(version, commit) {
178
+ function installedEvidence(version, commit, reason) {
162
179
  return Object.freeze({
163
180
  kind: "installed",
164
181
  ...(version === undefined || version === "" ? {} : { version }),
165
182
  ...(commit === undefined || commit === "" ? {} : { commit }),
183
+ ...(reason === undefined ? {} : { reason }),
166
184
  });
167
185
  }
186
+ async function attachPiCheckoutEvidence(section, agentDirectory, reader) {
187
+ const records = await Promise.all(section.records.map(async (record) => {
188
+ if (record.drifted || !record.identity.startsWith("git:"))
189
+ return record;
190
+ try {
191
+ const checkoutPath = piGitInstallPath(agentDirectory, record.identity);
192
+ const evidence = await reader(checkoutPath, record.identity);
193
+ return Object.freeze({
194
+ ...record,
195
+ ...(evidence.commit === undefined ? {} : { checkoutCommit: evidence.commit }),
196
+ ...(evidence.version === undefined ? {} : { checkoutVersion: evidence.version }),
197
+ ...(evidence.issue === undefined ? {} : { checkoutIssue: evidence.issue }),
198
+ });
199
+ }
200
+ catch (error) {
201
+ return Object.freeze({
202
+ ...record,
203
+ checkoutIssue: error instanceof Error ? error.message : "Pi Git checkout could not be verified",
204
+ });
205
+ }
206
+ }));
207
+ return Object.freeze({ ...section, records: Object.freeze(records) });
208
+ }
209
+ function piGitInstallPath(agentDirectory, identity) {
210
+ const source = identity.slice("git:".length);
211
+ let parsed;
212
+ try {
213
+ parsed = new URL(`https://${source}`);
214
+ }
215
+ catch {
216
+ throw new Error("the configured Pi Git source has no safe checkout path");
217
+ }
218
+ const segments = parsed.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
219
+ const lastSegment = segments.at(-1);
220
+ if (lastSegment?.endsWith(".git"))
221
+ segments[segments.length - 1] = lastSegment.slice(0, -4);
222
+ if (segments.length < 2 || segments.some((segment) => segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))) {
223
+ throw new Error("the configured Pi Git source has no safe checkout path");
224
+ }
225
+ return path.resolve(agentDirectory, "git", parsed.hostname, ...segments);
226
+ }
227
+ async function readPiCheckoutEvidence(checkoutPath, sourceIdentity) {
228
+ const unavailable = (reason) => ({
229
+ issue: `Pi reports this Package installed, but ${reason}. Version and update assessment are unavailable; inspect the checkout before updating.`,
230
+ });
231
+ try {
232
+ await assertNoSymlinkPath(checkoutPath, "Pi package checkout path");
233
+ const root = await lstat(checkoutPath).catch((error) => {
234
+ if (isNotFound(error))
235
+ return undefined;
236
+ throw error;
237
+ });
238
+ if (root === undefined)
239
+ return unavailable("its configured Git checkout is missing");
240
+ if (!root.isDirectory())
241
+ return unavailable("its configured Git checkout is not a directory");
242
+ const [topLevel, origin, commit, status, manifest] = await Promise.all([
243
+ runPiGit(checkoutPath, ["rev-parse", "--show-toplevel"]),
244
+ runPiGit(checkoutPath, ["config", "--get", "remote.origin.url"]),
245
+ runPiGit(checkoutPath, ["rev-parse", "--verify", "HEAD^{commit}"]),
246
+ runPiGit(checkoutPath, ["status", "--porcelain", "--untracked-files=all"]),
247
+ runPiGit(checkoutPath, ["show", "HEAD:package.json"]),
248
+ ]);
249
+ const expectedRoot = path.resolve(checkoutPath);
250
+ if (path.resolve(topLevel) !== expectedRoot)
251
+ return unavailable("the package path is not the root of its Git checkout");
252
+ if (normalizeRepository(piSourceIdentityFromUrl(origin)) !== normalizeRepository(sourceIdentity)) {
253
+ return unavailable("the Git checkout origin does not match the selected Pi package Source");
254
+ }
255
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(commit))
256
+ return unavailable("the Pi checkout HEAD is not a full Git commit");
257
+ const packageJson = JSON.parse(manifest);
258
+ const version = isRecord(packageJson) && typeof packageJson.version === "string" && packageJson.version.trim() !== ""
259
+ ? packageJson.version
260
+ : undefined;
261
+ if (status !== "") {
262
+ return {
263
+ ...(version === undefined ? {} : { version }),
264
+ issue: `Pi reports this Package installed, but the Pi Git checkout has local modifications. Update assessment is unavailable; inspect the checkout before updating.`,
265
+ };
266
+ }
267
+ return { commit: commit.toLowerCase(), ...(version === undefined ? {} : { version }) };
268
+ }
269
+ catch (error) {
270
+ const message = error instanceof Error ? error.message : "";
271
+ const reason = message.includes("timed out")
272
+ ? "Git checkout verification timed out"
273
+ : message.includes("symbolic link")
274
+ ? "the configured checkout path contains a symbolic link"
275
+ : "the Pi Git checkout could not be read safely";
276
+ return unavailable(reason);
277
+ }
278
+ }
279
+ async function runPiGit(checkoutPath, args) {
280
+ const result = await runProcess("git", ["-C", checkoutPath, ...args], {
281
+ captureStdout: true,
282
+ maxOutputBytes: 1024 * 1024,
283
+ maxStderrBytes: 8192,
284
+ timeoutMs: 3000,
285
+ });
286
+ if (result.timedOut)
287
+ throw new Error("Pi checkout verification timed out");
288
+ if (result.outputTooLarge || result.code !== 0 || result.signal !== null)
289
+ throw new Error("Pi checkout verification command failed");
290
+ return result.stdout.toString("utf8").trim();
291
+ }
292
+ function piSourceIdentityFromUrl(origin) {
293
+ const canonical = canonicalizeGitSourceUrl(origin);
294
+ return `git:${canonical.replace(/^[a-z][a-z0-9+.-]*:\/\//iu, "")}`;
295
+ }
168
296
  function unknownEvidence(reason) {
169
297
  return Object.freeze({ kind: "unknown", reason });
170
298
  }
@@ -21,12 +21,14 @@ export function activeBundles(bundles, selections, hostView) {
21
21
  continue;
22
22
  }
23
23
  const selection = packageSelectionFor(descriptor, source);
24
- if (selectedKeys.has(selectionKey(selection))) {
25
- active.push({ descriptor, reason: "selected" });
26
- continue;
27
- }
28
- if (findInstalledBundle(hostView, descriptor, selection).kind === "installed") {
29
- active.push({ descriptor, reason: "installed" });
24
+ const selected = selectedKeys.has(selectionKey(selection));
25
+ const installed = findInstalledBundle(hostView, descriptor, selection).kind === "installed";
26
+ if (selected || installed) {
27
+ active.push({
28
+ descriptor,
29
+ selection,
30
+ reason: selected ? (installed ? "selected-and-installed" : "selected") : "installed",
31
+ });
30
32
  }
31
33
  }
32
34
  return Object.freeze(active);
@@ -12,6 +12,12 @@ export function runProcess(command, args, options = {}) {
12
12
  let stderr = "";
13
13
  let stderrBytes = 0;
14
14
  let outputTooLarge = false;
15
+ let timedOut = false;
16
+ const timeout = options.timeoutMs === undefined ? undefined : setTimeout(() => {
17
+ timedOut = true;
18
+ child.kill();
19
+ }, options.timeoutMs);
20
+ timeout?.unref();
15
21
  child.stdout?.on("data", (chunk) => {
16
22
  stdoutBytes += chunk.length;
17
23
  if (options.maxOutputBytes !== undefined && stdoutBytes > options.maxOutputBytes) {
@@ -35,7 +41,9 @@ export function runProcess(command, args, options = {}) {
35
41
  });
36
42
  child.on("error", reject);
37
43
  child.on("close", (code, signal) => {
38
- resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, outputTooLarge });
44
+ if (timeout !== undefined)
45
+ clearTimeout(timeout);
46
+ resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, outputTooLarge, ...(timedOut ? { timedOut: true } : {}) });
39
47
  });
40
48
  });
41
49
  }
@@ -0,0 +1,141 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { compareAppVersions } from "./app-version.js";
4
+ import { createPortableExecutableResolver } from "./executable-resolution.js";
5
+ import { runProcess } from "./process-runner.js";
6
+ const REGISTRY_URL = "https://registry.npmjs.org";
7
+ const MAX_REGISTRY_RESPONSE_BYTES = 8 * 1024;
8
+ const MAX_NPM_STDERR_BYTES = 16 * 1024;
9
+ const COMMAND_TIMEOUT_MS = 30_000;
10
+ function compareVersions(left, right) {
11
+ const comparison = compareAppVersions(left, right);
12
+ if (comparison === undefined)
13
+ throw new Error(`Invalid semantic version comparison ${JSON.stringify(left)} and ${JSON.stringify(right)}`);
14
+ return comparison;
15
+ }
16
+ async function latestVersion(fetcher, packageName) {
17
+ const encodedName = encodeURIComponent(packageName).replace(/^%40/u, "@");
18
+ const response = await fetcher(`${REGISTRY_URL}/${encodedName}/latest`, {
19
+ signal: AbortSignal.timeout(10_000),
20
+ credentials: "omit",
21
+ redirect: "error",
22
+ });
23
+ if (!response.ok)
24
+ throw new Error(`npm registry returned HTTP ${response.status}`);
25
+ const contentLength = Number(response.headers.get("content-length"));
26
+ if (Number.isFinite(contentLength) && contentLength > MAX_REGISTRY_RESPONSE_BYTES) {
27
+ throw new Error("npm registry response exceeded the size limit");
28
+ }
29
+ if (!response.body)
30
+ throw new Error("npm registry response did not include a body");
31
+ const reader = response.body.getReader();
32
+ const chunks = [];
33
+ let responseBytes = 0;
34
+ try {
35
+ for (;;) {
36
+ const chunk = await reader.read();
37
+ if (chunk.done)
38
+ break;
39
+ responseBytes += chunk.value.byteLength;
40
+ if (responseBytes > MAX_REGISTRY_RESPONSE_BYTES) {
41
+ await reader.cancel();
42
+ throw new Error("npm registry response exceeded the size limit");
43
+ }
44
+ chunks.push(chunk.value);
45
+ }
46
+ }
47
+ finally {
48
+ reader.releaseLock();
49
+ }
50
+ const body = Buffer.concat(chunks).toString("utf8");
51
+ let payload;
52
+ try {
53
+ payload = JSON.parse(body);
54
+ }
55
+ catch {
56
+ throw new Error("npm registry returned invalid JSON");
57
+ }
58
+ if (typeof payload !== "object" || payload === null || !("version" in payload) || typeof payload.version !== "string") {
59
+ throw new Error("npm registry response did not contain a version");
60
+ }
61
+ compareVersions(payload.version, payload.version);
62
+ return payload.version;
63
+ }
64
+ function successfulStdout(result) {
65
+ if (result.code !== 0 || result.signal !== null || result.timedOut || result.outputTooLarge)
66
+ return undefined;
67
+ return result.stdout.toString("utf8").trim();
68
+ }
69
+ export function createSelfUpdateOperations(options) {
70
+ const resolveExecutable = options.resolveExecutable ?? createPortableExecutableResolver();
71
+ const run = options.run ?? runProcess;
72
+ const fetcher = options.fetch ?? globalThis.fetch;
73
+ async function npmGlobalExecutable() {
74
+ const npm = await resolveExecutable("npm");
75
+ if (npm.blocked || npm.executable === undefined)
76
+ return undefined;
77
+ const target = await realpath(npm.executable).catch(() => undefined);
78
+ if (target === undefined || path.basename(target) !== "npm-cli.js" || path.basename(path.dirname(target)) !== "bin" || path.basename(path.dirname(path.dirname(target))) !== "npm")
79
+ return undefined;
80
+ let rootResult;
81
+ try {
82
+ rootResult = await run(npm.executable, ["root", "--global", "--silent", "--logs-max=0", "--no-update-notifier"], {
83
+ captureStdout: true,
84
+ maxOutputBytes: 4096,
85
+ maxStderrBytes: MAX_NPM_STDERR_BYTES,
86
+ timeoutMs: COMMAND_TIMEOUT_MS,
87
+ });
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ const globalRoot = successfulStdout(rootResult);
93
+ if (globalRoot === undefined || !path.isAbsolute(globalRoot))
94
+ return undefined;
95
+ const installedPackagePath = path.join(globalRoot, ...options.packageName.split("/"));
96
+ const installedPackage = await lstat(installedPackagePath).catch(() => undefined);
97
+ if (installedPackage === undefined || !installedPackage.isDirectory())
98
+ return undefined;
99
+ const actualRoot = await realpath(options.packageRoot).catch(() => undefined);
100
+ const reportedRoot = await realpath(installedPackagePath).catch(() => undefined);
101
+ return actualRoot !== undefined && actualRoot === reportedRoot ? npm.executable : undefined;
102
+ }
103
+ return {
104
+ async check() {
105
+ compareVersions(options.packageVersion, options.packageVersion);
106
+ const npm = await npmGlobalExecutable();
107
+ if (npm === undefined) {
108
+ return { kind: "unsupported", reason: "this CLI is not verified in npm's global package directory" };
109
+ }
110
+ const latest = await latestVersion(fetcher, options.packageName);
111
+ const comparison = compareVersions(latest, options.packageVersion);
112
+ if (comparison === 0)
113
+ return { kind: "current", current: options.packageVersion, latest };
114
+ if (comparison < 0)
115
+ return { kind: "ahead", current: options.packageVersion, latest };
116
+ return { kind: "available", current: options.packageVersion, latest };
117
+ },
118
+ async apply(version) {
119
+ let comparison;
120
+ try {
121
+ comparison = compareVersions(version, options.packageVersion);
122
+ }
123
+ catch {
124
+ throw new Error(`Refusing invalid Agent Depot version ${JSON.stringify(version)}`);
125
+ }
126
+ if (comparison <= 0)
127
+ throw new Error(`Refusing to downgrade Agent Depot from ${options.packageVersion} to ${version}`);
128
+ const npm = await npmGlobalExecutable();
129
+ if (npm === undefined)
130
+ throw new Error("this CLI is not verified in npm's global package directory");
131
+ const result = await run(npm, [
132
+ "install", "--global", "--no-audit", "--no-fund", "--no-update-notifier", "--logs-max=0", "--ignore-scripts",
133
+ "--registry=https://registry.npmjs.org/", `${options.packageName}@${version}`,
134
+ ], { maxStderrBytes: MAX_NPM_STDERR_BYTES, timeoutMs: COMMAND_TIMEOUT_MS });
135
+ if (result.code !== 0 || result.signal !== null || result.timedOut || result.outputTooLarge) {
136
+ const detail = result.timedOut ? "timed out" : result.outputTooLarge ? "produced too much output" : result.signal ? `stopped by ${result.signal}` : `exited with code ${result.code ?? "unknown"}`;
137
+ throw new Error(`npm global update ${detail}${result.stderr ? `: ${result.stderr.trim()}` : ""}`);
138
+ }
139
+ },
140
+ };
141
+ }
@@ -73,6 +73,11 @@ export class NodeSourceContentAccess {
73
73
  resolvedVersion: Object.freeze({ kind: "git-commit", commit: snapshot.resolvedVersion }),
74
74
  });
75
75
  }
76
+ async isCommitAncestor(source, olderCommit, newerCommit) {
77
+ if (source.kind === "builtin")
78
+ return undefined;
79
+ return this.gitSnapshots.isAncestor(source, olderCommit, newerCommit);
80
+ }
76
81
  async readResolvedVersion(source) {
77
82
  if (source.kind === "builtin") {
78
83
  return AGENT_DEPOT_PACKAGE_VERSION === undefined
@@ -1,5 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text, useApp, useStdout } from "ink";
2
+ import { Box, Text, useApp, useWindowSize } from "ink";
3
3
  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";
@@ -20,7 +20,7 @@ const TABS = [
20
20
  function hintsOf(view, catalogFocusedOnSource) {
21
21
  switch (view) {
22
22
  case "catalog":
23
- return `j/k move · Enter expand · space mark · a all · i install · / filter${catalogFocusedOnSource ? " · s all sources" : ""} · q quit`;
23
+ return `j/k move · Enter expand · space mark · a repo · i install · / filter${catalogFocusedOnSource ? " · s all sources" : ""} · q quit`;
24
24
  case "installations":
25
25
  return "j/k move · Enter expand · space mark · a all · u uninstall · h add hosts · A adopt · q quit";
26
26
  case "profile":
@@ -31,7 +31,7 @@ function hintsOf(view, catalogFocusedOnSource) {
31
31
  }
32
32
  export function App({ operations, environment, onExit }) {
33
33
  const { exit } = useApp();
34
- const { stdout } = useStdout();
34
+ const { columns, rows } = useWindowSize();
35
35
  const [capturing, setCapturing] = useState(false);
36
36
  // Updated synchronously by the views so keys in the same burst never see a stale capturing state.
37
37
  const capturingRef = useRef(false);
@@ -61,8 +61,8 @@ export function App({ operations, environment, onExit }) {
61
61
  else if (input === "5")
62
62
  showView("profile");
63
63
  });
64
- 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, onHintsChange: setSourcesHints, onOpenCatalog: (source) => {
65
- setCatalogSourceId(source.id);
66
- setView("catalog");
67
- } })) : 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" : view === "sources" ? sourcesHints : hintsOf(view, catalogSourceId !== undefined) })] }));
64
+ return (_jsxs(Box, { flexDirection: "column", height: rows, 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(columns) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onHintsChange: setSourcesHints, onOpenCatalog: (source) => {
65
+ setCatalogSourceId(source.id);
66
+ setView("catalog");
67
+ } })) : 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" : view === "sources" ? sourcesHints : hintsOf(view, catalogSourceId !== undefined) })] }));
68
68
  }
@@ -1,4 +1,5 @@
1
- import { leavesOf, skillKey } from "./catalog-tree.js";
1
+ import { packageOwnedSkillAction } from "./catalog-ownership.js";
2
+ import { looseLeavesOf, skillKey } from "./catalog-tree.js";
2
3
  import { navigateTree } from "./tree-navigation.js";
3
4
  import { toggleNode } from "./tree.js";
4
5
  import { toggledMark } from "./view-state.js";
@@ -8,19 +9,40 @@ function markRow(context, row) {
8
9
  context.setMessage({ kind: "error", text: "Mark skills, not source rows" });
9
10
  return;
10
11
  }
12
+ if (row.node.data.ownedBy !== undefined) {
13
+ context.setMessage({ kind: "error", text: `Package-owned Skills cannot be marked. ${packageOwnedSkillAction(row.node.data.ownedBy)}` });
14
+ return;
15
+ }
11
16
  context.setMessage(undefined);
12
17
  context.marks.setMarked(toggledMark(context.marks.markedRef.current, row.node.id));
13
18
  }
14
- /** `a`: marks every listed Skill, or clears the marks when they are all marked already. */
15
- function markAllListed(context) {
16
- const { markedRef, setMarked } = context.marks;
17
- const leaves = leavesOf(context.navigation.latestRoots());
18
- if (leaves.length === 0) {
19
- context.setMessage({ kind: "error", text: "There are no skills to select" });
19
+ /** `a`: marks the visible loose Skills in the highlighted Source, or clears those marks. */
20
+ function markRepository(context, row, visible) {
21
+ const byId = new Map(visible.map((candidate) => [candidate.node.id, candidate]));
22
+ let ancestor = row;
23
+ while (ancestor !== undefined && ancestor.node.data.kind !== "source") {
24
+ ancestor = ancestor.parentId === undefined ? undefined : byId.get(ancestor.parentId);
25
+ }
26
+ if (ancestor?.node.data.kind !== "source") {
27
+ context.setMessage({ kind: "error", text: "Move to a Source, group, Package, or Skill row to select repository Skills" });
28
+ return;
29
+ }
30
+ const sourceId = ancestor.node.data.sourceId;
31
+ const keys = looseLeavesOf(context.navigation.latestRoots())
32
+ .filter((skill) => skill.sourceId === sourceId)
33
+ .map(skillKey);
34
+ if (keys.length === 0) {
35
+ context.setMessage({ kind: "error", text: "There are no visible loose skills in this repository" });
20
36
  return;
21
37
  }
22
38
  context.setMessage(undefined);
23
- setMarked(leaves.every((skill) => markedRef.current.has(skillKey(skill))) ? new Set() : new Set(leaves.map(skillKey)));
39
+ const { markedRef, setMarked } = context.marks;
40
+ const next = new Set(markedRef.current);
41
+ if (keys.every((key) => next.has(key)))
42
+ keys.forEach((key) => next.delete(key));
43
+ else
44
+ keys.forEach((key) => next.add(key));
45
+ setMarked(next);
24
46
  }
25
47
  /** `i`: installs the highlighted bundle, or the marked Skills that are listed, or the highlighted one. */
26
48
  function chooseHosts(context, row) {
@@ -32,8 +54,12 @@ function chooseHosts(context, row) {
32
54
  return;
33
55
  }
34
56
  // Only what is listed installs: marks on Skills hidden by the filter are ignored.
35
- const marked = leavesOf(context.navigation.latestRoots())
57
+ const marked = looseLeavesOf(context.navigation.latestRoots())
36
58
  .filter((skill) => context.marks.markedRef.current.has(skillKey(skill)));
59
+ if (marked.length === 0 && data.kind === "skill" && data.ownedBy !== undefined) {
60
+ context.setMessage({ kind: "error", text: packageOwnedSkillAction(data.ownedBy) });
61
+ return;
62
+ }
37
63
  const highlighted = data.kind === "skill" ? [data.skill] : [];
38
64
  const chosen = marked.length > 0 ? marked : highlighted;
39
65
  if (chosen.length === 0) {
@@ -60,7 +86,7 @@ export function handleCatalogBrowseKey(context, input, key) {
60
86
  else if (input === " " && row)
61
87
  markRow(context, row);
62
88
  else if (input === "a")
63
- markAllListed(context);
89
+ markRepository(context, row, snapshot.visible);
64
90
  else if (input === "i" && context.ready && row)
65
91
  chooseHosts(context, row);
66
92
  }
@@ -0,0 +1,12 @@
1
+ /** Compact badge for a Skill reserved by an active Package. */
2
+ export function packageOwnerBadge(owner) {
3
+ return `${owner.host}:${owner.name}`;
4
+ }
5
+ /** Full reason shown outside the row so the row can stay one physical line. */
6
+ export function packageOwnedSkillNotice(owner) {
7
+ return `Package-owned Skill: provided by the ${owner.host} Package ${owner.name}. Install or update the Package from its Package row.`;
8
+ }
9
+ /** Actionable refusal for attempts to install a Package-owned Skill as loose. */
10
+ export function packageOwnedSkillAction(owner) {
11
+ return `Install or update the ${owner.host} Package ${owner.name} from its Package row.`;
12
+ }