@iseoane/agent-depot 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.es.md +969 -0
  3. package/README.md +244 -23
  4. package/dist/src/app-flow.js +52 -51
  5. package/dist/src/app-recipe-removal.js +63 -0
  6. package/dist/src/cli.js +122 -11
  7. package/dist/src/executable-resolution.js +54 -0
  8. package/dist/src/git-source.js +129 -6
  9. package/dist/src/package-bundles.js +614 -0
  10. package/dist/src/package-cli.js +412 -0
  11. package/dist/src/package-content.js +158 -0
  12. package/dist/src/package-flow.js +636 -0
  13. package/dist/src/package-host-plans.js +240 -0
  14. package/dist/src/package-host-state.js +349 -0
  15. package/dist/src/package-model.js +33 -0
  16. package/dist/src/package-owned-skills.js +122 -0
  17. package/dist/src/package-state.js +404 -0
  18. package/dist/src/profile-cli.js +15 -9
  19. package/dist/src/profile-export.js +44 -9
  20. package/dist/src/profile-import.js +80 -17
  21. package/dist/src/profile.js +78 -3
  22. package/dist/src/skill-discovery.js +2 -2
  23. package/dist/src/skill-install.js +16 -0
  24. package/dist/src/sources.js +11 -0
  25. package/dist/src/tui/app-actions.js +1 -1
  26. package/dist/src/tui/app.js +5 -6
  27. package/dist/src/tui/catalog-browse.js +10 -3
  28. package/dist/src/tui/catalog-panel.js +7 -3
  29. package/dist/src/tui/catalog-state.js +57 -6
  30. package/dist/src/tui/catalog-tree.js +52 -10
  31. package/dist/src/tui/catalog-view.js +27 -4
  32. package/dist/src/tui/environment.js +8 -1
  33. package/dist/src/tui/installations-browse.js +20 -2
  34. package/dist/src/tui/installations-rows.js +2 -1
  35. package/dist/src/tui/installations-tree.js +14 -1
  36. package/dist/src/tui/installations-view.js +8 -2
  37. package/dist/src/tui/installations.js +12 -2
  38. package/dist/src/tui/manage-actions.js +2 -0
  39. package/dist/src/tui/manage-panel.js +5 -0
  40. package/dist/src/tui/package-actions.js +165 -0
  41. package/dist/src/tui/package-rows.js +87 -0
  42. package/dist/src/tui/profile-view.js +36 -14
  43. package/dist/src/tui/sources-mode.js +2 -0
  44. package/dist/src/tui/sources-recipes.js +116 -53
  45. package/dist/src/tui/sources-view.js +57 -19
  46. package/dist/src/tui/tree.js +3 -4
  47. package/dist/src/tui/updates-flow.js +4 -1
  48. package/dist/src/tui/updates-panel.js +2 -2
  49. package/dist/src/tui/updates.js +72 -4
  50. package/dist/src/user-global-skill-inventory.js +7 -2
  51. package/package.json +2 -1
  52. package/skills/agent-depot-apprecipe/SKILL.md +29 -5
package/dist/src/cli.js CHANGED
@@ -5,6 +5,9 @@ import { fileURLToPath } from "node:url";
5
5
  import path from "node:path";
6
6
  import { checkAppUpdates, createAppOperations } from "./app-flow.js";
7
7
  import { describeAppResult, describeManualCompletion, runAppCommand } from "./app-cli.js";
8
+ import { createPackageOperations } from "./package-flow.js";
9
+ import { describeLifecyclePlan, describePackageResult, describePackageUpdateCheck, formatPackageSelection, packageSelectionMatches, runPackageCommand } from "./package-cli.js";
10
+ import { selectionKey } from "./package-model.js";
8
11
  import { EXPORT_USAGE, IMPORT_USAGE, runProfileExport, runProfileImport } from "./profile-cli.js";
9
12
  import { pathsOverlap } from "./path-safety.js";
10
13
  import { skillTreeBaseline } from "./skill-discovery.js";
