@enactprotocol/shared 2.2.4 → 2.3.4

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 (111) hide show
  1. package/README.md +1 -18
  2. package/dist/config.d.ts +12 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +32 -6
  5. package/dist/config.js.map +1 -1
  6. package/dist/execution/action-command.d.ts +131 -0
  7. package/dist/execution/action-command.d.ts.map +1 -0
  8. package/dist/execution/action-command.js +300 -0
  9. package/dist/execution/action-command.js.map +1 -0
  10. package/dist/execution/command.d.ts +8 -8
  11. package/dist/execution/command.js +6 -6
  12. package/dist/execution/index.d.ts +1 -0
  13. package/dist/execution/index.d.ts.map +1 -1
  14. package/dist/execution/index.js +2 -0
  15. package/dist/execution/index.js.map +1 -1
  16. package/dist/execution/types.d.ts +5 -2
  17. package/dist/execution/types.d.ts.map +1 -1
  18. package/dist/execution/types.js.map +1 -1
  19. package/dist/index.d.ts +8 -6
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +11 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/manifest/actions-loader.d.ts +29 -0
  24. package/dist/manifest/actions-loader.d.ts.map +1 -0
  25. package/dist/manifest/actions-loader.js +34 -0
  26. package/dist/manifest/actions-loader.js.map +1 -0
  27. package/dist/manifest/actions-parser.d.ts +69 -0
  28. package/dist/manifest/actions-parser.d.ts.map +1 -0
  29. package/dist/manifest/actions-parser.js +265 -0
  30. package/dist/manifest/actions-parser.js.map +1 -0
  31. package/dist/manifest/index.d.ts +2 -0
  32. package/dist/manifest/index.d.ts.map +1 -1
  33. package/dist/manifest/index.js +4 -0
  34. package/dist/manifest/index.js.map +1 -1
  35. package/dist/manifest/loader.d.ts +7 -2
  36. package/dist/manifest/loader.d.ts.map +1 -1
  37. package/dist/manifest/loader.js +71 -4
  38. package/dist/manifest/loader.js.map +1 -1
  39. package/dist/manifest/parser.d.ts +1 -0
  40. package/dist/manifest/parser.d.ts.map +1 -1
  41. package/dist/manifest/parser.js +1 -0
  42. package/dist/manifest/parser.js.map +1 -1
  43. package/dist/manifest/scripts.d.ts +19 -0
  44. package/dist/manifest/scripts.d.ts.map +1 -0
  45. package/dist/manifest/scripts.js +102 -0
  46. package/dist/manifest/scripts.js.map +1 -0
  47. package/dist/manifest/validator.d.ts +1 -8
  48. package/dist/manifest/validator.d.ts.map +1 -1
  49. package/dist/manifest/validator.js +14 -13
  50. package/dist/manifest/validator.js.map +1 -1
  51. package/dist/mcp-registry.js +5 -5
  52. package/dist/mcp-registry.js.map +1 -1
  53. package/dist/paths.d.ts +9 -2
  54. package/dist/paths.d.ts.map +1 -1
  55. package/dist/paths.js +12 -3
  56. package/dist/paths.js.map +1 -1
  57. package/dist/registry.d.ts +3 -2
  58. package/dist/registry.d.ts.map +1 -1
  59. package/dist/registry.js +5 -5
  60. package/dist/registry.js.map +1 -1
  61. package/dist/resolver.d.ts +55 -4
  62. package/dist/resolver.d.ts.map +1 -1
  63. package/dist/resolver.js +133 -75
  64. package/dist/resolver.js.map +1 -1
  65. package/dist/types/actions.d.ts +194 -0
  66. package/dist/types/actions.d.ts.map +1 -0
  67. package/dist/types/actions.js +32 -0
  68. package/dist/types/actions.js.map +1 -0
  69. package/dist/types/index.d.ts +3 -1
  70. package/dist/types/index.d.ts.map +1 -1
  71. package/dist/types/index.js +1 -0
  72. package/dist/types/index.js.map +1 -1
  73. package/dist/types/manifest.d.ts +50 -5
  74. package/dist/types/manifest.d.ts.map +1 -1
  75. package/dist/types/manifest.js +10 -2
  76. package/dist/types/manifest.js.map +1 -1
  77. package/package.json +2 -2
  78. package/src/config.ts +48 -6
  79. package/src/execution/action-command.ts +417 -0
  80. package/src/execution/command.ts +8 -8
  81. package/src/execution/index.ts +17 -0
  82. package/src/execution/types.ts +13 -2
  83. package/src/index.ts +37 -0
  84. package/src/manifest/actions-loader.ts +49 -0
  85. package/src/manifest/index.ts +12 -0
  86. package/src/manifest/loader.ts +77 -4
  87. package/src/manifest/parser.ts +1 -0
  88. package/src/manifest/scripts.ts +116 -0
  89. package/src/manifest/validator.ts +15 -14
  90. package/src/mcp-registry.ts +5 -5
  91. package/src/paths.ts +13 -3
  92. package/src/registry.ts +5 -5
  93. package/src/resolver.ts +172 -77
  94. package/src/types/actions.ts +223 -0
  95. package/src/types/index.ts +11 -0
  96. package/src/types/manifest.ts +67 -6
  97. package/tests/action-command.test.ts +249 -0
  98. package/tests/config-normalization.test.ts +279 -0
  99. package/tests/config.test.ts +4 -1
  100. package/tests/effective-input-schema.test.ts +86 -0
  101. package/tests/fixtures/valid-tool.md +5 -12
  102. package/tests/fixtures/valid-tool.yaml +3 -10
  103. package/tests/hooks.test.ts +177 -0
  104. package/tests/manifest/loader.test.ts +34 -20
  105. package/tests/manifest/parser.test.ts +11 -15
  106. package/tests/manifest/validator.test.ts +7 -17
  107. package/tests/manifest-types.test.ts +9 -11
  108. package/tests/paths.test.ts +11 -4
  109. package/tests/registry.test.ts +12 -11
  110. package/tests/resolver.test.ts +11 -7
  111. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * TypeScript types for Enact tool manifests
