@iseoane/agent-depot 0.5.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - The TUI fills the terminal from startup, responds to resize, and restores the primary screen on exit.
15
+ - Catalog keeps a stable list height while you move between Skills with different Package owners.
16
+ - Catalog `a` marks or clears only visible loose Skills in the repository under the cursor. Marks in other repositories and filtered-out Skills stay unchanged.
17
+
18
+ ### Fixed
19
+
20
+ - Catalog rows use readable contrast, one-line truncation, and compact Package badges. A separate panel explains the highlighted Skill's ownership.
21
+ - Host-confirmed installed Packages disappear from Catalog. Selected-only Packages and Packages with unknown installation state remain visible.
22
+ - Pi Git Package inspections read the installed checkout's origin, commit, and version without changing the checkout. Local modifications keep the Package installed but make version assessment unavailable.
23
+ - Git Package updates require cached Source history to prove that the installed commit precedes the available commit. Different or locally divergent commits alone do not imply an update.
24
+ - Tests isolate `PI_CODING_AGENT_DIR` so an inherited shell override cannot redirect fixtures to user state.
25
+
10
26
  ## [0.5.0] - 2026-10-06
11
27
 
12
28
  ### Added
@@ -238,6 +238,27 @@ export class GitSourceSnapshotAccess {
238
238
  files: Object.freeze(files),
239
239
  });
240
240
  }
241
+ /** True only when the cached Source history proves `olderCommit` precedes `newerCommit`. */
242
+ async isAncestor(source, olderCommit, newerCommit) {
243
+ if (![olderCommit, newerCommit].every((commit) => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(commit))) {
244
+ return undefined;
245
+ }
246
+ try {
247
+ const destination = await this.requireMirror(source);
248
+ await this.runner.run("git", [
249
+ "--git-dir",
250
+ destination,
251
+ "merge-base",
252
+ "--is-ancestor",
253
+ olderCommit,
254
+ newerCommit,
255
+ ]);
256
+ return true;
257
+ }
258
+ catch {
259
+ return undefined;
260
+ }
261
+ }
241
262
  /** Resolves the selected ref to an immutable commit without refreshing the mirror. */