@@ -30,9 +33,9 @@ const USAGE = [
30
33
  " agent-depot discover <source-id> [source-id...]",
31
34
  " agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--yes] [--confirm-additional-host]",
32
35
  " agent-depot install --scope project --manifest --portable-v1 [--yes] [--confirm-additional-host]",
33
- " agent-depot update check --scope <project|user-global>",
34
- " agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>... | --app <name>...) [--yes] [--confirm-path <relative-path>...]",
35
- " Apps run after Skills; --all --scope project skips Apps.",
36
+ " agent-depot update check --scope <project|user-global> [--package <selection>...]",
37
+ " agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>... | --app <name>... | --package <selection>...) [--yes] [--confirm-path <relative-path>...]",
38
+ " Apps and Packages run after Skills; --all --scope project skips Apps and Packages.",
36
39
  " agent-depot skill remove <id|path>... [--yes]",
37
40
  " agent-depot skill remove <id|path> --host <host>... [--yes]",
38
41
  " agent-depot skill host add <id|path> --host <host>... [--yes] [--confirm-additional-host]",
@@ -42,6 +45,12 @@ const USAGE = [
42
45
  " agent-depot app approve <name> [--yes]",
43
46
  " agent-depot app install|update|uninstall <name> [--yes] [--manual-done]",
44
47
  " agent-depot app uninstall <name> --forget [--yes]",
48
+ " agent-depot package list",
49
+ " agent-depot package discover <source-id>",
50
+ " agent-depot package inspect <selection> --host <host>",
51
+ " agent-depot package approve <selection> --host <host> [--action <install|update|uninstall>]",
52
+ " agent-depot package install|update|uninstall <selection> --host <host> [--yes]",
53
+ " agent-depot package forget <selection> --host <host> --yes",
45
54
  ` ${EXPORT_USAGE}`,
46
55
  ` ${IMPORT_USAGE}`,
47
56
  " agent-depot tui",
@@ -70,10 +79,18 @@ const SOURCE_SUBCOMMANDS = new Map([
70
79
  ["remove", (values, { operations, dependencies, output }) => runSourceRemoval(values, operations, dependencies, output)],
71
80
  ["migrate", (values, { operations, dependencies, output }) => runSourceMigration(values, operations, dependencies, output)],
72
81
  ]);
82
+ function packageOperationsFor(dependencies, operations) {
83
+ return dependencies.packageOperations ?? createPackageOperations({
84
+ ...(dependencies.homeDirectory === undefined ? {} : { homeDirectory: dependencies.homeDirectory }),
85
+ sourceOperations: operations,
86
+ ...(dependencies.sourceContentAccess === undefined ? {} : { sourceContentAccess: dependencies.sourceContentAccess }),
87
+ });
88
+ }
73
89
  const COMMANDS = new Map([
74
- ["import", (values, { operations, dependencies, output }) => runProfileImport(values, operations, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output, dependencies)],
75
- ["export", (values, { operations, dependencies, output }) => runProfileExport(values, operations, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output, dependencies.stderr ?? (line => console.error(line)), dependencies.homeDirectory)],
90
+ ["import", (values, { operations, dependencies, output }) => runProfileImport(values, operations, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output, { ...dependencies, packageOperations: packageOperationsFor(dependencies, operations) })],
91
+ ["export", (values, { operations, dependencies, output }) => runProfileExport(values, operations, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output, dependencies.stderr ?? (line => console.error(line)), dependencies.homeDirectory, packageOperationsFor(dependencies, operations))],
76
92
  ["app", (values, { dependencies, output }) => runAppCommand(values, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output)],
93
+ ["package", (values, { operations, dependencies, output }) => runPackageCommand(values, packageOperationsFor(dependencies, operations), operations, output)],
77
94
  ["install", async (values, { operations, dependencies, output }) => {
78
95
  await runInstall(values, operations, dependencies, output);
79
96
  return 0;
@@ -684,8 +701,14 @@ async function runUninstall(argv, operations, dependencies, output) {
684
701
  async function runUpdate(argv, operations, dependencies, output) {
685
702
  const options = parseUpdateOptions(argv);
686
703
  const apps = dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory });
704
+ const packages = packageOperationsFor(dependencies, operations);
705
+ const requestedPackages = options.scope === "user-global" && options.requestedPackages.length > 0
706
+ ? await resolvePackageUpdateSelections(packages, options.requestedPackages)
707
+ : [];
687
708
  let appUpdates = [];
688
709
  let appCheckFailed = false;
710
+ let packageUpdates = [];
711
+ let packageCheckFailed = false;
689
712
  if (options.scope === "user-global") {
690
713
  try {
691
714
  appUpdates = await checkAppUpdates(apps);
@@ -694,6 +717,13 @@ async function runUpdate(argv, operations, dependencies, output) {
694
717
  appCheckFailed = true;
695
718
  output(`Error (Apps): ${error instanceof Error ? error.message : String(error)}`);
696
719
  }
720
+ try {
721
+ packageUpdates = await packages.checkUpdates(requestedPackages.length === 0 ? undefined : requestedPackages);
722
+ }
723
+ catch (error) {
724
+ packageCheckFailed = true;
725
+ output(`Error (Packages): ${error instanceof Error ? error.message : String(error)}`);
726
+ }
697
727
  }
698
728
  let loaded;
699
729
  try {
@@ -720,10 +750,13 @@ async function runUpdate(argv, operations, dependencies, output) {
720
750
  for (const item of appUpdates) {
721
751
  output(`App ${item.entry.recipe?.name ?? item.entry.file}: ${item.status}; installed ${item.installedVersion ?? "unknown"}; latest ${item.latestVersion ?? "unknown"}${item.reason ? `; ${item.reason}` : ""}`);
722
752
  }
753
+ for (const item of packageUpdates) {
754
+ output(describePackageUpdateCheck(item));
755
+ }
723
756
  if (!loaded)
724
757
  return 1;
725
758
  if (options.action === "check") {
726
- return appCheckFailed ? 1 : 0;
759
+ return appCheckFailed || packageCheckFailed ? 1 : 0;
727
760
  }
728
761
  const { assessment, context } = loaded;
729
762
  const selectedApps = options.all ? appUpdates.filter(item => item.status === "update available")
@@ -734,8 +767,13 @@ async function runUpdate(argv, operations, dependencies, output) {
734
767
  }
735
768
  return matches[0];
736
769
  });
770
+ const selectedPackages = options.all
771
+ ? packageUpdates.filter(item => item.status === "update available")
772
+ : selectPackageUpdateChecks(packageUpdates, requestedPackages);
737
773
  const appPlans = [];
774
+ const packagePlans = [];
738
775
  let appFailed = appCheckFailed ? 1 : 0;
776
+ let packageFailed = packageCheckFailed ? 1 : 0;
739
777
  for (const item of selectedApps) {
740
778
  try {
741
779
  const plan = await apps.planLifecycle(item.entry, "update", undefined, item);
@@ -751,6 +789,24 @@ async function runUpdate(argv, operations, dependencies, output) {
751
789
  output(`App ${item.entry.recipe.name}: preview failed: ${error instanceof Error ? error.message : String(error)}`);
752
790
  }
753
791
  }
792
+ for (const item of selectedPackages) {
793
+ try {
794
+ const plan = await packages.planLifecycle(item.installed.selection, "update");
795
+ const approval = await packages.approvalStatus(item.installed.selection, "update");
796
+ describeLifecyclePlan(plan, approval, output);
797
+ if (approval === "approved") {
798
+ packagePlans.push(plan);
799
+ }
800
+ else {
801
+ packageFailed++;
802
+ output(`Package ${formatPackageSelection(item.installed.selection)}: needs approval for update`);
803
+ }
804
+ }
805
+ catch (error) {
806
+ packageFailed++;
807
+ output(`Package ${formatPackageSelection(item.installed.selection)}: preview failed: ${error instanceof Error ? error.message : String(error)}`);
808
+ }
809
+ }
754
810
  const selected = selectUpdateCandidates(assessment, options);
755
811
  const { plans, failures: previewFailures, nonCanonicalPaths } = await previewUpdateCandidates(selected, context);
756
812
  // Successes and failures are reported in candidate order, as the preview runs.
@@ -803,8 +859,25 @@ async function runUpdate(argv, operations, dependencies, output) {
803
859
  output(`App ${plan.entry.recipe.name}: failed: ${error instanceof Error ? error.message : String(error)}`);
804
860
  }
805
861
  }
806
- output(`Update summary: ${result.updated.length + appUpdated} updated, ${result.failed.length + appFailed} failed`);
807
- return result.failed.length + appFailed === 0 ? 0 : 1;
862
+ let packageUpdated = 0;
863
+ for (const plan of packagePlans) {
864
+ try {
865
+ const outcome = await packages.executeLifecycle(plan, true);
866
+ output(`Package ${formatPackageSelection(plan.selection)}: ${describePackageResult(outcome)}`);
867
+ if (outcome.status === "updated") {
868
+ packageUpdated++;
869
+ }
870
+ else {
871
+ packageFailed++;
872
+ }
873
+ }
874
+ catch (error) {
875
+ packageFailed++;
876
+ output(`Package ${formatPackageSelection(plan.selection)}: failed: ${error instanceof Error ? error.message : String(error)}`);
877
+ }
878
+ }
879
+ output(`Update summary: ${result.updated.length + appUpdated + packageUpdated} updated, ${result.failed.length + appFailed + packageFailed} failed`);
880
+ return result.failed.length + appFailed + packageFailed === 0 ? 0 : 1;
808
881
  }
809
882
  function selectUpdateCandidates(assessment, options) {
810
883
  if (!options.all && !options.requested.length)
@@ -821,6 +894,35 @@ function selectUpdateCandidates(assessment, options) {
821
894
  throw error;
822
895
  }
823
896
  }
897
+ async function resolvePackageUpdateSelections(packages, requested) {
898
+ const records = await packages.list();
899
+ return Object.freeze(requested.map((value) => {
900
+ const matches = records.filter((record) => packageSelectionMatches(record.selection, value));
901
+ if (matches.length !== 1) {
902
+ throw new CliUsageError(matches.length === 0
903
+ ? `Unknown Package selection ${JSON.stringify(value)}`
904
+ : `Package selection ${JSON.stringify(value)} is ambiguous`);
905
+ }
906
+ return matches[0].selection;
907
+ }));
908
+ }
909
+ function selectPackageUpdateChecks(checks, selections) {
910
+ return Object.freeze(selections.map((selection) => {
911
+ const label = formatPackageSelection(selection);
912
+ const matches = checks.filter((check) => selectionKey(check.installed.selection) === selectionKey(selection));
913
+ if (matches.length !== 1) {
914
+ throw new CliUsageError(`Package ${JSON.stringify(label)} is unknown or not updateable`);
915
+ }
916
+ const match = matches[0];
917
+ if (match.status === "unknown") {
918
+ throw new CliUsageError(`Package ${JSON.stringify(label)} has unknown version status and is not updateable`);
919
+ }
920
+ if (match.status === "current") {
921
+ throw new CliUsageError(`Package ${JSON.stringify(label)} has no available update`);
922
+ }
923
+ return match;
924
+ }));
925
+ }
824
926
  function parseUpdateOptions(argv) {
825
927
  const action = argv[0];
826
928
  if (action !== "check" && action !== "apply") {
@@ -831,6 +933,7 @@ function parseUpdateOptions(argv) {
831
933
  let confirmed = false;
832
934
  const requested = [];
833
935
  const requestedApps = [];
936
+ const requestedPackages = [];
834
937
  const confirmedPaths = [];
835
938
  for (let index = 1; index < argv.length; index += 1) {
836
939
  switch (argv[index]) {
@@ -846,6 +949,9 @@ function parseUpdateOptions(argv) {
846
949
  case "--app":
847
950
  requestedApps.push(requireOptionValue(argv, ++index, "--app"));
848
951
  break;
952
+ case "--package":
953
+ requestedPackages.push(requireOptionValue(argv, ++index, "--package"));
954
+ break;
849
955
  case "--skill":
850
956
  requested.push(requireOptionValue(argv, ++index, "--skill"));
851
957
  break;
@@ -860,15 +966,19 @@ function parseUpdateOptions(argv) {
860
966
  throw new CliUsageError("Update scope must be explicit: use --scope project or --scope user-global");
861
967
  }
862
968
  if (action === "check" && (all || requested.length > 0 || requestedApps.length > 0 || confirmed || confirmedPaths.length > 0)) {
863
- throw new CliUsageError("update check only accepts --scope; use update apply to select and apply updates");
969
+ throw new CliUsageError("update check only accepts --scope and optional --package filters; use update apply to select and apply updates");
864
970
  }
865
- if (action === "apply" && (all === (requested.length + requestedApps.length > 0))) {
866
- throw new CliUsageError("update apply requires exactly one selection mode: --all or one or more --skill/--app values");
971
+ if (action === "apply" && (all === (requested.length + requestedApps.length + requestedPackages.length > 0))) {
972
+ throw new CliUsageError("update apply requires exactly one selection mode: --all or one or more --skill/--app/--package values");
867
973
  }
868
974
  if (requestedApps.length && scope !== "user-global")
869
975
  throw new CliUsageError("Apps are user-global only");
976
+ if (requestedPackages.length && scope !== "user-global")
977
+ throw new CliUsageError("Packages are user-global only");
870
978
  if (new Set(requestedApps).size !== requestedApps.length)
871
979
  throw new CliUsageError("Duplicate App selection");
980
+ if (new Set(requestedPackages).size !== requestedPackages.length)
981
+ throw new CliUsageError("Duplicate Package selection");
872
982
  if (scope === "user-global" && confirmedPaths.length > 0) {
873
983
  throw new CliUsageError("--confirm-path only applies to --scope project");
874
984
  }
@@ -878,6 +988,7 @@ function parseUpdateOptions(argv) {
878
988
  all,
879
989
  requested: Object.freeze(requested),
880
990
  requestedApps: Object.freeze(requestedApps),
991
+ requestedPackages: Object.freeze(requestedPackages),
881
992
  confirmed,
882
993
  confirmedPaths: Object.freeze(confirmedPaths),
883
994
  };
@@ -0,0 +1,54 @@
1
+ import { constants } from "node:fs";
2
+ import { access, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ /** A POSIX path on a mounted Windows drive, which WSL cannot execute. */
5
+ export function isWindowsExecutableOnWsl(executable, environment) {
6
+ const relative = path.posix.relative(environment.wslMountRoot, executable);
7
+ return environment.isWsl && /^[a-z]\//iu.test(relative);
8
+ }
9
+ /**
10
+ * Resolves one host executable without a shell. The injected resolver is the
11
+ * test and embedding seam; otherwise the name is looked up on PATH, and a
12
+ * Windows executable reachable from WSL is reported as blocked rather than run.
13
+ */
14
+ export function createPortableExecutableResolver(options = {}) {
15
+ const platform = options.platform ?? process.platform;
16
+ const injected = options.resolveExecutable;
17
+ const wslEnvironment = {
18
+ isWsl: options.isWsl ?? (platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)),
19
+ wslMountRoot: options.wslMountRoot ?? "/mnt",
20
+ };
21
+ return async function resolve(name) {
22
+ if (injected) {
23
+ const executable = await injected(name);
24
+ const target = executable ? await realpath(executable).catch(() => executable) : undefined;
25
+ return { executable, blocked: target !== undefined && isWindowsExecutableOnWsl(target, wslEnvironment) };
26
+ }
27
+ let blockedExecutable;
28
+ const suffixes = platform === "win32"
29
+ ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")]
30
+ : [""];
31
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
32
+ if (!path.isAbsolute(directory)) {
33
+ continue;
34
+ }
35
+ for (const suffix of suffixes) {
36
+ const candidate = path.join(directory, name + suffix);
37
+ try {
38
+ await access(candidate, platform === "win32" ? constants.F_OK : constants.X_OK);
39
+ const target = await realpath(candidate);
40
+ if (isWindowsExecutableOnWsl(target, wslEnvironment)) {
41
+ blockedExecutable ??= candidate;
42
+ continue;
43
+ }
44
+ // Preserve shim/multicall identity when spawning; realpath is only a safety check.
45
+ return { executable: candidate, blocked: false };
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ }
51
+ }
52
+ return { executable: blockedExecutable, blocked: blockedExecutable !== undefined };
53
+ };
54
+ }
@@ -18,6 +18,24 @@ const EXCLUDED_LIFECYCLE_SEGMENTS = new Set([
18
18
  "deprecated",
19
19
  "retired",
20
20
  ]);
21
+ /**
22
+ * One file a Source snapshot carries: a Skill manifest, or a bundle manifest the
23
+ * Package discovery pass reads. The Pi root `package.json` and the two Claude
24
+ * `.claude-plugin` manifests sit beside the Skill files in the same immutable
25
+ * Source view, so one read feeds both passes.
26
+ */
27
+ export function isSourceSnapshotFile(relativePath, scopeDirectory) {
28
+ if (path.posix.basename(relativePath) === "SKILL.md") {
29
+ return true;
30
+ }
31
+ const segments = relativePath.split("/");
32
+ if (segments.at(-2) === ".claude-plugin") {
33
+ const name = segments.at(-1);
34
+ return name === "plugin.json" || name === "marketplace.json";
35
+ }
36
+ const root = scopeDirectory === undefined || scopeDirectory === "" ? "package.json" : `${scopeDirectory}/package.json`;
37
+ return relativePath === root;
38
+ }
21
39
  export class GitSourceAccessError extends Error {
22
40
  constructor(source, reason) {
23
41
  super(`Unable to refresh Git Source ${JSON.stringify(source.id)} from ${JSON.stringify(source.url)}: ${reason}`);
@@ -111,14 +129,14 @@ export class GitSourceAccessAdapter {
111
129
  "fetch",
112
130
  "--prune",
113
131
  "--force",
114
- source.url,
132
+ gitSourceLocation(source.url).repositoryUrl,
115
133
  "+refs/*:refs/*",
116
134
  ]);
117
135
  return;
118
136
  }
119
137
  const temporaryDestination = `${destination}.${process.pid}.${randomUUID()}.tmp`;
120
138
  try {
121
- await this.runner.run("git", ["clone", "--mirror", source.url, temporaryDestination]);
139
+ await this.runner.run("git", ["clone", "--mirror", gitSourceLocation(source.url).repositoryUrl, temporaryDestination]);
122
140
  await assertNoSymlinkPath(temporaryDestination, "cache path");
123
141
  if ((await pathType(temporaryDestination)) !== "directory") {
124
142
  throw new Error("Git clone did not create a directory mirror");
@@ -170,6 +188,10 @@ export class GitSourceSnapshotAccess {
170
188
  /** Reads Skill bytes and the commit they came from as one immutable snapshot. */
171
189
  async readSkillTreeSnapshot(source, skillPath) {
172
190
  const relativeSkillPath = validateSkillDirectoryPath(skillPath);
191
+ const directory = gitSourceLocation(source.url).directory;
192
+ if (directory !== undefined && !relativeSkillPath.startsWith(`${directory}/`) && relativeSkillPath !== directory) {
193
+ throw new Error("Selected Skill path is outside the Git Source directory");
194
+ }
173
195
  const destination = await this.requireMirror(source);
174
196
  const commit = await this.readResolvedCommit(source);
175
197
  const tree = await this.runner.run("git", [
@@ -219,7 +241,7 @@ export class GitSourceSnapshotAccess {
219
241
  /** Resolves the selected ref to an immutable commit without refreshing the mirror. */
220
242
  async readResolvedCommit(source) {
221
243
  const destination = await this.requireMirror(source);
222
- const revision = source.ref === undefined ? "HEAD" : validateGitRef(source.ref);
244
+ const revision = validateGitRef(source.ref ?? gitSourceLocation(source.url).ref ?? "HEAD");
223
245
  const commit = (await this.runner.run("git", [
224
246
  "--git-dir",
225
247
  destination,
@@ -235,7 +257,11 @@ export class GitSourceSnapshotAccess {
235
257
  async readSnapshot(source) {
236
258
  const destination = await this.requireMirror(source);
237
259
  const commit = await this.readResolvedCommit(source);
238
- const tree = await this.runner.run("git", ["--git-dir", destination, "ls-tree", "-r", "-z", commit]);
260
+ const directory = gitSourceLocation(source.url).directory;
261
+ const tree = await this.runner.run("git", [
262
+ "--git-dir", destination, "ls-tree", "-r", "-z", commit,
263
+ ...(directory === undefined ? [] : ["--", directory]),
264
+ ]);
239
265
  const files = [];
240
266
  for (const record of tree.split("\0")) {
241
267
  if (!record) {
@@ -250,7 +276,8 @@ export class GitSourceSnapshotAccess {
250
276
  if (metadata[0] === "120000"
251
277
  || metadata[1] !== "blob"
252
278
  || !relativePath
253
- || path.posix.basename(relativePath) !== "SKILL.md"
279
+ || (directory !== undefined && !relativePath.startsWith(`${directory}/`))
280
+ || !isSourceSnapshotFile(relativePath, directory)
254
281
  || relativePath.split("/").slice(0, -1).some((segment) => EXCLUDED_LIFECYCLE_SEGMENTS.has(segment))) {
255
282
  continue;
256
283
  }
@@ -261,6 +288,76 @@ export class GitSourceSnapshotAccess {
261
288
  }
262
289
  return Object.freeze(files);
263
290
  }
291
+ /** File paths at or under `root`, from the same immutable commit as the other reads. */
292
+ async listFiles(source, root) {
293
+ const relativeRoot = root === "" ? "" : validateSkillDirectoryPath(root);
294
+ const destination = await this.requireMirror(source);
295
+ const commit = await this.readResolvedCommit(source);
296
+ const tree = await this.runner.run("git", [
297
+ "--git-dir", destination, "ls-tree", "-r", "-z", commit,
298
+ ...(relativeRoot === "" ? [] : ["--", relativeRoot]),
299
+ ], { maxOutputBytes: SKILL_TREE_LIMITS.maxListingBytes });
300
+ if (Buffer.byteLength(tree, "utf8") > SKILL_TREE_LIMITS.maxListingBytes) {
301
+ throw new Error("Source file listing exceeds the safe size limit");
302
+ }
303
+ const files = [];
304
+ for (const record of tree.split("\0")) {
305
+ if (!record) {
306
+ continue;
307
+ }
308
+ const separator = record.indexOf("\t");
309
+ if (separator < 0) {
310
+ continue;
311
+ }
312
+ const metadata = record.slice(0, separator).split(" ");
313
+ const relativePath = record.slice(separator + 1);
314
+ if (metadata[0] === "120000"
315
+ || metadata[1] !== "blob"
316
+ || !relativePath
317
+ || (relativeRoot !== "" && !relativePath.startsWith(`${relativeRoot}/`))) {
318
+ continue;
319
+ }
320
+ files.push(relativePath);
321
+ }
322
+ return Object.freeze(files);
323
+ }
324
+ /** One file's text at `path`, or undefined when the commit has no such blob. */
325
+ async readFile(source, path) {
326
+ const safePath = validateSkillDirectoryPath(path);
327
+ const destination = await this.requireMirror(source);
328
+ const commit = await this.readResolvedCommit(source);
329
+ const tree = await this.runner.run("git", [
330
+ "--git-dir", destination, "ls-tree", "-z", commit, "--", safePath,
331
+ ], { maxOutputBytes: SKILL_TREE_LIMITS.maxListingBytes });
332
+ for (const record of tree.split("\0")) {
333
+ if (!record) {
334
+ continue;
335
+ }
336
+ const separator = record.indexOf("\t");
337
+ if (separator < 0) {
338
+ continue;
339
+ }
340
+ const [mode, objectType, objectId] = record.slice(0, separator).split(" ");
341
+ const relativePath = record.slice(separator + 1);
342
+ if (relativePath !== safePath) {
343
+ continue;
344
+ }
345
+ if (objectType !== "blob" || (mode !== "100644" && mode !== "100755")) {
346
+ return undefined;
347
+ }
348
+ if (!objectId || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(objectId)) {
349
+ throw new Error("Git Source returned an unsafe object reference");
350
+ }
351
+ const content = await this.runner.run("git", [
352
+ "--git-dir", destination, "cat-file", "blob", objectId,
353
+ ], { maxOutputBytes: SKILL_TREE_LIMITS.maxFileBytes });
354
+ if (Buffer.byteLength(content, "utf8") > SKILL_TREE_LIMITS.maxFileBytes) {
355
+ throw new Error(`Source file exceeds the safe size limit: ${safePath}`);
356
+ }
357
+ return content;
358
+ }
359
+ return undefined;
360
+ }
264
361
  }
265
362
  export function defaultGitSourceCachePath(environment = process.env, platform = process.platform, homeDirectory = homedir()) {
266
363
  const pathApi = platform === "win32" ? path.win32 : path.posix;
@@ -400,7 +497,33 @@ export function canonicalizeGitSourceUrl(input) {
400
497
  if (parsed.pathname === "/" || parsed.pathname.length < 2) {
401
498
  throw new Error("Git Source URL must identify a repository path");
402
499
  }
403
- return parsed.toString();
500
+ const canonicalUrl = parsed.toString();
501
+ gitSourceLocation(canonicalUrl);
502
+ return canonicalUrl;
503
+ }
504
+ /** The GitHub tree/<ref>/<directory> shape, parsed once so the Catalog label cannot describe a different scope than the clone and tree walk use. */
505
+ export function githubTreeLocation(url) {
506
+ const parsed = new URL(url);
507
+ const segments = parsed.pathname.split("/").slice(1);
508
+ if (parsed.hostname !== "github.com" || !["https:", "http:"].includes(parsed.protocol) || segments[2] !== "tree") {
509
+ return undefined;
510
+ }
511
+ if (segments.length < 4 || !segments[0] || !segments[1] || !segments[3]) {
512
+ throw new Error("GitHub directory Source URL must include an owner, repository, and ref");
513
+ }
514
+ const ref = validateGitRef(decodeURIComponent(segments[3]));
515
+ if (ref.startsWith("-"))
516
+ throw new Error("GitHub directory Source ref must not start with a dash");
517
+ const directory = segments.length > 4
518
+ ? validateSkillDirectoryPath(segments.slice(4).map((segment) => decodeURIComponent(segment)).join("/"))
519
+ : undefined;
520
+ const repository = `${segments[0]}/${segments[1]}`;
521
+ parsed.pathname = `/${repository}`;
522
+ return { repository, repositoryUrl: parsed.toString(), ref, ...(directory === undefined ? {} : { directory }) };
523
+ }
524
+ /** Keep the registered URL (and identity) while deriving Git transport and discovery scope. */
525
+ function gitSourceLocation(url) {
526
+ return githubTreeLocation(url) ?? { repositoryUrl: url };
404
527
  }
405
528
  export function sourceIdForUrl(url) {
406
529
  const canonicalUrl = canonicalizeGitSourceUrl(url);