3
- * These types define the structure of SKILL.md (and legacy enact.yaml/enact.md) frontmatter
3
+ * These types define the structure of SKILL.md (and skill.yaml/legacy enact.yaml/enact.md) frontmatter
4
4
  */
5
5
 
6
6
  import type { JSONSchema7 } from "json-schema";
@@ -13,6 +13,8 @@ export interface EnvVariable {
13
13
  description: string;
14
14
  /** If true, stored in OS keyring; if false, stored in .env files */
15
15
  secret?: boolean;
16
+ /** If true, must be set before execution (no default fallback) */
17
+ required?: boolean;
16
18
  /** Default value if not set (only for non-secrets) */
17
19
  default?: string;
18
20
  }
@@ -50,6 +52,32 @@ export interface ToolAnnotations {
50
52
  openWorldHint?: boolean;
51
53
  }
52
54
 
55
+ /**
56
+ * Lifecycle hooks for tool installation and management
57
+ */
58
+ export interface ToolHooks {
59
+ /** Command(s) to run after the tool is installed/extracted (e.g., "npm install", "pip install -r requirements.txt") */
60
+ postinstall?: string | string[];
61
+ /** Build command(s) to run before execution (e.g., "pip install -r requirements.txt") */
62
+ build?: string | string[];
63
+ }
64
+
65
+ /**
66
+ * Script definition — either a simple command string or an expanded object
67
+ *
68
+ * Simple form: "python main.py {{url}}"
69
+ * Expanded form: { command: "python main.py {{url}}", description: "Scrape a URL" }
70
+ */
71
+ export type ScriptDefinition =
72
+ | string
73
+ | {
74
+ command: string;
75
+ description?: string;
76
+ inputSchema?: JSONSchema7;
77
+ outputSchema?: JSONSchema7;
78
+ annotations?: ToolAnnotations;
79
+ };
80
+
53
81
  /**
54
82
  * Resource requirements for tool execution
55
83
  */
