@iseoane/agent-depot 0.2.1 → 0.3.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.
@@ -0,0 +1,116 @@
1
+ import { parsePortableInstallationMethod, PROJECT_HOSTS, } from "./project-manifest.js";
2
+ export const APP_GITHUB_PATTERN = "^[\\w-][\\w.-]*/[\\w-][\\w.-]*$";
3
+ export const APP_NPM_PATTERN = "^(?:@[a-z0-9~-][a-z0-9._~-]*/)?[a-z0-9~-][a-z0-9._~-]*$";
4
+ export const APP_SKILL_PATTERN = "^(?!\\*+$)(?!\\s*$)[^/\\\\\\u0000]+$";
5
+ function object(value, keys, field) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
7
+ throw new Error(`${field}: expected an object`);
8
+ }
9
+ const record = value;
10
+ const unsupported = Object.keys(record).find((key) => !keys.includes(key));
11
+ if (unsupported !== undefined)
12
+ throw new Error(`${field}.${unsupported}: unsupported recipe field`);
13
+ return record;
14
+ }
15
+ function text(value, field) {
16
+ if (typeof value !== "string" || !value.trim())
17
+ throw new Error(`${field}: expected non-empty text`);
18
+ return value;
19
+ }
20
+ function argv(value, field) {
21
+ return parsePortableInstallationMethod({ kind: "command", argv: value }, { label: `${field}:` }).argv;
22
+ }
23
+ function step(value, field) {
24
+ const item = object(value, ["argv", "manual"], field);
25
+ if (item.argv === undefined && item.manual === undefined) {
26
+ throw new Error(`${field}: step needs argv or manual`);
27
+ }
28
+ return {
29
+ ...(item.argv === undefined ? {} : { argv: argv(item.argv, `${field}.argv`) }),
30
+ ...(item.manual === undefined ? {} : { manual: text(item.manual, `${field}.manual`) }),
31
+ };
32
+ }
33
+ function version(value, field) {
34
+ const item = object(value, ["argv", "pattern"], field);
35
+ const pattern = text(item.pattern, `${field}.pattern`);
36
+ try {
37
+ new RegExp(pattern);
38
+ }
39
+ catch (error) {
40
+ throw new Error(`${field}.pattern: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ // Count captures while ignoring escapes, character classes and non-capturing/lookaround groups.
43
+ let captures = 0;
44
+ let inClass = false;
45
+ for (let i = 0; i < pattern.length; i++) {
46
+ if (pattern[i] === "\\") {
47
+ i++;
48
+ continue;
49
+ }
50
+ if (pattern[i] === "[")
51
+ inClass = true;
52
+ if (pattern[i] === "]")
53
+ inClass = false;
54
+ const namedCapture = /^\?<[^=!]/u.test(pattern.slice(i + 1));
55
+ if (!inClass && pattern[i] === "(" && (pattern[i + 1] !== "?" || namedCapture))
56
+ captures++;
57
+ }
58
+ if (captures !== 1)
59
+ throw new Error(`${field}.pattern: expected exactly one capture group`);
60
+ return { argv: argv(item.argv, `${field}.argv`), pattern };
61
+ }
62
+ function hosts(value, field) {
63
+ return Object.fromEntries(Object.entries(object(value, PROJECT_HOSTS, field)).map(([host, item]) => [host, step(item, `${field}.${host}`)]));
64
+ }
65
+ function latest(value) {
66
+ const item = object(value, ["github", "npm", "argv", "pattern"], "latest");
67
+ if (Object.keys(item).length === 1 && item.github !== undefined) {
68
+ const github = text(item.github, "latest.github");
69
+ if (!new RegExp(APP_GITHUB_PATTERN).test(github))
70
+ throw new Error("latest.github: expected owner/repo");
71
+ return { github };
72
+ }
73
+ if (Object.keys(item).length === 1 && item.npm !== undefined) {
74
+ const npm = text(item.npm, "latest.npm");
75
+ if (npm.length > 214 || !new RegExp(APP_NPM_PATTERN).test(npm) || ["node_modules", "favicon.ico"].includes(npm)) {
76
+ throw new Error("latest.npm: expected a valid npm package name");
77
+ }
78
+ return { npm };
79
+ }
80
+ return version(item, "latest");
81
+ }
82
+ export function parseAppRecipe(value) {
83
+ const item = object(value, [
84
+ "name", "platform", "homepage", "install", "update", "uninstall", "version",
85
+ "latest", "setup", "teardown", "skills",
86
+ ], "recipe");
87
+ if (item.platform !== undefined && (typeof item.platform !== "string" || !["linux", "windows", "darwin"].includes(item.platform))) {
88
+ throw new Error("platform: unsupported platform");
89
+ }
90
+ let skills;
91
+ if (item.skills !== undefined) {
92
+ if (!Array.isArray(item.skills))
93
+ throw new Error("skills: expected a list");
94
+ skills = [];
95
+ for (const [index, skill] of item.skills.entries()) {
96
+ const pattern = text(skill, `skills[${index}]`);
97
+ if (!new RegExp(APP_SKILL_PATTERN, "u").test(pattern)) {
98
+ throw new Error(`skills[${index}]: expected a non-empty name pattern without path separators or NUL, and not only stars`);
99
+ }
100
+ skills.push(pattern);
101
+ }
102
+ }
103
+ return {
104
+ name: text(item.name, "name"),
105
+ install: step(item.install, "install"),
106
+ update: step(item.update, "update"),
107
+ uninstall: step(item.uninstall, "uninstall"),
108
+ version: version(item.version, "version"),
109
+ ...(item.platform === undefined ? {} : { platform: item.platform }),
110
+ ...(item.homepage === undefined ? {} : { homepage: text(item.homepage, "homepage") }),
111
+ ...(item.latest === undefined ? {} : { latest: latest(item.latest) }),
112
+ ...(item.setup === undefined ? {} : { setup: hosts(item.setup, "setup") }),
113
+ ...(item.teardown === undefined ? {} : { teardown: hosts(item.teardown, "teardown") }),
114
+ ...(skills === undefined ? {} : { skills }),
115
+ };
116
+ }
@@ -0,0 +1,45 @@
1
+ import { APP_GITHUB_PATTERN, APP_NPM_PATTERN, APP_SKILL_PATTERN } from "./app-recipes.js";
2
+ import { PROJECT_HOSTS } from "./project-manifest.js";
3
+ const argv = {
4
+ type: "array", minItems: 1, items: { type: "string", minLength: 1 },
5
+ description: "Bare no-shell executable and arguments; user-method safety rules also apply.",
6
+ };
7
+ const text = { type: "string", minLength: 1 };
8
+ const step = {
9
+ type: "object", additionalProperties: false,
10
+ properties: { argv, manual: text },
11
+ anyOf: [{ required: ["argv"] }, { required: ["manual"] }],
12
+ };
13
+ const version = {
14
+ type: "object", additionalProperties: false, required: ["argv", "pattern"],
15
+ properties: {
16
+ argv, pattern: { ...text, description: "Valid JavaScript regex with exactly one capture group." },
17
+ },
18
+ };
19
+ const hosts = {
20
+ type: "object", additionalProperties: false,
21
+ properties: Object.fromEntries(PROJECT_HOSTS.map((host) => [host, step])),
22
+ };
23
+ export const APP_RECIPE_SCHEMA = {
24
+ $schema: "https://json-schema.org/draft/2020-12/schema",
25
+ title: "Agent Depot App recipe", type: "object", additionalProperties: false,
26
+ required: ["name", "install", "update", "uninstall", "version"],
27
+ properties: {
28
+ name: text, platform: { enum: ["linux", "windows", "darwin"] }, homepage: text,
29
+ install: step, update: step, uninstall: step, version,
30
+ latest: {
31
+ oneOf: [
32
+ version,
33
+ {
34
+ type: "object", additionalProperties: false, required: ["github"],
35
+ properties: { github: { ...text, pattern: APP_GITHUB_PATTERN } },
36
+ },
37
+ {
38
+ type: "object", additionalProperties: false, required: ["npm"],
39
+ properties: { npm: { ...text, pattern: APP_NPM_PATTERN, maxLength: 214, not: { enum: ["node_modules", "favicon.ico"] } } },
40
+ },
41
+ ],
42
+ },
43
+ setup: hosts, teardown: hosts, skills: { type: "array", items: { ...text, pattern: APP_SKILL_PATTERN } },
44
+ },
45
+ };
@@ -0,0 +1,46 @@
1
+ /** Strict SemVer ordering; unlike Skill's permissive comparator, opaque inputs stay distinguishable. */
2
+ export function compareAppVersions(left, right) {
3
+ const parse = (value) => {
4
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+[\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*)?$/u.exec(normalizeAppVersion(value));
5
+ if (!match)
6
+ return undefined;
7
+ const pre = match[4]?.split(".") ?? [];
8
+ if (pre.some(part => /^\d+$/u.test(part) && /^0\d/u.test(part)))
9
+ return undefined;
10
+ return { core: match.slice(1, 4).map(part => BigInt(part)), pre };
11
+ };
12
+ const a = parse(left);
13
+ const b = parse(right);
14
+ if (!a || !b)
15
+ return undefined;
16
+ for (let index = 0; index < 3; index++) {
17
+ if (a.core[index] !== b.core[index])
18
+ return a.core[index] < b.core[index] ? -1 : 1;
19
+ }
20
+ if (!a.pre.length || !b.pre.length)
21
+ return Math.sign(b.pre.length - a.pre.length);
22
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index++) {
23
+ const x = a.pre[index];
24
+ const y = b.pre[index];
25
+ if (x === y)
26
+ continue;
27
+ if (x === undefined)
28
+ return -1;
29
+ if (y === undefined)
30
+ return 1;
31
+ const numericX = /^\d+$/u.test(x);
32
+ const numericY = /^\d+$/u.test(y);
33
+ if (numericX && numericY)
34
+ return BigInt(x) < BigInt(y) ? -1 : 1;
35
+ if (numericX !== numericY)
36
+ return numericX ? -1 : 1;
37
+ return x < y ? -1 : 1;
38
+ }
39
+ return 0;
40
+ }
41
+ export function normalizeAppVersion(version) {
42
+ return version.replace(/^v(?=\d)/u, "");
43
+ }
44
+ export function sameAppVersion(left, right) {
45
+ return compareAppVersions(left, right) === 0 || normalizeAppVersion(left) === normalizeAppVersion(right);
46
+ }
@@ -0,0 +1,26 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { rename, unlink, writeFile } from "node:fs/promises";
3
+ /** Write a unique temporary sibling, then publish it with rename. */
4
+ export async function writeFileAtomically(file, contents, options = {}) {
5
+ const temporaryPath = `${file}.${process.pid}.${randomUUID()}.tmp`;
6
+ try {
7
+ await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx", mode: options.mode });
8
+ try {
9
+ await rename(temporaryPath, file);
10
+ }
11
+ catch (error) {
12
+ const code = error.code;
13
+ if (!options.allowLockedReplaceFallback || !["EEXIST", "EPERM", "ENOTEMPTY"].includes(code ?? "")) {
14
+ throw error;
15
+ }
16
+ // Source state holds its read-modify-write lock while using this Windows fallback.
17
+ await writeFile(file, contents, "utf8");
18
+ }
19
+ }
20
+ finally {
21
+ await unlink(temporaryPath).catch((error) => {
22
+ if (error.code !== "ENOENT")
23
+ throw error;
24
+ });
25
+ }
26
+ }
package/dist/src/cli.js CHANGED
@@ -3,6 +3,8 @@ import { realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import path from "node:path";
6
+ import { checkAppUpdates, createAppOperations } from "./app-flow.js";
7
+ import { describeAppResult, describeManualCompletion, runAppCommand } from "./app-cli.js";
6
8
  import { pathsOverlap } from "./path-safety.js";
7
9
  import { skillTreeBaseline } from "./skill-discovery.js";
8
10
  import { BuiltInSourceError, createSourceOperations, resolveProjectSource, SourceMetadataError, SourceNotFoundError, } from "./sources.js";
@@ -28,11 +30,17 @@ const USAGE = [
28
30
  " 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]",
29
31
  " agent-depot install --scope project --manifest --portable-v1 [--yes] [--confirm-additional-host]",
30
32
  " agent-depot update check --scope <project|user-global>",
31
- " agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>...) [--yes] [--confirm-path <relative-path>...]",
33
+ " agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>... | --app <name>...) [--yes] [--confirm-path <relative-path>...]",
34
+ " Apps run after Skills; --all --scope project skips Apps.",
32
35
  " agent-depot skill remove <id|path>... [--yes]",
33
36
  " agent-depot skill remove <id|path> --host <host>... [--yes]",
34
37
  " agent-depot skill host add <id|path> --host <host>... [--yes] [--confirm-additional-host]",
35
38
  " agent-depot uninstall [--skills] [--unmanaged-skill <exact-global-path>...] [--data] [--cli] [--yes]",
39
+ " agent-depot app schema|list",
40
+ " agent-depot app validate <file>...",
41
+ " agent-depot app approve <name> [--yes]",
42
+ " agent-depot app install|update|uninstall <name> [--yes] [--manual-done]",
43
+ " agent-depot app uninstall <name> --forget [--yes]",
36
44
  " agent-depot tui",
37
45
  " agent-depot --version",
38
46
  ].join("\n");
@@ -60,6 +68,7 @@ const SOURCE_SUBCOMMANDS = new Map([
60
68
  ["migrate", (values, { operations, dependencies, output }) => runSourceMigration(values, operations, dependencies, output)],
61
69
  ]);
62
70
  const COMMANDS = new Map([
71
+ ["app", (values, { dependencies, output }) => runAppCommand(values, dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory }), output)],
63
72
  ["install", async (values, { operations, dependencies, output }) => {
64
73
  await runInstall(values, operations, dependencies, output);
65
74
  return 0;
@@ -669,21 +678,73 @@ async function runUninstall(argv, operations, dependencies, output) {
669
678
  }
670
679
  async function runUpdate(argv, operations, dependencies, output) {
671
680
  const options = parseUpdateOptions(argv);
672
- const { assessment, installed, context } = await loadUpdates({
673
- scope: options.scope,
674
- operations,
675
- ...(dependencies.homeDirectory === undefined ? {} : { homeDirectory: dependencies.homeDirectory }),
676
- ...(dependencies.projectRoot === undefined ? {} : { projectRoot: dependencies.projectRoot }),
677
- ...(dependencies.sourceAccess === undefined ? {} : { sourceAccess: dependencies.sourceAccess }),
678
- ...(dependencies.projectManifestStore === undefined ? {} : { projectManifestStore: dependencies.projectManifestStore }),
679
- ...(dependencies.installationOptions?.fileSystem === undefined ? {} : { installationFileSystem: dependencies.installationOptions.fileSystem }),
680
- });
681
- const userGlobalInventory = options.action === "check" && options.scope === "user-global"
682
- ? await scanUserGlobalSkillInventory({ homeDirectory: context.projectRoot, managedInstallations: installed })
683
- : undefined;
684
- outputUpdateAssessment(assessment, options.scope, output, userGlobalInventory);
681
+ const apps = dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory });
682
+ let appUpdates = [];
683
+ let appCheckFailed = false;
684
+ if (options.scope === "user-global") {
685
+ try {
686
+ appUpdates = await checkAppUpdates(apps);
687
+ }
688
+ catch (error) {
689
+ appCheckFailed = true;
690
+ output(`Error (Apps): ${error instanceof Error ? error.message : String(error)}`);
691
+ }
692
+ }
693
+ let loaded;
694
+ try {
695
+ loaded = await loadUpdates({
696
+ scope: options.scope,
697
+ operations,
698
+ ...(dependencies.homeDirectory === undefined ? {} : { homeDirectory: dependencies.homeDirectory }),
699
+ ...(dependencies.projectRoot === undefined ? {} : { projectRoot: dependencies.projectRoot }),
700
+ ...(dependencies.sourceAccess === undefined ? {} : { sourceAccess: dependencies.sourceAccess }),
701
+ ...(dependencies.projectManifestStore === undefined ? {} : { projectManifestStore: dependencies.projectManifestStore }),
702
+ ...(dependencies.installationOptions?.fileSystem === undefined ? {} : { installationFileSystem: dependencies.installationOptions.fileSystem }),
703
+ });
704
+ }
705
+ catch (error) {
706
+ output(`Error (Skills, ${options.scope}): ${error instanceof Error ? error.message : String(error)}`);
707
+ }
708
+ if (loaded) {
709
+ const { assessment, installed, context } = loaded;
710
+ const userGlobalInventory = options.action === "check" && options.scope === "user-global"
711
+ ? await scanUserGlobalSkillInventory({ homeDirectory: context.projectRoot, managedInstallations: installed, reportWarning: output })
712
+ : undefined;
713
+ outputUpdateAssessment(assessment, options.scope, output, userGlobalInventory);
714
+ }
715
+ for (const item of appUpdates) {
716
+ output(`App ${item.entry.recipe?.name ?? item.entry.file}: ${item.status}; installed ${item.installedVersion ?? "unknown"}; latest ${item.latestVersion ?? "unknown"}${item.reason ? `; ${item.reason}` : ""}`);
717
+ }
718
+ if (!loaded)
719
+ return 1;
685
720
  if (options.action === "check") {
686
- return 0;
721
+ return appCheckFailed ? 1 : 0;
722
+ }
723
+ const { assessment, context } = loaded;
724
+ const selectedApps = options.all ? appUpdates.filter(item => item.status === "update available")
725
+ : options.requestedApps.map(name => {
726
+ const matches = appUpdates.filter(item => item.entry.recipe?.name === name);
727
+ if (matches.length !== 1 || matches[0].status !== "update available") {
728
+ throw new CliUsageError(`App ${JSON.stringify(name)} is unknown or not updateable`);
729
+ }
730
+ return matches[0];
731
+ });
732
+ const appPlans = [];
733
+ let appFailed = appCheckFailed ? 1 : 0;
734
+ for (const item of selectedApps) {
735
+ try {
736
+ const plan = await apps.planLifecycle(item.entry, "update", undefined, item);
737
+ output(`Preview: update App ${item.entry.recipe.name}: ${JSON.stringify(plan.argv ?? [])}; executable ${plan.executable ?? "unresolved"}; cwd ${plan.cwd}; environment ${plan.environment}`);
738
+ if (plan.warning)
739
+ output(plan.warning);
740
+ if (plan.manual)
741
+ output(`Manual (never executed): ${plan.manual}`);
742
+ appPlans.push(plan);
743
+ }
744
+ catch (error) {
745
+ appFailed++;
746
+ output(`App ${item.entry.recipe.name}: preview failed: ${error instanceof Error ? error.message : String(error)}`);
747
+ }
687
748
  }
688
749
  const selected = selectUpdateCandidates(assessment, options);
689
750
  const { plans, failures: previewFailures, nonCanonicalPaths } = await previewUpdateCandidates(selected, context);
@@ -717,10 +778,32 @@ async function runUpdate(argv, operations, dependencies, output) {
717
778
  for (const item of result.failed) {
718
779
  output(`Failed Skill ${JSON.stringify(item.selection.path)} (${item.id}): ${item.error ?? "unknown error"}`);
719
780
  }
720
- output(`Update summary: ${result.updated.length} updated, ${result.failed.length} failed`);
721
- return result.failed.length === 0 ? 0 : 1;
781
+ let appUpdated = 0;
782
+ for (const plan of appPlans) {
783
+ try {
784
+ const outcome = await apps.executeLifecycle(plan, true);
785
+ output(`App ${plan.entry.recipe.name}: ${describeAppResult(outcome)}${outcome.reason ? `: ${outcome.reason}` : ""}`);
786
+ if (outcome.status === "installed")
787
+ appUpdated++;
788
+ else {
789
+ appFailed++;
790
+ if (outcome.manual) {
791
+ output(`Manual (never executed): ${outcome.manual}`);
792
+ output(describeManualCompletion(plan));
793
+ }
794
+ }
795
+ }
796
+ catch (error) {
797
+ appFailed++;
798
+ output(`App ${plan.entry.recipe.name}: failed: ${error instanceof Error ? error.message : String(error)}`);
799
+ }
800
+ }
801
+ output(`Update summary: ${result.updated.length + appUpdated} updated, ${result.failed.length + appFailed} failed`);
802
+ return result.failed.length + appFailed === 0 ? 0 : 1;
722
803
  }
723
804
  function selectUpdateCandidates(assessment, options) {
805
+ if (!options.all && !options.requested.length)
806
+ return [];
724
807
  const requested = options.all
725
808
  ? "all"
726
809
  : resolveUpdateSelectionIds(assessment, options.requested);
@@ -742,6 +825,7 @@ function parseUpdateOptions(argv) {
742
825
  let all = false;
743
826
  let confirmed = false;
744
827
  const requested = [];
828
+ const requestedApps = [];
745
829
  const confirmedPaths = [];
746
830
  for (let index = 1; index < argv.length; index += 1) {
747
831
  switch (argv[index]) {
@@ -754,6 +838,9 @@ function parseUpdateOptions(argv) {
754
838
  case "--all":
755
839
  all = true;
756
840
  break;
841
+ case "--app":
842
+ requestedApps.push(requireOptionValue(argv, ++index, "--app"));
843
+ break;
757
844
  case "--skill":
758
845
  requested.push(requireOptionValue(argv, ++index, "--skill"));
759
846
  break;
@@ -767,12 +854,16 @@ function parseUpdateOptions(argv) {
767
854
  if (scope !== "project" && scope !== "user-global") {
768
855
  throw new CliUsageError("Update scope must be explicit: use --scope project or --scope user-global");
769
856
  }
770
- if (action === "check" && (all || requested.length > 0 || confirmed || confirmedPaths.length > 0)) {
857
+ if (action === "check" && (all || requested.length > 0 || requestedApps.length > 0 || confirmed || confirmedPaths.length > 0)) {
771
858
  throw new CliUsageError("update check only accepts --scope; use update apply to select and apply updates");
772
859
  }
773
- if (action === "apply" && (all === (requested.length > 0))) {
774
- throw new CliUsageError("update apply requires exactly one selection mode: --all or one or more --skill values");
860
+ if (action === "apply" && (all === (requested.length + requestedApps.length > 0))) {
861
+ throw new CliUsageError("update apply requires exactly one selection mode: --all or one or more --skill/--app values");
775
862
  }
863
+ if (requestedApps.length && scope !== "user-global")
864
+ throw new CliUsageError("Apps are user-global only");
865
+ if (new Set(requestedApps).size !== requestedApps.length)
866
+ throw new CliUsageError("Duplicate App selection");
776
867
  if (scope === "user-global" && confirmedPaths.length > 0) {
777
868
  throw new CliUsageError("--confirm-path only applies to --scope project");
778
869
  }
@@ -781,6 +872,7 @@ function parseUpdateOptions(argv) {
781
872
  scope: scope,
782
873
  all,
783
874
  requested: Object.freeze(requested),
875
+ requestedApps: Object.freeze(requestedApps),
784
876
  confirmed,
785
877
  confirmedPaths: Object.freeze(confirmedPaths),
786
878
  };
@@ -10,6 +10,7 @@ export function runProcess(command, args, options = {}) {
10
10
  const stdout = [];
11
11
  let stdoutBytes = 0;
12
12
  let stderr = "";
13
+ let stderrBytes = 0;
13
14
  let outputTooLarge = false;
14
15
  child.stdout?.on("data", (chunk) => {
15
16
  stdoutBytes += chunk.length;
@@ -22,7 +23,15 @@ export function runProcess(command, args, options = {}) {
22
23
  });
23
24
  child.stderr?.setEncoding("utf8");
24
25
  child.stderr?.on("data", (chunk) => {
25
- stderr += chunk;
26
+ const bytes = Buffer.from(chunk);
27
+ const remaining = options.maxStderrBytes === undefined
28
+ ? bytes.length : Math.max(0, options.maxStderrBytes - stderrBytes);
29
+ stderrBytes += bytes.length;
30
+ stderr += bytes.subarray(0, remaining).toString("utf8");
31
+ if (options.maxStderrBytes !== undefined && stderrBytes > options.maxStderrBytes) {
32
+ outputTooLarge = true;
33
+ child.kill();
34
+ }
26
35
  });
27
36
  child.on("error", reject);
28
37
  child.on("close", (code, signal) => {
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { assertSkillNotAppOwned } from "./app-owned-skills.js";
2
3
  import { pathsOverlap } from "./path-safety.js";
3
4
  import { inspectProjectSkillInstallation, installProjectSkillTransaction, validateProjectSkillInstallationPreview, } from "./project-installation.js";
4
5
  import { AGENT_DEPOT_PACKAGE_VERSION, parseProjectManifest, } from "./project-manifest.js";
@@ -311,6 +312,7 @@ export async function planSingleInstall(request, context) {
311
312
  throw new CliUsageError(`Skill selection ${JSON.stringify(selection.path)} is already recorded in ${context.recordedIn}; already managed Skills must be changed with update apply`);
312
313
  }
313
314
  const { resolvedSource, resolved, installationInspection } = await resolveInstallInspection(selection, source, projectSource, context.root, request, context.operations, context.environment, context.sourceAccess);
315
+ await assertInstallNotAppOwned(request, context, installationInspection);
314
316
  return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection };
315
317
  }
316
318
  /** Prints the preview the user confirms before {@link executeSingleInstall}. */
@@ -324,6 +326,7 @@ export function outputSingleInstallPreview(plan, output) {
324
326
  */
325
327
  export async function executeSingleInstall(plan, persist, output) {
326
328
  const { request, context, selection, resolvedSource, resolved, inspection } = plan;
329
+ await assertInstallNotAppOwned(request, context, inspection);
327
330
  const protectedPaths = assertNoManagedInstallationOverlap(context.existing, selection, inspection, context.root, request.overwrite);
328
331
  rejectInstallationCollision(inspection, request.overwrite);
329
332
  const transaction = await installOne(selection, resolvedSource, context.sourceAccess, context.root, context.environment, resolved.method, request.portableV1, resolved.files, resolved.resolvedVersion, request.confirmAdditionalHostExposure, request.overwrite, inspection, protectedPaths);
@@ -360,3 +363,12 @@ export function needsAdditionalHostExposure(inspection, hosts) {
360
363
  const missingCanonical = hosts.some((host) => host !== "claude") && !identical.includes(inspection.canonicalPath);
361
364
  return missingClaude || missingCanonical;
362
365
  }
366
+ async function assertInstallNotAppOwned(request, context, inspection) {
367
+ if (request.scope !== "user-global")
368
+ return;
369
+ for (const location of inspection.locations) {
370
+ if (location.status === "missing")
371
+ continue;
372
+ await assertSkillNotAppOwned(path.basename(location.path), { ...context.environment, homeDirectory: context.root });
373
+ }
374
+ }
@@ -1,8 +1,8 @@
1
- import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises";
2
- import { randomUUID } from "node:crypto";
1
+ import { mkdir, readFile, rmdir, unlink } from "node:fs/promises";
3
2
  import { homedir } from "node:os";
4
3
  import { setTimeout as delay } from "node:timers/promises";
5
4
  import path from "node:path";
5
+ import { writeFileAtomically } from "./atomic-file.js";
6
6
  import { canonicalizeGitSourceUrl, sourceIdForUrl } from "./git-source.js";
7
7
  import { parseProjectManifest } from "./project-manifest.js";
8
8
  const EMPTY_STATE = Object.freeze({
@@ -204,28 +204,7 @@ export class SourceStateStore {
204
204
  const normalized = parseState(state, this.path);
205
205
  await mkdir(path.dirname(this.path), { recursive: true });
206
206
  const contents = `${JSON.stringify(normalized, null, 2)}\n`;
207
- const temporaryPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
208
- try {
209
- await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
210
- try {
211
- await rename(temporaryPath, this.path);
212
- }
213
- catch (error) {
214
- if (!isWindowsReplaceError(error)) {
215
- throw error;
216
- }
217
- // Windows may not replace an existing file with rename. The lock makes
218
- // this fallback safe from concurrent read-modify-write operations.
219
- await writeFile(this.path, contents, "utf8");
220
- }
221
- }
222
- finally {
223
- await unlink(temporaryPath).catch((error) => {
224
- if (!isMissingFile(error)) {
225
- throw error;
226
- }
227
- });
228
- }
207
+ await writeFileAtomically(this.path, contents, { allowLockedReplaceFallback: true });
229
208
  }
230
209
  async acquireLock() {
231
210
  const lockPath = `${this.path}.lock`;
@@ -259,7 +238,3 @@ function isMissingFile(error) {
259
238
  function isAlreadyExists(error) {
260
239
  return errorCode(error) === "EEXIST";
261
240
  }
262
- function isWindowsReplaceError(error) {
263
- const code = errorCode(error);
264
- return code === "EEXIST" || code === "EPERM" || code === "ENOTEMPTY";
265
- }