@codemcp/ade 0.2.5 → 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.
Files changed (45) hide show
  1. package/.agentskills/skills/conventional-commits/SKILL.md +36 -0
  2. package/.beads/issues.jsonl +6 -0
  3. package/.beads/last-touched +1 -1
  4. package/.kiro/agents/ade.json +9 -2
  5. package/.opencode/agents/ade.md +9 -18
  6. package/.vibe/beads-state-ade-fix-no-git-k396xs.json +34 -0
  7. package/.vibe/development-plan-fix-no-git.md +76 -0
  8. package/AGENTS.md +27 -0
  9. package/config.lock.yaml +33 -9
  10. package/config.yaml +3 -0
  11. package/package.json +1 -1
  12. package/packages/cli/dist/index.js +404 -343
  13. package/packages/cli/package.json +1 -1
  14. package/packages/cli/src/commands/conventions.integration.spec.ts +7 -1
  15. package/packages/cli/src/commands/install.ts +19 -1
  16. package/packages/cli/src/commands/setup.ts +19 -1
  17. package/packages/core/package.json +1 -1
  18. package/packages/core/src/catalog/catalog.spec.ts +1 -10
  19. package/packages/core/src/catalog/facets/autonomy.ts +4 -62
  20. package/packages/core/src/index.ts +1 -4
  21. package/packages/core/src/resolver.spec.ts +4 -22
  22. package/packages/core/src/resolver.ts +1 -5
  23. package/packages/core/src/types.ts +0 -20
  24. package/packages/harnesses/package.json +2 -1
  25. package/packages/harnesses/src/permission-policy.ts +1 -165
  26. package/packages/harnesses/src/util.spec.ts +97 -0
  27. package/packages/harnesses/src/util.ts +32 -4
  28. package/packages/harnesses/src/writers/claude-code.spec.ts +14 -46
  29. package/packages/harnesses/src/writers/claude-code.ts +33 -16
  30. package/packages/harnesses/src/writers/cline.spec.ts +1 -41
  31. package/packages/harnesses/src/writers/copilot.spec.ts +2 -42
  32. package/packages/harnesses/src/writers/copilot.ts +19 -32
  33. package/packages/harnesses/src/writers/cursor.spec.ts +1 -41
  34. package/packages/harnesses/src/writers/cursor.ts +28 -40
  35. package/packages/harnesses/src/writers/kiro.spec.ts +1 -41
  36. package/packages/harnesses/src/writers/kiro.ts +23 -24
  37. package/packages/harnesses/src/writers/opencode.spec.ts +5 -47
  38. package/packages/harnesses/src/writers/opencode.ts +153 -10
  39. package/packages/harnesses/src/writers/roo-code.spec.ts +2 -42
  40. package/packages/harnesses/src/writers/roo-code.ts +25 -10
  41. package/packages/harnesses/src/writers/universal.spec.ts +1 -41
  42. package/packages/harnesses/src/writers/universal.ts +45 -31
  43. package/packages/harnesses/src/writers/windsurf.spec.ts +5 -42
  44. package/packages/harnesses/src/writers/windsurf.ts +30 -47
  45. package/skills-lock.json +6 -1
@@ -39,5 +39,5 @@
39
39
  "typescript": "catalog:",
40
40
  "vitest": "catalog:"
41
41
  },
42
- "version": "0.2.5"
42
+ "version": "0.3.0"
43
43
  }
@@ -8,9 +8,15 @@ vi.mock("@clack/prompts", () => ({
8
8
  outro: vi.fn(),
9
9
  select: vi.fn(),
10
10
  multiselect: vi.fn(),
11
- confirm: vi.fn(),
11
+ confirm: vi.fn().mockResolvedValue(true),
12
12
  isCancel: vi.fn().mockReturnValue(false),
13
13
  cancel: vi.fn(),
14
+ log: {
15
+ info: vi.fn(),
16
+ warn: vi.fn(),
17
+ error: vi.fn(),
18
+ success: vi.fn()
19
+ },
14
20
  spinner: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn() })
15
21
  }));
16
22
 
@@ -51,7 +51,25 @@ export async function runInstall(
51
51
  );
52
52
  }
53
53
 