242
263
  async readResolvedCommit(source) {
243
264
  const destination = await this.requireMirror(source);
@@ -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
  /**
@@ -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(undefined, 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,12 +175,119 @@ 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 }),
184
+ });
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
+ if (status !== "")
258
+ return unavailable("the Pi Git checkout has local modifications");
259
+ const packageJson = JSON.parse(manifest);
260
+ if (!isRecord(packageJson) || typeof packageJson.version !== "string" || packageJson.version.trim() === "") {
261
+ return { commit: commit.toLowerCase() };
262
+ }
263
+ return { commit: commit.toLowerCase(), version: packageJson.version };
264
+ }
265
+ catch (error) {
266
+ const message = error instanceof Error ? error.message : "";
267
+ const reason = message.includes("timed out")
268
+ ? "Git checkout verification timed out"
269
+ : message.includes("symbolic link")
270
+ ? "the configured checkout path contains a symbolic link"
271
+ : "the Pi Git checkout could not be read safely";
272
+ return unavailable(reason);
273
+ }
274
+ }
275
+ async function runPiGit(checkoutPath, args) {
276
+ const result = await runProcess("git", ["-C", checkoutPath, ...args], {
277
+ captureStdout: true,
278
+ maxOutputBytes: 1024 * 1024,
279
+ maxStderrBytes: 8192,
280
+ timeoutMs: 3000,
166
281
  });
282
+ if (result.timedOut)
283
+ throw new Error("Pi checkout verification timed out");
284
+ if (result.outputTooLarge || result.code !== 0 || result.signal !== null)
285
+ throw new Error("Pi checkout verification command failed");
286
+ return result.stdout.toString("utf8").trim();
287
+ }
288
+ function piSourceIdentityFromUrl(origin) {
289
+ const canonical = canonicalizeGitSourceUrl(origin);
290
+ return `git:${canonical.replace(/^[a-z][a-z0-9+.-]*:\/\//iu, "")}`;
167
291
  }
168
292
  function unknownEvidence(reason) {
169
293
  return Object.freeze({ kind: "unknown", reason });
@@ -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
  }
@@ -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
+ }
@@ -1,9 +1,10 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { describeSkills, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.js";
4
+ import { packageOwnedSkillNotice, packageOwnerBadge } from "./catalog-ownership.js";
4
5
  import { skillKey } from "./catalog-tree.js";
5
6
  import { hasUnmanagedCopy } from "./catalog-installs.js";
6
- import { describeRow, UNMANAGED_MARKER } from "./catalog-tree.js";
7
+ import { describeRow, truncateDescription, UNMANAGED_MARKER } from "./catalog-tree.js";
7
8
  import { isPackageMode, PackagePanel } from "./package-actions.js";
8
9
  import { HostChecklist, PreviewLines } from "./panel-parts.js";
9
10
  import { rowStyle } from "./theme.js";
@@ -17,10 +18,17 @@ export function CatalogRowLine({ row, selected, marked, installed }) {
17
18
  const { data } = row.node;
18
19
  const isGroup = data.kind === "source" || data.kind === "group";
19
20
  const ownedBy = data.kind === "skill" ? data.ownedBy : undefined;
20
- return (_jsxs(Text, { ...rowStyle(selected), color: isGroup ? theme.group : ownedBy === undefined ? undefined : theme.inactive, children: [selected ? "> " : " ", " ".repeat(row.depth), data.kind === "skill" ? marked ? "[x] " : "[ ] " : "", describeRow(row), ownedBy === undefined ? null : _jsxs(Text, { color: theme.inactive, children: [" ", "provided by the ", ownedBy.host, " Package ", ownedBy.name] }), data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
21
+ return (_jsxs(Text, { ...rowStyle(selected), color: isGroup ? theme.group : undefined, wrap: "truncate-end", children: [selected ? "> " : " ", " ".repeat(row.depth), data.kind === "skill" ? marked ? "[x] " : "[ ] " : "", data.kind === "skill" && ownedBy !== undefined
22
+ ? _jsxs(_Fragment, { children: [data.skill.name, _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description)] })
23
+ : describeRow(row), data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
21
24
  ? _jsxs(Text, { color: theme.marker, children: [" ", UNMANAGED_MARKER] })
22
25
  : null] }));
23
26
  }
27
+ /** Full Package ownership reason for the highlighted Skill, outside the one-line row. */
28
+ export function CatalogSelectionHelp({ row, height }) {
29
+ const owner = row?.node.data.kind === "skill" ? row.node.data.ownedBy : undefined;
30
+ return (_jsx(Box, { flexDirection: "column", minHeight: height, children: owner === undefined ? null : _jsx(Text, { color: theme.marker, children: packageOwnedSkillNotice(owner) }) }));
31
+ }
24
32
  /** The prompt of the install flow; browsing shows nothing. */
25
33
  export function ActionPanel({ mode }) {
26
34
  if (isPackageMode(mode))
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useReducer, useRef, useState } from "react";
2
- import { packageSelectionFor } from "../package-model.js";
2
+ import { packageSelectionFor, selectionKey } from "../package-model.js";
3
3
  import { projectSourceOf } from "../sources.js";
4
4
  import { filterReducer, initialFilter } from "./catalog-filter.js";
5
5
  import { loadInstalledSkills, NO_INSTALLED } from "./catalog-installs.js";
@@ -23,7 +23,7 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
23
23
  : [sourceId];
24
24
  const labels = new Map(sources.map((source) => [source.id, sourceLabel(source)]));
25
25
  const skills = [];
26
- const bundles = [];
26
+ let bundles = [];
27
27
  const discovered = [];
28
28
  const warnings = [];
29
29
  try {
@@ -62,7 +62,12 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
62
62
  // Ownership is selection- or host-gated, so a loose Skill stays normal until its Package is active.
63
63
  let activeBundles = [];
64
64
  try {
65
- activeBundles = (await packages.activeBundles(discovered)).map(bundle => bundle.descriptor);
65
+ const active = await packages.activeBundles(discovered);
66
+ const installedKeys = new Set(active
67
+ .filter(bundle => bundle.reason === "installed" || bundle.reason === "selected-and-installed")
68
+ .map(bundle => selectionKey(bundle.selection)));
69
+ bundles = bundles.filter(entry => !installedKeys.has(selectionKey(entry.selection)));
70
+ activeBundles = active.map(bundle => bundle.descriptor);
66
71
  }
67
72
  catch (error) {
68
73
  const detail = error instanceof Error ? error.message : String(error);
@@ -7,7 +7,7 @@ const DESCRIPTION_LIMIT = 60;
7
7
  export const UNMANAGED_MARKER = "on disk, unmanaged · adopt it from Installations";
8
8
  export const skillKey = (skill) => `skill:${skill.sourceId}:${skill.path}`;
9
9
  export const bundleKey = (entry) => `bundle:${entry.sourceId}:${entry.descriptor.host}:${bundleRoot(entry.descriptor.coordinates)}`;
10
- function truncate(text) {
10
+ export function truncateDescription(text) {
11
11
  return text.length > DESCRIPTION_LIMIT ? `${text.slice(0, DESCRIPTION_LIMIT - 3)}...` : text;
12
12
  }
13
13
  /** A repository name, with directory and ref for scoped GitHub Sources. */
@@ -69,9 +69,13 @@ export function describeRow(row) {
69
69
  if (data.kind === "bundle") {
70
70
  return `Package ${data.entry.descriptor.name} (${data.entry.descriptor.host}) ${packageVersionLabel(data.entry.descriptor.version)}`;
71
71
  }
72
- return `${data.skill.name} ${truncate(data.skill.description)}`;
72
+ return `${data.skill.name} ${truncateDescription(data.skill.description)}`;
73
73
  }
74
74
  /** The Skills under the tree's Sources, in display order. */
75
75
  export function leavesOf(roots) {
76
76
  return roots.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" ? [child.data.skill] : []));
77
77
  }
78
+ /** Skills that can still be installed as loose Skills, excluding active Package-owned rows. */
79
+ export function looseLeavesOf(roots) {
80
+ return roots.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" && child.data.ownedBy === undefined ? [child.data.skill] : []));
81
+ }
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box, Text, useWindowSize } from "ink";
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import { BROWSE } from "./catalog-actions.js";
5
5
  import { handleCatalogBrowseKey } from "./catalog-browse.js";
6
6
  import { filterSkills } from "./catalog-filter.js";
7
7
  import { handleCatalogActionKey } from "./catalog-flow.js";
8
8
  import { isInstalled } from "./catalog-installs.js";
9
- import { ActionPanel, CatalogHeader, CatalogRowLine } from "./catalog-panel.js";
9
+ import { ActionPanel, CatalogHeader, CatalogRowLine, CatalogSelectionHelp } from "./catalog-panel.js";
10
+ import { packageOwnedSkillNotice } from "./catalog-ownership.js";
10
11
  import { packageOperationsOf } from "./environment.js";
11
12
  import { handlePackageKey, isPackageMode, startPackageAction } from "./package-actions.js";
12
13
  import { ListFooter } from "./panel-parts.js";
@@ -18,8 +19,26 @@ import { useMarks, useMounted } from "./view-state.js";
18
19
  import { computeWindow, useListHeight } from "./window.js";
19
20
  import { useKeys } from "./keys.js";
20
21
  const NO_ENVIRONMENT = {};
22
+ function wrappedLineCount(text, columns) {
23
+ let lines = 1;
24
+ let used = 0;
25
+ for (const word of text.split(" ")) {
26
+ const width = [...word].reduce((total, character) => total + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0);
27
+ if (used > 0 && used + 1 + width <= columns) {
28
+ used += 1 + width;
29
+ continue;
30
+ }
31
+ if (used > 0) {
32
+ lines += 1;
33
+ used = 0;
34
+ }
35
+ lines += Math.floor((width - 1) / columns);
36
+ used = ((width - 1) % columns) + 1;
37
+ }
38
+ return lines;
39
+ }
21
40
  export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight }) {
22
- const height = useListHeight(listHeight, 6);
41
+ const { columns } = useWindowSize();
23
42
  const env = environment ?? NO_ENVIRONMENT;
24
43
  const [all, setAll] = useState(sourceId === undefined);
25
44
  const mounted = useMounted();
@@ -48,6 +67,10 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
48
67
  const { restart } = navigation;
49
68
  useEffect(restart, [operations, sourceId, all, restart]);
50
69
  const { rows, index } = navigation;
70
+ const helpLines = state.status === "ready"
71
+ ? Math.max(1, ...state.activeBundles.map((owner) => wrappedLineCount(packageOwnedSkillNotice(owner), Math.max(columns, 1))))
72
+ : 1;
73
+ const height = useListHeight(listHeight, 6 + helpLines);
51
74
  const flow = {
52
75
  operations,
53
76
  env,
@@ -108,5 +131,5 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
108
131
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
109
132
  const window = computeWindow(rows.length, index, height);
110
133
  const selectedCount = leavesOf(roots).filter((skill) => marks.marked.has(skillKey(skill))).length;
111
- return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), _jsx(ActionPanel, { mode: action })] }));
134
+ return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), _jsx(ActionPanel, { mode: action })] }));
112
135
  }
