@codemcp/ade-harnesses 0.0.2 → 0.1.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 (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-format.log +1 -1
  3. package/.turbo/turbo-lint.log +1 -1
  4. package/.turbo/turbo-test.log +15 -12
  5. package/.turbo/turbo-typecheck.log +1 -1
  6. package/dist/permission-policy.d.ts +7 -0
  7. package/dist/permission-policy.js +152 -0
  8. package/dist/writers/claude-code.js +50 -18
  9. package/dist/writers/cline.js +2 -2
  10. package/dist/writers/copilot.js +61 -8
  11. package/dist/writers/cursor.js +48 -2
  12. package/dist/writers/kiro.js +54 -38
  13. package/dist/writers/opencode.js +26 -23
  14. package/dist/writers/roo-code.js +38 -2
  15. package/dist/writers/universal.js +41 -3
  16. package/dist/writers/windsurf.js +43 -1
  17. package/package.json +2 -2
  18. package/src/permission-policy.ts +173 -0
  19. package/src/writers/claude-code.spec.ts +160 -3
  20. package/src/writers/claude-code.ts +63 -18
  21. package/src/writers/cline.spec.ts +146 -3
  22. package/src/writers/cline.ts +2 -2
  23. package/src/writers/copilot.spec.ts +157 -1
  24. package/src/writers/copilot.ts +76 -9
  25. package/src/writers/cursor.spec.ts +104 -1
  26. package/src/writers/cursor.ts +65 -3
  27. package/src/writers/kiro.spec.ts +228 -0
  28. package/src/writers/kiro.ts +77 -40
  29. package/src/writers/opencode.spec.ts +258 -0
  30. package/src/writers/opencode.ts +40 -27
  31. package/src/writers/roo-code.spec.ts +129 -1
  32. package/src/writers/roo-code.ts +49 -2
  33. package/src/writers/universal.spec.ts +134 -0
  34. package/src/writers/universal.ts +57 -4
  35. package/src/writers/windsurf.spec.ts +111 -3
  36. package/src/writers/windsurf.ts +64 -2
  37. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type {
6
+ AutonomyProfile,
7
+ LogicalConfig,
8
+ PermissionPolicy
9
+ } from "@codemcp/ade-core";
10
+ import { kiroWriter } from "./kiro.js";
11
+
12
+ function autonomyPolicy(profile: AutonomyProfile): PermissionPolicy {
13
+ switch (profile) {
14
+ case "rigid":
15
+ return {
16
+ profile,
17
+ capabilities: {
18
+ read: "ask",
19
+ edit_write: "ask",
20
+ search_list: "ask",
21
+ bash_safe: "ask",
22
+ bash_unsafe: "ask",
23
+ web: "ask",
24
+ task_agent: "ask"
25
+ }
26
+ };
27
+ case "sensible-defaults":
28
+ return {
29
+ profile,
30
+ capabilities: {
31
+ read: "allow",
32
+ edit_write: "allow",
33
+ search_list: "allow",
34
+ bash_safe: "allow",
35
+ bash_unsafe: "ask",
36
+ web: "ask",
37
+ task_agent: "allow"
38
+ }
39
+ };
40
+ case "max-autonomy":
41
+ return {
42
+ profile,
43
+ capabilities: {
44
+ read: "allow",
45
+ edit_write: "allow",
46
+ search_list: "allow",
47
+ bash_safe: "allow",
48
+ bash_unsafe: "allow",
49
+ web: "ask",
50
+ task_agent: "allow"
51
+ }
52
+ };
53
+ }
54
+ }
55
+
56
+ describe("kiroWriter", () => {
57
+ let dir: string;
58
+
59
+ beforeEach(async () => {
60
+ dir = await mkdtemp(join(tmpdir(), "ade-harness-kiro-"));
61
+ });
62
+
63
+ afterEach(async () => {
64
+ await rm(dir, { recursive: true, force: true });
65
+ });
66
+
67
+ it("has correct metadata", () => {
68
+ expect(kiroWriter.id).toBe("kiro");
69
+ expect(kiroWriter.label).toBe("Kiro");
70
+ expect(kiroWriter.description).toContain(".kiro/agents/ade.json");
71
+ });
72
+
73
+ it("writes a JSON Kiro agent with documented built-in tool selectors", async () => {
74
+ const config: LogicalConfig = {
75
+ mcp_servers: [
76
+ {
77
+ ref: "workflows",
78
+ command: "npx",
79
+ args: ["-y", "@codemcp/workflows"],
80
+ env: {}
81
+ }
82
+ ],
83
+ instructions: ["Use project workflows."],
84
+ cli_actions: [],
85
+ knowledge_sources: [],
86
+ skills: [],
87
+ git_hooks: [],
88
+ setup_notes: []
89
+ };
90
+
91
+ await kiroWriter.install(config, dir);
92
+
93
+ const raw = await readFile(
94
+ join(dir, ".kiro", "agents", "ade.json"),
95
+ "utf-8"
96
+ );
97
+ const content = JSON.parse(raw);
98
+
99
+ expect(content.name).toBe("ade");
100
+ expect(content.mcpServers.workflows).toEqual({
101
+ command: "npx",
102
+ args: ["-y", "@codemcp/workflows"],
103
+ autoApprove: ["*"]
104
+ });
105
+ expect(content.tools).toEqual([
106
+ "read",
107
+ "write",
108
+ "shell",
109
+ "spec",
110
+ "@workflows/*"
111
+ ]);
112
+ expect(content.allowedTools).toEqual([
113
+ "read",
114
+ "write",
115
+ "shell",
116
+ "spec",
117
+ "@workflows/*"
118
+ ]);
119
+ expect(content.useLegacyMcpJson).toBe(true);
120
+ expect(content.tools).not.toContain("@workflows");
121
+ expect(content.prompt).toContain("Use project workflows.");
122
+ });
123
+
124
+ it("writes Kiro MCP settings and forwards provisioning trust via autoApprove", async () => {
125
+ const config: LogicalConfig = {
126
+ mcp_servers: [
127
+ {
128
+ ref: "workflows",
129
+ command: "npx",
130
+ args: ["-y", "@codemcp/workflows"],
131
+ env: { NODE_ENV: "test" },
132
+ allowedTools: ["use_skill", "whats_next"]
133
+ }
134
+ ],
135
+ instructions: [],
136
+ cli_actions: [],
137
+ knowledge_sources: [],
138
+ skills: [],
139
+ git_hooks: [],
140
+ setup_notes: []
141
+ };
142
+
143
+ await kiroWriter.install(config, dir);
144
+
145
+ const raw = await readFile(
146
+ join(dir, ".kiro", "settings", "mcp.json"),
147
+ "utf-8"
148
+ );
149
+ const parsed = JSON.parse(raw);
150
+
151
+ expect(parsed.mcpServers.workflows).toEqual({
152
+ command: "npx",
153
+ args: ["-y", "@codemcp/workflows"],
154
+ env: { NODE_ENV: "test" },
155
+ autoApprove: ["use_skill", "whats_next"]
156
+ });
157
+ });
158
+
159
+ it("maps autonomy only to built-in selectors and keeps web approval-gated", async () => {
160
+ const rigidRoot = join(dir, "rigid");
161
+ const maxRoot = join(dir, "max");
162
+
163
+ const baseConfig = {
164
+ mcp_servers: [
165
+ {
166
+ ref: "workflows",
167
+ command: "npx",
168
+ args: ["-y", "@codemcp/workflows"],
169
+ env: {},
170
+ allowedTools: ["*"]
171
+ }
172
+ ],
173
+ instructions: [],
174
+ cli_actions: [],
175
+ knowledge_sources: [],
176
+ skills: [],
177
+ git_hooks: [],
178
+ setup_notes: []
179
+ } satisfies LogicalConfig;
180
+
181
+ const rigidConfig: LogicalConfig = {
182
+ ...baseConfig,
183
+ permission_policy: autonomyPolicy("rigid")
184
+ };
185
+
186
+ const maxConfig: LogicalConfig = {
187
+ ...baseConfig,
188
+ permission_policy: autonomyPolicy("max-autonomy")
189
+ };
190
+
191
+ await kiroWriter.install(rigidConfig, rigidRoot);
192
+ await kiroWriter.install(maxConfig, maxRoot);
193
+
194
+ const rigidAgent = JSON.parse(
195
+ await readFile(join(rigidRoot, ".kiro", "agents", "ade.json"), "utf-8")
196
+ );
197
+ const maxAgent = JSON.parse(
198
+ await readFile(join(maxRoot, ".kiro", "agents", "ade.json"), "utf-8")
199
+ );
200
+ const rigidMcp = JSON.parse(
201
+ await readFile(join(rigidRoot, ".kiro", "settings", "mcp.json"), "utf-8")
202
+ );
203
+ const maxMcp = JSON.parse(
204
+ await readFile(join(maxRoot, ".kiro", "settings", "mcp.json"), "utf-8")
205
+ );
206
+
207
+ expect(rigidAgent.tools).toContain("read");
208
+ expect(rigidAgent.tools).toContain("spec");
209
+ expect(rigidAgent.tools).toContain("@workflows/*");
210
+ expect(rigidAgent.allowedTools).toContain("@workflows/*");
211
+ expect(rigidAgent.mcpServers.workflows.autoApprove).toEqual(["*"]);
212
+ expect(rigidAgent.tools).not.toContain("write");
213
+ expect(rigidAgent.tools).not.toContain("shell");
214
+ expect(rigidAgent.tools).not.toContain("web");
215
+
216
+ expect(maxAgent.tools).toContain("read");
217
+ expect(maxAgent.tools).toContain("write");
218
+ expect(maxAgent.tools).toContain("shell");
219
+ expect(maxAgent.tools).toContain("spec");
220
+ expect(maxAgent.tools).toContain("@workflows/*");
221
+ expect(maxAgent.allowedTools).toContain("@workflows/*");
222
+ expect(maxAgent.mcpServers.workflows.autoApprove).toEqual(["*"]);
223
+ expect(maxAgent.tools).not.toContain("web");
224
+
225
+ expect(rigidMcp.mcpServers.workflows.autoApprove).toEqual(["*"]);
226
+ expect(maxMcp.mcpServers.workflows.autoApprove).toEqual(["*"]);
227
+ });
228
+ });
@@ -1,52 +1,89 @@
1
1
  import { join } from "node:path";
2
- import type { LogicalConfig } from "@codemcp/ade-core";
2
+ import type { LogicalConfig, McpServerEntry } from "@codemcp/ade-core";
3
3
  import type { HarnessWriter } from "../types.js";
4
- import { standardEntry, writeJson, writeGitHooks } from "../util.js";
4
+ import {
5
+ standardEntry,
6
+ writeGitHooks,
7
+ writeJson,
8
+ writeMcpServers
9
+ } from "../util.js";
10
+ import {
11
+ allowsCapability,
12
+ getCapabilityDecision,
13
+ hasPermissionPolicy
14
+ } from "../permission-policy.js";
5
15
 
6
16
  export const kiroWriter: HarnessWriter = {
7
17
  id: "kiro",
8
18
  label: "Kiro",
9
- description: "AWS AI IDE — .kiro/agents/ade.json",
19
+ description: "AWS AI IDE — .kiro/agents/ade.json + .kiro/settings/mcp.json",
10
20
  async install(config: LogicalConfig, projectRoot: string) {
11
- const servers = config.mcp_servers;
12
- if (servers.length > 0 || config.instructions.length > 0) {
13
- const mcpServers: Record<string, unknown> = {};
14
- for (const s of servers) {
15
- mcpServers[s.ref] = standardEntry(s);
16
- }
21
+ await writeMcpServers(config.mcp_servers, {
22
+ path: join(projectRoot, ".kiro", "settings", "mcp.json"),
23
+ transform: (server) => ({
24
+ ...standardEntry(server),
25
+ autoApprove: server.allowedTools ?? ["*"]
26
+ })
27
+ });
17
28
 
18
- const tools: string[] = [
19
- "execute_bash",
20
- "fs_read",
21
- "fs_write",
22
- "knowledge",
23
- "thinking",
24
- ...Object.keys(mcpServers).map((n) => `@${n}`)
25
- ];
26
-
27
- const allowedTools: string[] = [];
28
- for (const s of servers) {
29
- const explicit = s.allowedTools;
30
- if (explicit && !explicit.includes("*")) {
31
- for (const tool of explicit) {
32
- allowedTools.push(`@${s.ref}/${tool}`);
33
- }
34
- } else {
35
- allowedTools.push(`@${s.ref}/*`);
36
- }
37
- }
29
+ await writeJson(join(projectRoot, ".kiro", "agents", "ade.json"), {
30
+ name: "ade",
31
+ description:
32
+ "ADE — Agentic Development Environment agent with project conventions and tools.",
33
+ prompt:
34
+ config.instructions.join("\n\n") ||
35
+ "ADE Agentic Development Environment agent.",
36
+ mcpServers: getKiroAgentMcpServers(config.mcp_servers),
37
+ tools: getKiroTools(config),
38
+ allowedTools: getKiroAllowedTools(config),
39
+ useLegacyMcpJson: true
40
+ });
38
41
 
39
- await writeJson(join(projectRoot, ".kiro", "agents", "ade.json"), {
40
- name: "ade",
41
- prompt:
42
- config.instructions.length > 0
43
- ? config.instructions.join("\n\n")
44
- : "ADE — Agentic Development Environment agent",
45
- mcpServers,
46
- tools,
47
- allowedTools
48
- });
49
- }
50
42
  await writeGitHooks(config.git_hooks, projectRoot);
51
43
  }
52
44
  };
45
+
46
+ function getKiroTools(config: LogicalConfig): string[] {
47
+ const mcpTools = getKiroForwardedMcpTools(config.mcp_servers);
48
+
49
+ if (!hasPermissionPolicy(config)) {
50
+ return ["read", "write", "shell", "spec", ...mcpTools];
51
+ }
52
+
53
+ return [
54
+ ...(getCapabilityDecision(config, "read") !== "deny" ? ["read"] : []),
55
+ ...(allowsCapability(config, "edit_write") ? ["write"] : []),
56
+ ...(allowsCapability(config, "bash_unsafe") ? ["shell"] : []),
57
+ "spec",
58
+ ...mcpTools
59
+ ];
60
+ }
61
+
62
+ function getKiroAllowedTools(config: LogicalConfig): string[] {
63
+ return getKiroTools(config);
64
+ }
65
+
66
+ function getKiroForwardedMcpTools(servers: McpServerEntry[]): string[] {
67
+ return servers.flatMap((server) => {
68
+ const allowedTools = server.allowedTools ?? ["*"];
69
+ if (allowedTools.includes("*")) {
70
+ return [`@${server.ref}/*`];
71
+ }
72
+
73
+ return allowedTools.map((tool) => `@${server.ref}/${tool}`);
74
+ });
75
+ }
76
+
77
+ function getKiroAgentMcpServers(
78
+ servers: McpServerEntry[]
79
+ ): Record<string, Record<string, unknown>> {
80
+ return Object.fromEntries(
81
+ servers.map((server) => [
82
+ server.ref,
83
+ {
84
+ ...standardEntry(server),
85
+ autoApprove: server.allowedTools ?? ["*"]
86
+ }
87
+ ])
88
+ );
89
+ }
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type {
6
+ AutonomyProfile,
7
+ LogicalConfig,
8
+ PermissionPolicy
9
+ } from "@codemcp/ade-core";
10
+ import { parse as parseYaml } from "yaml";
11
+ import { opencodeWriter } from "./opencode.js";
12
+
13
+ function autonomyPolicy(profile: AutonomyProfile): PermissionPolicy {
14
+ switch (profile) {
15
+ case "rigid":
16
+ return {
17
+ profile,
18
+ capabilities: {
19
+ read: "ask",
20
+ edit_write: "ask",
21
+ search_list: "ask",
22
+ bash_safe: "ask",
23
+ bash_unsafe: "ask",
24
+ web: "ask",
25
+ task_agent: "ask"
26
+ }
27
+ };
28
+ case "sensible-defaults":
29
+ return {
30
+ profile,
31
+ capabilities: {
32
+ read: "allow",
33
+ edit_write: "allow",
34
+ search_list: "allow",
35
+ bash_safe: "allow",
36
+ bash_unsafe: "ask",
37
+ web: "ask",
38
+ task_agent: "allow"
39
+ }
40
+ };
41
+ case "max-autonomy":
42
+ return {
43
+ profile,
44
+ capabilities: {
45
+ read: "allow",
46
+ edit_write: "allow",
47
+ search_list: "allow",
48
+ bash_safe: "allow",
49
+ bash_unsafe: "allow",
50
+ web: "ask",
51
+ task_agent: "allow"
52
+ }
53
+ };
54
+ }
55
+ }
56
+
57
+ describe("opencodeWriter", () => {
58
+ let dir: string;
59
+
60
+ beforeEach(async () => {
61
+ dir = await mkdtemp(join(tmpdir(), "ade-harness-opencode-"));
62
+ });
63
+
64
+ afterEach(async () => {
65
+ await rm(dir, { recursive: true, force: true });
66
+ });
67
+
68
+ it("writes OpenCode permissions to the ADE agent frontmatter using the documented schema", async () => {
69
+ const rigidRoot = join(dir, "rigid");
70
+ const defaultsRoot = join(dir, "defaults");
71
+ const maxRoot = join(dir, "max");
72
+
73
+ const baseConfig = {
74
+ mcp_servers: [],
75
+ instructions: ["Follow project rules."],
76
+ cli_actions: [],
77
+ knowledge_sources: [],
78
+ skills: [],
79
+ git_hooks: [],
80
+ setup_notes: []
81
+ } satisfies LogicalConfig;
82
+
83
+ const rigidConfig = {
84
+ ...baseConfig,
85
+ permission_policy: autonomyPolicy("rigid")
86
+ } as LogicalConfig;
87
+
88
+ const maxConfig = {
89
+ ...baseConfig,
90
+ permission_policy: autonomyPolicy("max-autonomy")
91
+ } as LogicalConfig;
92
+
93
+ const defaultsConfig = {
94
+ ...baseConfig,
95
+ permission_policy: autonomyPolicy("sensible-defaults")
96
+ } as LogicalConfig;
97
+
98
+ await opencodeWriter.install(rigidConfig, rigidRoot);
99
+ await opencodeWriter.install(defaultsConfig, defaultsRoot);
100
+ await opencodeWriter.install(maxConfig, maxRoot);
101
+
102
+ const rigidAgent = await readFile(
103
+ join(rigidRoot, ".opencode", "agents", "ade.md"),
104
+ "utf-8"
105
+ );
106
+ const defaultsAgent = await readFile(
107
+ join(defaultsRoot, ".opencode", "agents", "ade.md"),
108
+ "utf-8"
109
+ );
110
+ const maxAgent = await readFile(
111
+ join(maxRoot, ".opencode", "agents", "ade.md"),
112
+ "utf-8"
113
+ );
114
+ const rigidFrontmatter = parseFrontmatter(rigidAgent);
115
+ const defaultsFrontmatter = parseFrontmatter(defaultsAgent);
116
+ const maxFrontmatter = parseFrontmatter(maxAgent);
117
+
118
+ await expect(
119
+ readFile(join(rigidRoot, "opencode.json"), "utf-8")
120
+ ).rejects.toThrow();
121
+ await expect(
122
+ readFile(join(defaultsRoot, "opencode.json"), "utf-8")
123
+ ).rejects.toThrow();
124
+ await expect(
125
+ readFile(join(maxRoot, "opencode.json"), "utf-8")
126
+ ).rejects.toThrow();
127
+
128
+ expect(rigidAgent).toContain("permission:");
129
+ expect(rigidAgent).toContain('"*": "ask"');
130
+ expect(rigidAgent).toContain('webfetch: "ask"');
131
+ expect(rigidAgent).toContain('websearch: "ask"');
132
+ expect(rigidAgent).toContain('codesearch: "ask"');
133
+ expect(rigidFrontmatter.permission).toMatchObject({
134
+ "*": "ask",
135
+ webfetch: "ask",
136
+ websearch: "ask",
137
+ codesearch: "ask"
138
+ });
139
+
140
+ expect(defaultsAgent).toContain('edit: "allow"');
141
+ expect(defaultsAgent).toContain('glob: "allow"');
142
+ expect(defaultsAgent).toContain('grep: "allow"');
143
+ expect(defaultsAgent).toContain('list: "allow"');
144
+ expect(defaultsAgent).toContain('lsp: "allow"');
145
+ expect(defaultsAgent).toContain('task: "allow"');
146
+ expect(defaultsAgent).toContain('skill: "deny"');
147
+ expect(defaultsAgent).toContain('todoread: "deny"');
148
+ expect(defaultsAgent).toContain('todowrite: "deny"');
149
+ expect(defaultsAgent).toContain('webfetch: "ask"');
150
+ expect(defaultsAgent).toContain('websearch: "ask"');
151
+ expect(defaultsAgent).toContain('codesearch: "ask"');
152
+ expect(defaultsAgent).toContain('external_directory: "deny"');
153
+ expect(defaultsAgent).toContain('doom_loop: "deny"');
154
+ expect(defaultsAgent).toContain('"grep *": "allow"');
155
+ expect(defaultsAgent).toContain('"cp *": "ask"');
156
+ expect(defaultsAgent).toContain('"rm *": "deny"');
157
+ expect(defaultsFrontmatter.permission).toMatchObject({
158
+ edit: "allow",
159
+ glob: "allow",
160
+ grep: "allow",
161
+ list: "allow",
162
+ lsp: "allow",
163
+ task: "allow",
164
+ skill: "deny",
165
+ todoread: "deny",
166
+ todowrite: "deny",
167
+ webfetch: "ask",
168
+ websearch: "ask",
169
+ codesearch: "ask",
170
+ external_directory: "deny",
171
+ doom_loop: "deny"
172
+ });
173
+ const defaultsPermission = defaultsFrontmatter.permission as {
174
+ bash: Record<string, string>;
175
+ };
176
+ expect(defaultsPermission.bash["grep *"]).toBe("allow");
177
+ expect(defaultsPermission.bash["cp *"]).toBe("ask");
178
+ expect(defaultsPermission.bash["rm *"]).toBe("deny");
179
+
180
+ expect(maxAgent).toContain('"*": "allow"');
181
+ expect(maxAgent).toContain('webfetch: "ask"');
182
+ expect(maxAgent).toContain('websearch: "ask"');
183
+ expect(maxAgent).toContain('codesearch: "ask"');
184
+ expect(maxFrontmatter.permission).toMatchObject({
185
+ "*": "allow",
186
+ webfetch: "ask",
187
+ websearch: "ask",
188
+ codesearch: "ask"
189
+ });
190
+ expect(rigidAgent).not.toContain("tools:");
191
+ });
192
+
193
+ it("keeps MCP servers in project config and writes documented environment fields", async () => {
194
+ const projectRoot = join(dir, "mcp");
195
+ const config = {
196
+ mcp_servers: [
197
+ {
198
+ ref: "workflows",
199
+ command: "npx",
200
+ args: ["@codemcp/workflows-server@latest"],
201
+ env: { FOO: "bar" },
202
+ allowedTools: ["whats_next"]
203
+ }
204
+ ],
205
+ instructions: ["Follow project rules."],
206
+ cli_actions: [],
207
+ knowledge_sources: [],
208
+ skills: [],
209
+ git_hooks: [],
210
+ setup_notes: [],
211
+ permission_policy: autonomyPolicy("rigid")
212
+ } as LogicalConfig;
213
+
214
+ await mkdir(projectRoot, { recursive: true });
215
+ await writeFile(
216
+ join(projectRoot, "opencode.json"),
217
+ JSON.stringify(
218
+ {
219
+ $schema: "https://opencode.ai/config.json",
220
+ permission: { read: "allow" }
221
+ },
222
+ null,
223
+ 2
224
+ ) + "\n",
225
+ "utf-8"
226
+ );
227
+
228
+ await opencodeWriter.install(config, projectRoot);
229
+
230
+ const projectJson = JSON.parse(
231
+ await readFile(join(projectRoot, "opencode.json"), "utf-8")
232
+ );
233
+ const agent = await readFile(
234
+ join(projectRoot, ".opencode", "agents", "ade.md"),
235
+ "utf-8"
236
+ );
237
+
238
+ expect(projectJson.permission).toEqual({ read: "allow" });
239
+ expect(projectJson.mcp).toEqual({
240
+ workflows: {
241
+ type: "local",
242
+ command: ["npx", "@codemcp/workflows-server@latest"],
243
+ environment: { FOO: "bar" }
244
+ }
245
+ });
246
+ expect(agent).toContain("permission:");
247
+ expect(agent).not.toContain("mcp_servers:");
248
+ });
249
+ });
250
+
251
+ function parseFrontmatter(content: string) {
252
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
253
+ if (!match) {
254
+ throw new Error("Expected frontmatter in agent markdown");
255
+ }
256
+
257
+ return parseYaml(match[1]) as Record<string, unknown>;
258
+ }
@@ -1,7 +1,8 @@
1
1
  import { join } from "node:path";
2
- import type { LogicalConfig } from "@codemcp/ade-core";
2
+ import type { LogicalConfig, PermissionRule } from "@codemcp/ade-core";
3
3
  import type { HarnessWriter } from "../types.js";
4
- import { writeMcpServers, writeAgentMd, writeGitHooks } from "../util.js";
4
+ import { writeAgentMd, writeGitHooks, writeMcpServers } from "../util.js";
5
+ import { getHarnessPermissionRules } from "../permission-policy.js";
5
6
 
6
7
  export const opencodeWriter: HarnessWriter = {
7
8
  id: "opencode",
@@ -14,41 +15,53 @@ export const opencodeWriter: HarnessWriter = {
14
15
  transform: (s) => ({
15
16
  type: "local",
16
17
  command: [s.command, ...s.args],
17
- ...(Object.keys(s.env).length > 0 ? { env: s.env } : {})
18
+ ...(Object.keys(s.env).length > 0 ? { environment: s.env } : {})
18
19
  }),
19
20
  defaults: { $schema: "https://opencode.ai/config.json" }
20
21
  });
21
22
 
22
- const servers = config.mcp_servers;
23
- const extraFm: string[] = [
24
- "tools:",
25
- " read: true",
26
- " edit: approve",
27
- " bash: approve"
28
- ];
29
-
30
- if (servers.length > 0) {
31
- extraFm.push("mcp_servers:");
32
- for (const s of servers) {
33
- extraFm.push(` ${s.ref}:`);
34
- extraFm.push(
35
- ` command: [${[s.command, ...s.args].map((a) => `"${a}"`).join(", ")}]`
36
- );
37
- if (Object.keys(s.env).length > 0) {
38
- extraFm.push(" env:");
39
- for (const [k, v] of Object.entries(s.env)) {
40
- extraFm.push(` ${k}: "${v}"`);
41
- }
42
- }
43
- }
44
- }
23
+ const permission = getHarnessPermissionRules(config);
45
24
 
46
25
  await writeAgentMd(config, {
47
26
  path: join(projectRoot, ".opencode", "agents", "ade.md"),
48
- extraFrontmatter: extraFm,
27
+ extraFrontmatter: permission
28
+ ? renderYamlMapping("permission", permission)
29
+ : undefined,
49
30
  fallbackBody:
50
31
  "ADE — Agentic Development Environment agent with project conventions and tools."
51
32
  });
52
33
  await writeGitHooks(config.git_hooks, projectRoot);
53
34
  }
54
35
  };
36
+
37
+ function renderYamlMapping(
38
+ key: string,
39
+ value: Record<string, PermissionRule>,
40
+ indent = 0
41
+ ): string[] {
42
+ const prefix = " ".repeat(indent);
43
+ const lines = [`${prefix}${formatYamlKey(key)}:`];
44
+
45
+ for (const [childKey, childValue] of Object.entries(value)) {
46
+ if (
47
+ typeof childValue === "object" &&
48
+ childValue !== null &&
49
+ !Array.isArray(childValue)
50
+ ) {
51
+ lines.push(...renderYamlMapping(childKey, childValue, indent + 2));
52
+ continue;
53
+ }
54
+
55
+ lines.push(
56
+ `${" ".repeat(indent + 2)}${formatYamlKey(childKey)}: ${JSON.stringify(childValue)}`
57
+ );
58
+ }
59
+
60
+ return lines;
61
+ }
62
+
63
+ function formatYamlKey(value: string): string {
64
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value)
65
+ ? value
66
+ : JSON.stringify(value);
67
+ }