54
- await installSkills(logicalConfig.skills, projectRoot);
54
+ if (logicalConfig.skills.length > 0) {
55
+ const confirmInstall = await clack.confirm({
56
+ message: `Install ${logicalConfig.skills.length} skill(s) now?`,
57
+ initialValue: true
58
+ });
59
+
60
+ if (typeof confirmInstall === "symbol") {
61
+ clack.cancel("Install cancelled.");
62
+ return;
63
+ }
64
+
65
+ if (confirmInstall) {
66
+ await installSkills(logicalConfig.skills, projectRoot);
67
+ } else {
68
+ clack.log.info(
69
+ "Skills not installed. Run manually when ready:\n npx @codemcp/skills experimental_install"
70
+ );
71
+ }
72
+ }
55
73
 
56
74
  if (logicalConfig.knowledge_sources.length > 0) {
57
75
  clack.log.info(
@@ -172,7 +172,25 @@ export async function runSetup(
172
172
  );
173
173
  }
174
174
 
175
- await installSkills(logicalConfig.skills, projectRoot);
175
+ if (logicalConfig.skills.length > 0) {
176
+ const confirmInstall = await clack.confirm({
177
+ message: `Install ${logicalConfig.skills.length} skill(s) now?`,
178
+ initialValue: true
179
+ });
180
+
181
+ if (typeof confirmInstall === "symbol") {
182
+ clack.cancel("Setup cancelled.");
183
+ return;
184
+ }
185
+
186
+ if (confirmInstall) {
187
+ await installSkills(logicalConfig.skills, projectRoot);
188
+ } else {
189
+ clack.log.info(
190
+ "Skills not installed. Run manually when ready:\n npx @codemcp/skills experimental_install"
191
+ );
192
+ }
193
+ }
176
194
 
177
195
  if (logicalConfig.knowledge_sources.length > 0) {
178
196
  clack.log.info(
@@ -38,5 +38,5 @@
38
38
  "typescript": "catalog:",
39
39
  "vitest": "catalog:"
40
40
  },
41
- "version": "0.2.5"
41
+ "version": "0.3.0"
42
42
  }
@@ -486,16 +486,7 @@ describe("catalog", () => {
486
486
 
487
487
  expect(provision).toBeDefined();
488
488
  expect(provision!.config).toEqual({
489
- profile: "sensible-defaults",
490
- capabilities: {
491
- read: "allow",
492
- edit_write: "allow",
493
- search_list: "allow",
494
- bash_safe: "allow",
495
- bash_unsafe: "ask",
496
- web: "ask",
497
- task_agent: "allow"
498
- }
489
+ profile: "sensible-defaults"
499
490
  });
500
491
  });
501
492
  });
@@ -1,62 +1,4 @@
1
- import type {
2
- AutonomyCapability,
3
- Facet,
4
- PermissionDecision,
5
- PermissionPolicy
6
- } from "../../types.js";
7
-
8
- const ALL_CAPABILITIES: AutonomyCapability[] = [
9
- "read",
10
- "edit_write",
11
- "search_list",
12
- "bash_safe",
13
- "bash_unsafe",
14
- "web",
15
- "task_agent"
16
- ];
17
-
18
- function capabilityMap(
19
- defaultDecision: PermissionDecision,
20
- overrides: Partial<Record<AutonomyCapability, PermissionDecision>> = {}
21
- ): Record<AutonomyCapability, PermissionDecision> {
22
- return Object.fromEntries(
23
- ALL_CAPABILITIES.map((capability) => [
24
- capability,
25
- overrides[capability] ?? defaultDecision
26
- ])
27
- ) as Record<AutonomyCapability, PermissionDecision>;
28
- }
29
-
30
- function autonomyPolicy(
31
- profile: PermissionPolicy["profile"]
32
- ): PermissionPolicy {
33
- switch (profile) {
34
- case "rigid":
35
- return {
36
- profile,
37
- capabilities: capabilityMap("ask")
38
- };
39
- case "sensible-defaults":
40
- return {
41
- profile,
42
- capabilities: capabilityMap("ask", {
43
- read: "allow",
44
- edit_write: "allow",
45
- search_list: "allow",
46
- bash_safe: "allow",
47
- task_agent: "allow",
48
- web: "ask"
49
- })
50
- };
51
- case "max-autonomy":
52
- return {
53
- profile,
54
- capabilities: capabilityMap("allow", {
55
- web: "ask"
56
- })
57
- };
58
- }
59
- }
1
+ import type { Facet } from "../../types.js";
60
2
 