@@ -10,6 +10,10 @@ export function packageVersionLabel(evidence) {
10
10
  export function installedEvidenceLabel(evidence) {
11
11
  switch (evidence.kind) {
12
12
  case "installed": {
13
+ if (evidence.reason !== undefined) {
14
+ const reason = evidence.reason.includes("local modifications") ? "local changes" : evidence.reason;
15
+ return `installed (version unavailable: ${reason})`;
16
+ }
13
17
  if (evidence.version !== undefined)
14
18
  return `installed ${evidence.version}`;
15
19
  if (evidence.commit !== undefined)
@@ -46,7 +50,7 @@ export function packageRow(record, inspection) {
46
50
  name: inspection.descriptor?.name ?? describeCoordinates(record.selection),
47
51
  installed: installedEvidenceLabel(inspection.installed),
48
52
  available: packageVersionLabel(inspection.available),
49
- status: classifyUpdate(inspection.installed, inspection.available),
53
+ status: classifyUpdate(inspection.installed, inspection.available, inspection.availableCommit, inspection.installedCommitIsAncestor),
50
54
  ...(inspection.drift === undefined ? {} : { drift: inspection.drift }),
51
55
  };
52
56
  }
@@ -3,6 +3,6 @@ import { render } from "ink";
3
3
  import { App } from "./app.js";
4
4
  /** Renders the TUI and resolves once the user exits it. */
5
5
  export async function renderTui(operations, environment) {
6
- const instance = render(_jsx(App, { operations: operations, environment: environment }));
6
+ const instance = render(_jsx(App, { operations: operations, environment: environment }), { alternateScreen: true });
7
7
  await instance.waitUntilExit();
8
8
  }
@@ -1,4 +1,4 @@
1
- import { useStdout } from "ink";
1
+ import { useWindowSize } from "ink";
2
2
  /** Rows assumed when the terminal size is unknown (for example under test renderers). */
3
3
  export const FALLBACK_TERMINAL_ROWS = 24;
4
4
  /** Lines the shell itself uses: title, tab bar, separator and footer hint. */
@@ -23,9 +23,8 @@ export function pageStep(height) {
23
23
  * view's own `reserved` lines. `override` fixes the height for tests.
24
24
  */
25
25
  export function useListHeight(override, reserved) {
26
- const { stdout } = useStdout();
26
+ const { rows } = useWindowSize();
27
27
  if (override !== undefined)
28
28
  return Math.max(override, 1);
29
- const rows = stdout.rows ?? FALLBACK_TERMINAL_ROWS;
30
- return Math.max(rows - APP_CHROME_ROWS - reserved, MINIMUM_ROWS);
29
+ return Math.max((rows || FALLBACK_TERMINAL_ROWS) - APP_CHROME_ROWS - reserved, MINIMUM_ROWS);
31
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iseoane/agent-depot",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A cross-platform catalog for reusable coding-agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "iseoane",