@@ -76,7 +104,7 @@ export interface ToolExample {
76
104
 
77
105
  /**
78
106
  * Complete tool manifest structure
79
- * This represents the YAML frontmatter in SKILL.md (or legacy enact.md/enact.yaml)
107
+ * This represents the YAML frontmatter in SKILL.md (or skill.yaml/legacy enact.md/enact.yaml)
80
108
  */
81
109
  export interface ToolManifest {
82
110
  // ==================== Required Fields ====================
@@ -115,9 +143,6 @@ export interface ToolManifest {
115
143
 
116
144
  // ==================== Schema Fields ====================
117
145
 
118
- /** JSON Schema defining input parameters */
119
- inputSchema?: JSONSchema7;
120
-
121
146
  /** JSON Schema defining output structure */
122
147
  outputSchema?: JSONSchema7;
123
148
 
@@ -134,6 +159,28 @@ export interface ToolManifest {
134
159
  /** Resource limits and requirements */
135
160
  resources?: ResourceRequirements;
136
161
 
162
+ // ==================== Lifecycle Hooks ====================
163
+
164
+ /** Lifecycle hooks (e.g., postinstall build step) */
165
+ hooks?: ToolHooks;
166
+
167
+ // ==================== Scripts ====================
168
+
169
+ /**
170
+ * Inline executable scripts (each becomes an MCP tool via colon syntax)
171
+ *
172
+ * Scripts replace the need for a separate ACTIONS.yaml file.
173
+ * Each script maps a name to a command with {{param}} template syntax.
174
+ *
175
+ * @example
176
+ * scripts:
177
+ * scrape: python scripts/scrape.py {{url}}
178
+ * crawl:
179
+ * command: python scripts/crawl.py {{url}} {{depth}}
180
+ * description: Crawl a website to specified depth
181
+ */
182
+ scripts?: Record<string, ScriptDefinition>;
183
+
137
184
  // ==================== Agent Skills Spec Fields ====================
138
185
 
139
186
  /** Environment requirements (intended product, system packages, network access, etc.) */
@@ -252,14 +299,28 @@ export interface ToolResolution {
252
299
  manifestPath: string;
253
300
  /** Tool version (if available) */
254
301
  version?: string | undefined;
302
+ /** The resolved script (if a script was specified via colon syntax) */
303
+ action?: import("./actions").Action | undefined;
304
+ /** The requested script name (if specified via colon syntax) */
305
+ actionName?: string | undefined;
306
+ /** The scripts manifest (converted from inline scripts) */
307
+ actionsManifest?: import("./actions").ActionsManifest | undefined;
255
308
  }
256
309
 
257
310
  /**
258
311
  * Supported manifest file names
259
312
  * SKILL.md is the primary format (aligned with Anthropic Agent Skills)
313
+ * skill.yaml/yml is the package manifest
260
314
  * enact.md/yaml/yml are supported for backwards compatibility
261
315
  */
262
- export const MANIFEST_FILES = ["SKILL.md", "enact.md", "enact.yaml", "enact.yml"] as const;
316
+ export const MANIFEST_FILES = [
317
+ "SKILL.md",
318
+ "skill.yaml",
319
+ "skill.yml",
320
+ "enact.md",
321
+ "enact.yaml",
322
+ "enact.yml",
323
+ ] as const;
263
324
  export type ManifestFileName = (typeof MANIFEST_FILES)[number];
264
325
 
265
326
  /**
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tests for the action command interpolation module.
3
+ *
4
+ * Covers {{param}} template parsing, interpolation, omission of optionals,
5
+ * and the prepareActionCommand entry point.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import {
10
+ getActionCommandParams,
11
+ getMissingRequiredParams,
12
+ hasActionTemplates,
13
+ interpolateActionCommand,
14
+ parseActionArgument,
15
+ parseActionCommand,
16
+ prepareActionCommand,
17
+ } from "../src/execution/action-command";
18
+
19
+ describe("hasActionTemplates", () => {
20
+ test("returns true for string with {{param}}", () => {
21
+ expect(hasActionTemplates("hello {{name}}")).toBe(true);
22
+ });
23
+
24
+ test("returns false for plain string", () => {
25
+ expect(hasActionTemplates("echo hello")).toBe(false);
26
+ });
27
+
28
+ test("returns false for ${param} syntax", () => {
29
+ expect(hasActionTemplates("echo ${name}")).toBe(false);
30
+ });
31
+
32
+ test("returns true for standalone {{param}}", () => {
33
+ expect(hasActionTemplates("{{url}}")).toBe(true);
34
+ });
35
+ });
36
+
37
+ describe("parseActionArgument", () => {
38
+ test("parses a literal argument", () => {
39
+ const result = parseActionArgument("echo");
40
+ expect(result.tokens).toHaveLength(1);
41
+ expect(result.tokens[0]?.type).toBe("literal");
42
+ expect(result.tokens[0]?.type === "literal" && result.tokens[0].value).toBe("echo");
43
+ expect(result.parameters).toHaveLength(0);
44
+ });
45
+
46
+ test("parses a standalone parameter", () => {
47
+ const result = parseActionArgument("{{url}}");
48
+ expect(result.tokens).toHaveLength(1);
49
+ expect(result.tokens[0]?.type).toBe("parameter");
50
+ expect(result.tokens[0]?.type === "parameter" && result.tokens[0].name).toBe("url");
51
+ expect(result.parameters).toEqual(["url"]);
52
+ });
53
+
54
+ test("parses mixed literal and parameter", () => {
55
+ const result = parseActionArgument("--name={{name}}");
56
+ expect(result.tokens).toHaveLength(2);
57
+ expect(result.tokens[0]?.type).toBe("literal");
58
+ expect(result.tokens[1]?.type).toBe("parameter");
59
+ expect(result.parameters).toEqual(["name"]);
60
+ });
61
+
62
+ test("parses multiple parameters in one argument", () => {
63
+ const result = parseActionArgument("{{host}}:{{port}}");
64
+ expect(result.tokens).toHaveLength(3);
65
+ expect(result.parameters).toEqual(["host", "port"]);
66
+ });
67
+
68
+ test("trims whitespace in parameter names", () => {
69
+ const result = parseActionArgument("{{ name }}");
70
+ expect(result.parameters).toEqual(["name"]);
71
+ });
72
+ });
73
+
74
+ describe("parseActionCommand", () => {
75
+ test("parses command with no templates", () => {
76
+ const result = parseActionCommand(["echo", "hello", "world"]);
77
+ expect(result.allParameters).toHaveLength(0);
78
+ expect(result.arguments).toHaveLength(3);
79
+ });
80
+
81
+ test("parses command with templates", () => {
82
+ const result = parseActionCommand(["python", "main.py", "{{url}}"]);
83
+ expect(result.allParameters).toEqual(["url"]);
84
+ });
85
+
86
+ test("deduplicates parameter names", () => {
87
+ const result = parseActionCommand(["echo", "{{name}}", "{{name}}"]);
88
+ expect(result.allParameters).toEqual(["name"]);
89
+ });
90
+
91
+ test("collects all unique parameters", () => {
92
+ const result = parseActionCommand(["cmd", "{{a}}", "{{b}}", "{{c}}"]);
93
+ expect(result.allParameters).toEqual(["a", "b", "c"]);
94
+ });
95
+ });
96
+
97
+ describe("interpolateActionCommand", () => {
98
+ test("replaces parameter with value", () => {
99
+ const result = interpolateActionCommand(["echo", "{{name}}"], { name: "Alice" });
100
+ expect(result).toEqual(["echo", "Alice"]);
101
+ });
102
+
103
+ test("preserves literal arguments", () => {
104
+ const result = interpolateActionCommand(["python", "main.py", "--verbose"], {});
105
+ expect(result).toEqual(["python", "main.py", "--verbose"]);
106
+ });
107
+
108
+ test("handles mixed literal and parameter", () => {
109
+ const result = interpolateActionCommand(["--output={{format}}"], { format: "json" });
110
+ expect(result).toEqual(["--output=json"]);
111
+ });
112
+
113
+ test("converts number to string", () => {
114
+ const result = interpolateActionCommand(["echo", "{{count}}"], { count: 42 });
115
+ expect(result).toEqual(["echo", "42"]);
116
+ });
117
+
118
+ test("converts boolean to string", () => {
119
+ const result = interpolateActionCommand(["echo", "{{flag}}"], { flag: true });
120
+ expect(result).toEqual(["echo", "true"]);
121
+ });
122
+
123
+ test("converts object to JSON string", () => {
124
+ const result = interpolateActionCommand(["echo", "{{data}}"], { data: { key: "value" } });
125
+ expect(result).toEqual(["echo", '{"key":"value"}']);
126
+ });
127
+
128
+ test("omits argument for optional param with no value", () => {
129
+ const schema = {
130
+ type: "object" as const,
131
+ properties: {
132
+ name: { type: "string" as const },
133
+ verbose: { type: "boolean" as const },
134
+ },
135
+ required: ["name"],
136
+ };
137
+
138
+ const result = interpolateActionCommand(
139
+ ["echo", "{{name}}", "{{verbose}}"],
140
+ { name: "Alice" },
141
+ { inputSchema: schema }
142
+ );
143
+ // "verbose" is optional with no value → omitted
144
+ expect(result).toEqual(["echo", "Alice"]);
145
+ });
146
+
147
+ test("uses default value for optional param", () => {
148
+ const schema = {
149
+ type: "object" as const,
150
+ properties: {
151
+ format: { type: "string" as const, default: "text" },
152
+ },
153
+ };
154
+
155
+ const result = interpolateActionCommand(["echo", "{{format}}"], {}, { inputSchema: schema });
156
+ expect(result).toEqual(["echo", "text"]);
157
+ });
158
+
159
+ test("does not split values with spaces into multiple args", () => {
160
+ const result = interpolateActionCommand(["echo", "{{msg}}"], {
161
+ msg: "hello world with spaces",
162
+ });
163
+ // Must remain a single argument — security property
164
+ expect(result).toEqual(["echo", "hello world with spaces"]);
165
+ });
166
+ });
167
+
168
+ describe("getMissingRequiredParams", () => {
169
+ test("returns empty when all required params provided", () => {
170
+ const schema = {
171
+ type: "object" as const,
172
+ required: ["name"],
173
+ properties: { name: { type: "string" as const } },
174
+ };
175
+ const missing = getMissingRequiredParams(["echo", "{{name}}"], { name: "hi" }, schema);
176
+ expect(missing).toHaveLength(0);
177
+ });
178
+
179
+ test("returns missing required param names", () => {
180
+ const schema = {
181
+ type: "object" as const,
182
+ required: ["name", "age"],
183
+ properties: {
184
+ name: { type: "string" as const },
185
+ age: { type: "number" as const },
186
+ },
187
+ };
188
+ const missing = getMissingRequiredParams(
189
+ ["echo", "{{name}}", "{{age}}"],
190
+ { name: "Alice" },
191
+ schema
192
+ );
193
+ expect(missing).toEqual(["age"]);
194
+ });
195
+
196
+ test("does not report optional params as missing", () => {
197
+ const schema = {
198
+ type: "object" as const,
199
+ required: [],
200
+ properties: { verbose: { type: "boolean" as const } },
201
+ };
202
+ const missing = getMissingRequiredParams(["echo", "{{verbose}}"], {}, schema);
203
+ expect(missing).toHaveLength(0);
204
+ });
205
+ });
206
+
207
+ describe("getActionCommandParams", () => {
208
+ test("returns all parameter names from command", () => {
209
+ expect(getActionCommandParams(["cmd", "{{a}}", "{{b}}"])).toEqual(["a", "b"]);
210
+ });
211
+
212
+ test("returns empty for command without templates", () => {
213
+ expect(getActionCommandParams(["echo", "hello"])).toEqual([]);
214
+ });
215
+ });
216
+
217
+ describe("prepareActionCommand", () => {
218
+ test("interpolates and returns command array", () => {
219
+ const schema = {
220
+ type: "object" as const,
221
+ required: ["url"],
222
+ properties: { url: { type: "string" as const } },
223
+ };
224
+ const result = prepareActionCommand(
225
+ ["python", "main.py", "{{url}}"],
226
+ { url: "https://example.com" },
227
+ schema
228
+ );
229
+ expect(result).toEqual(["python", "main.py", "https://example.com"]);
230
+ });
231
+
232
+ test("throws when required parameters are missing", () => {
233
+ const schema = {
234
+ type: "object" as const,
235
+ required: ["url"],
236
+ properties: { url: { type: "string" as const } },
237
+ };
238
+ expect(() => prepareActionCommand(["python", "main.py", "{{url}}"], {}, schema)).toThrow(
239
+ "Missing required parameters: url"
240
+ );
241
+ });
242
+
243
+ test("works without schema (all params treated conservatively)", () => {
244
+ // Without schema, all params are treated as required
245
+ expect(() => prepareActionCommand(["echo", "{{name}}"], {})).toThrow(
246
+ "Missing required parameters: name"
247
+ );
248
+ });
249
+ });
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tests for config normalization — alias fields added in the README update.
3
+ *
4
+ * TrustConfig aliases: require_signatures → policy, trusted_publishers → auditors
5
+ * ExecutionConfig routing: default, fallback, trusted_scopes
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import yaml from "js-yaml";
13
+ import {
14
+ DEFAULT_CONFIG,
15
+ getTrustPolicy,
16
+ getTrustedIdentities,
17
+ loadConfig,
18
+ saveConfig,
19
+ } from "../src/config";
20
+
21
+ describe("config normalization", () => {
22
+ describe("TrustConfig aliases", () => {
23
+ test("require_signatures: true sets policy to require_attestation", () => {
24
+ const configPath = join(homedir(), ".enact", "config.yaml");
25
+ mkdirSync(join(homedir(), ".enact"), { recursive: true });
26
+ writeFileSync(
27
+ configPath,
28
+ yaml.dump({
29
+ trust: {
30
+ require_signatures: true,
31
+ },
32
+ }),
33
+ "utf-8"
34
+ );
35
+
36
+ const config = loadConfig();
37
+ expect(config.trust?.policy).toBe("require_attestation");
38
+ });
39
+
40
+ test("require_signatures: false sets policy to allow", () => {
41
+ const configPath = join(homedir(), ".enact", "config.yaml");
42
+ writeFileSync(
43
+ configPath,
44
+ yaml.dump({
45
+ trust: {
46
+ require_signatures: false,
47
+ },
48
+ }),
49
+ "utf-8"
50
+ );
51
+
52
+ const config = loadConfig();
53
+ expect(config.trust?.policy).toBe("allow");
54
+ });
55
+
56
+ test("explicit policy takes precedence over require_signatures", () => {
57
+ const configPath = join(homedir(), ".enact", "config.yaml");
58
+ writeFileSync(
59
+ configPath,
60
+ yaml.dump({
61
+ trust: {
62
+ policy: "prompt",
63
+ require_signatures: true,
64
+ },
65
+ }),
66
+ "utf-8"
67
+ );
68
+
69
+ const config = loadConfig();
70
+ // When policy is explicitly set, require_signatures should not override it
71
+ expect(config.trust?.policy).toBe("prompt");
72
+ });
73
+
74
+ test("trusted_publishers merges into auditors", () => {
75
+ const configPath = join(homedir(), ".enact", "config.yaml");
76
+ writeFileSync(
77
+ configPath,
78
+ yaml.dump({
79
+ trust: {
80
+ auditors: ["github:existing-user"],
81
+ trusted_publishers: ["@my-org"],
82
+ },
83
+ }),
84
+ "utf-8"
85
+ );
86
+
87
+ const config = loadConfig();
88
+ expect(config.trust?.auditors).toContain("github:existing-user");
89
+ expect(config.trust?.auditors).toContain("@my-org");
90
+ });
91
+
92
+ test("trusted_publishers does not create duplicates", () => {
93
+ const configPath = join(homedir(), ".enact", "config.yaml");
94
+ writeFileSync(
95
+ configPath,
96
+ yaml.dump({
97
+ trust: {
98
+ auditors: ["@my-org", "github:alice"],
99
+ trusted_publishers: ["@my-org"],
100
+ },
101
+ }),
102
+ "utf-8"
103
+ );
104
+
105
+ const config = loadConfig();
106
+ const orgCount = config.trust?.auditors?.filter((a) => a === "@my-org").length;
107
+ expect(orgCount).toBe(1);
108
+ });
109
+
110
+ test("trusted_publishers works without existing auditors", () => {
111
+ const configPath = join(homedir(), ".enact", "config.yaml");
112
+ writeFileSync(
113
+ configPath,
114
+ yaml.dump({
115
+ trust: {
116
+ trusted_publishers: ["@my-org", "@other-org"],
117
+ },
118
+ }),
119
+ "utf-8"
120
+ );
121
+
122
+ const config = loadConfig();
123
+ // Should merge with default auditors
124
+ expect(config.trust?.auditors).toContain("@my-org");
125
+ expect(config.trust?.auditors).toContain("@other-org");
126
+ });
127
+
128
+ test("getTrustPolicy respects require_signatures alias", () => {
129
+ const configPath = join(homedir(), ".enact", "config.yaml");
130
+ writeFileSync(
131
+ configPath,
132
+ yaml.dump({
133
+ trust: {
134
+ require_signatures: true,
135
+ },
136
+ }),
137
+ "utf-8"
138
+ );
139
+
140
+ expect(getTrustPolicy()).toBe("require_attestation");
141
+ });
142
+
143
+ test("getTrustedIdentities includes trusted_publishers", () => {
144
+ const configPath = join(homedir(), ".enact", "config.yaml");
145
+ writeFileSync(
146
+ configPath,
147
+ yaml.dump({
148
+ trust: {
149
+ auditors: ["github:alice"],
150
+ trusted_publishers: ["@acme"],
151
+ },
152
+ }),
153
+ "utf-8"
154
+ );
155
+
156
+ const identities = getTrustedIdentities();
157
+ expect(identities).toContain("github:alice");
158
+ expect(identities).toContain("@acme");
159
+ });
160
+ });
161
+
162
+ describe("ExecutionConfig routing fields", () => {
163
+ test("default execution backend is preserved", () => {
164
+ const configPath = join(homedir(), ".enact", "config.yaml");
165
+ writeFileSync(
166
+ configPath,
167
+ yaml.dump({
168
+ execution: {
169
+ default: "docker",
170
+ },
171
+ }),
172
+ "utf-8"
173
+ );
174
+
175
+ const config = loadConfig();
176
+ expect(config.execution?.default).toBe("docker");
177
+ });
178
+
179
+ test("fallback execution backend is preserved", () => {
180
+ const configPath = join(homedir(), ".enact", "config.yaml");
181
+ writeFileSync(
182
+ configPath,
183
+ yaml.dump({
184
+ execution: {
185
+ default: "container",
186
+ fallback: "remote",
187
+ },
188
+ }),
189
+ "utf-8"
190
+ );
191
+
192
+ const config = loadConfig();
193
+ expect(config.execution?.default).toBe("container");
194
+ expect(config.execution?.fallback).toBe("remote");
195
+ });
196
+
197
+ test("trusted_scopes is preserved as array", () => {
198
+ const configPath = join(homedir(), ".enact", "config.yaml");
199
+ writeFileSync(
200
+ configPath,
201
+ yaml.dump({
202
+ execution: {
203
+ default: "container",
204
+ fallback: "remote",
205
+ trusted_scopes: ["@my-org/*", "@internal/*"],
206
+ },
207
+ }),
208
+ "utf-8"
209
+ );
210
+
211
+ const config = loadConfig();
212
+ expect(config.execution?.trusted_scopes).toEqual(["@my-org/*", "@internal/*"]);
213
+ });
214
+
215
+ test("routing fields coexist with existing fields", () => {
216
+ const configPath = join(homedir(), ".enact", "config.yaml");
217
+ writeFileSync(
218
+ configPath,
219
+ yaml.dump({
220
+ execution: {
221
+ defaultTimeout: "1m",
222
+ verbose: true,
223
+ default: "docker",
224
+ fallback: "local",
225
+ trusted_scopes: ["@my-org/*"],
226
+ },
227
+ }),
228
+ "utf-8"
229
+ );
230
+
231
+ const config = loadConfig();
232
+ expect(config.execution?.defaultTimeout).toBe("1m");
233
+ expect(config.execution?.verbose).toBe(true);
234
+ expect(config.execution?.default).toBe("docker");
235
+ expect(config.execution?.fallback).toBe("local");
236
+ expect(config.execution?.trusted_scopes).toEqual(["@my-org/*"]);
237
+ });
238
+
239
+ test("missing routing fields default to undefined", () => {
240
+ saveConfig({ ...DEFAULT_CONFIG });
241
+ const config = loadConfig();
242
+ expect(config.execution?.default).toBeUndefined();
243
+ expect(config.execution?.fallback).toBeUndefined();
244
+ expect(config.execution?.trusted_scopes).toBeUndefined();
245
+ });
246
+ });
247
+
248
+ describe("full README-style config", () => {
249
+ test("parses complete README example correctly", () => {
250
+ const configPath = join(homedir(), ".enact", "config.yaml");
251
+ writeFileSync(
252
+ configPath,
253
+ yaml.dump({
254
+ trust: {
255
+ require_signatures: true,
256
+ trusted_publishers: ["@my-org"],
257
+ },
258
+ execution: {
259
+ default: "container",
260
+ fallback: "remote",
261
+ trusted_scopes: ["@my-org/*"],
262
+ },
263
+ }),
264
+ "utf-8"
265
+ );
266
+
267
+ const config = loadConfig();
268
+
269
+ // Trust
270
+ expect(config.trust?.policy).toBe("require_attestation");
271
+ expect(config.trust?.auditors).toContain("@my-org");
272
+
273
+ // Execution
274
+ expect(config.execution?.default).toBe("container");
275
+ expect(config.execution?.fallback).toBe("remote");
276
+ expect(config.execution?.trusted_scopes).toEqual(["@my-org/*"]);
277
+ });
278
+ });
279
+ });
@@ -534,7 +534,7 @@ describe("configuration manager", () => {
534
534
  expect(existsSync(enactHome)).toBe(true);
535
535
  });
536
536
 
537
- test("creates ~/.enact/cache/ directory", () => {
537
+ test("creates ~/.agent/skills/ directory", () => {
538
538
  const enactHome = getEnactHome();
539
539
  const cacheDir = getCacheDir();
540
540
 
@@ -542,6 +542,9 @@ describe("configuration manager", () => {
542
542
  if (existsSync(enactHome)) {
543
543
  rmSync(enactHome, { recursive: true, force: true });
544
544
  }
545
+ if (existsSync(cacheDir)) {
546
+ rmSync(cacheDir, { recursive: true, force: true });
547
+ }
545
548
 
546
549
  ensureGlobalSetup();
547
550