61
3
  export const autonomyFacet: Facet = {
62
4
  id: "autonomy",
@@ -74,7 +16,7 @@ export const autonomyFacet: Facet = {
74
16
  recipe: [
75
17
  {
76
18
  writer: "permission-policy",
77
- config: autonomyPolicy("rigid")
19
+ config: { profile: "rigid" }
78
20
  }
79
21
  ]
80
22
  },
@@ -86,7 +28,7 @@ export const autonomyFacet: Facet = {
86
28
  recipe: [
87
29
  {
88
30
  writer: "permission-policy",
89
- config: autonomyPolicy("sensible-defaults")
31
+ config: { profile: "sensible-defaults" }
90
32
  }
91
33
  ]
92
34
  },
@@ -98,7 +40,7 @@ export const autonomyFacet: Facet = {
98
40
  recipe: [
99
41
  {
100
42
  writer: "permission-policy",
101
- config: autonomyPolicy("max-autonomy")
43
+ config: { profile: "max-autonomy" }
102
44
  }
103
45
  ]
104
46
  }
@@ -15,10 +15,7 @@ export {
15
15
  type ExternalSkill,
16
16
  type GitHook,
17
17
  type PermissionPolicy,
18
- type AutonomyProfile,
19
- type AutonomyCapability,
20
- type PermissionDecision,
21
- type PermissionRule
18
+ type AutonomyProfile
22
19
  } from "./types.js";
23
20
  export { type ResolutionContext, type ResolvedFacet } from "./types.js";
24
21
  export { type UserConfig, type LockFile } from "./types.js";
@@ -580,7 +580,7 @@ describe("resolve", () => {
580
580
  });
581
581
 
582
582
  describe("autonomy permission policy", () => {
583
- it("adds a capability-based permission policy to LogicalConfig and keeps web access on ask", async () => {
583
+ it("adds a profile-based permission policy to LogicalConfig", async () => {
584
584
  const userConfig: UserConfig = {
585
585
  choices: { autonomy: "rigid" }
586
586
  };
@@ -589,20 +589,11 @@ describe("resolve", () => {
589
589
 
590
590
  expect(result).toHaveProperty("permission_policy");
591
591
  expect((result as Record<string, unknown>).permission_policy).toEqual({
592
- profile: "rigid",
593
- capabilities: {
594
- read: "ask",
595
- edit_write: "ask",
596
- search_list: "ask",
597
- bash_safe: "ask",
598
- bash_unsafe: "ask",
599
- web: "ask",
600
- task_agent: "ask"
601
- }
592
+ profile: "rigid"
602
593
  });
603
594
  });
604
595
 
605
- it("uses curated built-in defaults for the sensible-defaults autonomy profile", async () => {
596
+ it("uses the sensible-defaults autonomy profile", async () => {
606
597
  const userConfig: UserConfig = {
607
598
  choices: { autonomy: "sensible-defaults" }
608
599
  };
@@ -610,16 +601,7 @@ describe("resolve", () => {
610
601
  const result = await resolve(userConfig, catalog, registry);
611
602
 
612
603
  expect(result.permission_policy).toEqual({
613
- profile: "sensible-defaults",
614
- capabilities: {
615
- read: "allow",
616
- edit_write: "allow",
617
- search_list: "allow",
618
- bash_safe: "allow",
619
- bash_unsafe: "ask",
620
- web: "ask",
621
- task_agent: "allow"
622
- }
604
+ profile: "sensible-defaults"
623
605
  });
624
606
  });
625
607
  });
@@ -179,11 +179,7 @@ function mergePermissionPolicy(
179
179
 
180
180
  return {
181
181
  ...existing,
182
- ...incoming,
183
- capabilities: {
184
- ...existing.capabilities,
185
- ...incoming.capabilities
186
- }
182
+ ...incoming
187
183
  };
188
184
  }
189
185
 
@@ -68,28 +68,8 @@ export interface GitHook {
68
68
 
69
69
  export type AutonomyProfile = "rigid" | "sensible-defaults" | "max-autonomy";
70
70
 
71
- export type PermissionDecision = "ask" | "allow" | "deny";
72
-
73
- export type AutonomyCapability =
74
- | "read"
75
- | "edit_write"
76
- | "search_list"
77
- | "bash_safe"
78
- | "bash_unsafe"
79
- | "web"
80
- | "task_agent";
81
-
82
- /**
83
- * @deprecated Harness-specific tool-level rules are no longer produced by core.
84
- * Kept temporarily as a compatibility type for downstream packages.
85
- */
86
- export type PermissionRule =
87
- | PermissionDecision
88
- | Record<string, PermissionDecision>;
89
-
90
71
  export interface PermissionPolicy extends Record<string, unknown> {
91
72
  profile: AutonomyProfile;
92
- capabilities: Record<AutonomyCapability, PermissionDecision>;
93
73
  }
94
74
 
95
75
  export interface LogicalConfig extends Record<string, unknown> {
@@ -28,6 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
+ "@clack/prompts": "^1.1.0",
31
32
  "@codemcp/ade-core": "workspace:*",
32
33
  "@codemcp/skills": "^2.3.0"
33
34
  },
@@ -39,5 +40,5 @@
39
40
  "typescript": "catalog:",
40
41
  "vitest": "catalog:"
41
42
  },
42
- "version": "0.2.5"
43
+ "version": "0.3.0"
43
44
  }
@@ -1,121 +1,4 @@
1
- import type {
2
- AutonomyCapability,
3
- LogicalConfig,
4
- PermissionDecision,
5
- PermissionRule
6
- } from "@codemcp/ade-core";
7
-
8
- const SENSIBLE_DEFAULTS_RULES: Record<string, PermissionRule> = {
9
- read: {
10
- "*": "allow",
11
- "*.env": "deny",
12
- "*.env.*": "deny",
13
- "*.env.example": "allow"
14
- },
15
- edit: "allow",
16
- glob: "allow",
17
- grep: "allow",
18
- list: "allow",
19
- lsp: "allow",
20
- task: "allow",
21
- todoread: "deny",
22
- todowrite: "deny",
23
- skill: "deny",
24
- webfetch: "ask",
25
- websearch: "ask",
26
- codesearch: "ask",
27
- bash: {
28
- "*": "deny",
29
- "grep *": "allow",
30
- "rg *": "allow",
31
- "find *": "allow",
32
- "fd *": "allow",
33
- ls: "allow",
34
- "ls *": "allow",
35
- "cat *": "allow",
36
- "head *": "allow",
37
- "tail *": "allow",
38
- "wc *": "allow",
39
- "sort *": "allow",
40
- "uniq *": "allow",
41
- "diff *": "allow",
42
- "echo *": "allow",
43
- "printf *": "allow",
44
- pwd: "allow",
45
- "which *": "allow",
46
- "type *": "allow",
47
- whoami: "allow",
48
- date: "allow",
49
- "date *": "allow",
50
- env: "allow",
51
- "tree *": "allow",
52
- "file *": "allow",
53
- "stat *": "allow",
54
- "readlink *": "allow",
55
- "realpath *": "allow",
56
- "dirname *": "allow",
57
- "basename *": "allow",
58
- "sed *": "allow",
59
- "awk *": "allow",
60
- "cut *": "allow",
61
- "tr *": "allow",
62
- "tee *": "allow",
63
- "xargs *": "allow",
64
- "jq *": "allow",
65
- "yq *": "allow",
66
- "mkdir *": "allow",
67
- "touch *": "allow",
68
- "cp *": "ask",
69
- "mv *": "ask",
70
- "ln *": "ask",
71
- "npm *": "ask",
72
- "node *": "ask",
73
- "pip *": "ask",
74
- "python *": "ask",
75
- "python3 *": "ask",
76
- "rm *": "deny",
77
- "rmdir *": "deny",
78
- "curl *": "deny",
79
- "wget *": "deny",
80
- "chmod *": "deny",
81
- "chown *": "deny",
82
- "sudo *": "deny",
83
- "su *": "deny",
84
- "sh *": "deny",
85
- "bash *": "deny",
86
- "zsh *": "deny",
87
- "eval *": "deny",
88
- "exec *": "deny",
89
- "source *": "deny",
90
- ". *": "deny",
91
- "nohup *": "deny",
92
- "dd *": "deny",
93
- "mkfs *": "deny",
94
- "mount *": "deny",
95
- "umount *": "deny",
96
- "kill *": "deny",
97
- "killall *": "deny",
98
- "pkill *": "deny",
99
- "nc *": "deny",
100
- "ncat *": "deny",
101
- "ssh *": "deny",
102
- "scp *": "deny",
103
- "rsync *": "deny",
104
- "docker *": "deny",
105
- "kubectl *": "deny",
106
- "systemctl *": "deny",
107
- "service *": "deny",
108
- "crontab *": "deny",
109
- reboot: "deny",
110
- "shutdown *": "deny",
111
- "passwd *": "deny",
112
- "useradd *": "deny",
113
- "userdel *": "deny",
114
- "iptables *": "deny"
115
- },
116
- external_directory: "deny",
117
- doom_loop: "deny"
118
- };
1
+ import type { LogicalConfig } from "@codemcp/ade-core";
119
2
 
120
3
  export function getAutonomyProfile(config: LogicalConfig) {
121
4
  return config.permission_policy?.profile;
@@ -124,50 +7,3 @@ export function getAutonomyProfile(config: LogicalConfig) {
124
7
  export function hasPermissionPolicy(config: LogicalConfig): boolean {
125
8
  return config.permission_policy !== undefined;
126
9
  }
127
-
128
- export function getCapabilityDecision(
129
- config: LogicalConfig,
130
- capability: AutonomyCapability
131
- ): PermissionDecision | undefined {
132
- return config.permission_policy?.capabilities?.[capability];
133
- }
134
-
135
- export function allowsCapability(
136
- config: LogicalConfig,
137
- capability: AutonomyCapability
138
- ): boolean {
139
- return getCapabilityDecision(config, capability) === "allow";
140
- }
141
-
142
- export function keepsWebOnAsk(config: LogicalConfig): boolean {
143
- return getCapabilityDecision(config, "web") === "ask";
144
- }
145
-
146
- export function getHarnessPermissionRules(
147
- config: LogicalConfig
148
- ): Record<string, PermissionRule> | undefined {
149
- switch (config.permission_policy?.profile) {
150
- case "rigid":
151
- return {
152
- "*": "ask",
153
- webfetch: "ask",
154
- websearch: "ask",
155
- codesearch: "ask",
156
- external_directory: "deny",
157
- doom_loop: "deny"
158
- };
159
- case "sensible-defaults":
160
- return SENSIBLE_DEFAULTS_RULES;
161
- case "max-autonomy":
162
- return {
163
- "*": "allow",
164
- webfetch: "ask",
165
- websearch: "ask",
166
- codesearch: "ask",
167
- external_directory: "deny",
168
- doom_loop: "deny"
169
- };
170
- default:
171
- return undefined;
172
- }
173
- }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtemp, rm, mkdir, readFile, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import * as clack from "@clack/prompts";
6
+ import type { GitHook } from "@codemcp/ade-core";
7
+ import { writeGitHooks } from "./util.js";
8
+
9
+ describe("writeGitHooks", () => {
10
+ let dir: string;
11
+
12
+ beforeEach(async () => {
13
+ dir = await mkdtemp(join(tmpdir(), "ade-util-git-hooks-"));
14
+ vi.spyOn(clack.log, "warn").mockImplementation(() => undefined);
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(dir, { recursive: true, force: true });
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ it("is a no-op when hooks is undefined", async () => {
23
+ await expect(writeGitHooks(undefined, dir)).resolves.toBeUndefined();
24
+ expect(clack.log.warn).not.toHaveBeenCalled();
25
+ });
26
+
27
+ it("is a no-op when hooks array is empty", async () => {
28
+ await expect(writeGitHooks([], dir)).resolves.toBeUndefined();
29
+ expect(clack.log.warn).not.toHaveBeenCalled();
30
+ });
31
+
32
+ it("warns and skips gracefully when .git directory does not exist", async () => {
33
+ const hooks: GitHook[] = [
34
+ { phase: "pre-commit", script: "#!/bin/sh\nnpx lint-staged" }
35
+ ];
36
+
37
+ await expect(writeGitHooks(hooks, dir)).resolves.toBeUndefined();
38
+
39
+ expect(clack.log.warn).toHaveBeenCalledOnce();
40
+ expect(clack.log.warn).toHaveBeenCalledWith(
41
+ expect.stringContaining("not a git repository")
42
+ );
43
+
44
+ // No .git/hooks directory should have been created
45
+ await expect(stat(join(dir, ".git"))).rejects.toThrow();
46
+ });
47
+
48
+ it("writes hook files when .git exists", async () => {
49
+ await mkdir(join(dir, ".git"), { recursive: true });
50
+
51
+ const script = "#!/bin/sh\nnpx lint-staged\n";
52
+ const hooks: GitHook[] = [{ phase: "pre-commit", script }];
53
+
54
+ await writeGitHooks(hooks, dir);
55
+
56
+ expect(clack.log.warn).not.toHaveBeenCalled();
57
+
58
+ const written = await readFile(
59
+ join(dir, ".git", "hooks", "pre-commit"),
60
+ "utf-8"
61
+ );
62
+ expect(written).toBe(script);
63
+ });
64
+
65
+ it("creates .git/hooks directory if it does not exist yet", async () => {
66
+ // .git exists but no hooks subdir
67
+ await mkdir(join(dir, ".git"), { recursive: true });
68
+
69
+ const hooks: GitHook[] = [{ phase: "pre-commit", script: "#!/bin/sh\n" }];
70
+ await writeGitHooks(hooks, dir);
71
+
72
+ const hookStat = await stat(join(dir, ".git", "hooks"));
73
+ expect(hookStat.isDirectory()).toBe(true);
74
+ });
75
+
76
+ it("writes multiple hooks", async () => {
77
+ await mkdir(join(dir, ".git"), { recursive: true });
78
+
79
+ const hooks: GitHook[] = [
80
+ { phase: "pre-commit", script: "#!/bin/sh\necho pre-commit\n" },
81
+ { phase: "pre-push", script: "#!/bin/sh\necho pre-push\n" }
82
+ ];
83
+
84
+ await writeGitHooks(hooks, dir);
85
+
86
+ const preCommit = await readFile(
87
+ join(dir, ".git", "hooks", "pre-commit"),
88
+ "utf-8"
89
+ );
90
+ const prePush = await readFile(
91
+ join(dir, ".git", "hooks", "pre-push"),
92
+ "utf-8"
93
+ );
94
+ expect(preCommit).toBe(hooks[0].script);
95
+ expect(prePush).toBe(hooks[1].script);
96
+ });
97
+ });
@@ -1,5 +1,6 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
+ import * as clack from "@clack/prompts";
3
4
  import type { GitHook, LogicalConfig, McpServerEntry } from "@codemcp/ade-core";
4
5
 
5
6
  // ---------------------------------------------------------------------------
@@ -169,15 +170,31 @@ export async function writeAgentMd(
169
170
  /**
170
171
  * Write git hook scripts to `.git/hooks/<phase>`.
171
172
  * Files are created with executable permissions (0o755).
172
- * No-op when the hooks array is empty.
173
+ * No-op when the hooks array is empty or undefined.
174
+ * Emits a warning and skips gracefully when the project root is not a git repository.
173
175
  */
174
176
  export async function writeGitHooks(
175
177
  hooks: GitHook[] | undefined,
176
178
  projectRoot: string
177
179
  ): Promise<void> {
178
- if (!hooks) return;
180
+ if (!hooks || hooks.length === 0) return;
181
+
182
+ const gitDir = join(projectRoot, ".git");
183
+ try {
184
+ await access(gitDir);
185
+ } catch {
186
+ clack.log.warn(
187
+ "Git hooks were configured but could not be installed: the project is not a git repository.\n" +
188
+ "Run `git init` and re-run setup to install the hooks."
189
+ );
190
+ return;
191
+ }
192
+
193
+ const hooksDir = join(gitDir, "hooks");
194
+ await mkdir(hooksDir, { recursive: true });
195
+
179
196
  for (const hook of hooks) {
180
- const hookPath = join(projectRoot, ".git", "hooks", hook.phase);
197
+ const hookPath = join(hooksDir, hook.phase);
181
198
  await writeFile(hookPath, hook.script, { mode: 0o755 });
182
199
  }
183
200
  }
@@ -219,3 +236,14 @@ export async function writeInlineSkills(
219
236
 
220
237
  return modified;
221
238
  }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // YAML helpers
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /** Quote a YAML key only when it contains characters that require quoting. */
245
+ export function formatYamlKey(value: string): string {
246
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value)
247
+ ? value
248
+ : JSON.stringify(value);
249
+ }