@uniqbit/mate-core 0.18.5 → 1.0.0-canary.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 (209) hide show
  1. package/claude-plugin/hooks/companion-instructions.mjs +9 -0
  2. package/claude-plugin/hooks/hooks.json +12 -2
  3. package/package.json +10 -2
  4. package/src/brand/art.generated.ts +69 -0
  5. package/src/brand/index.ts +12 -0
  6. package/src/brand/palette.ts +19 -0
  7. package/src/brand/svg.generated.ts +8 -0
  8. package/src/brand/terminal.ts +83 -0
  9. package/src/brand/types.ts +33 -0
  10. package/src/cli/commands/artifact/artifact.ts +8 -0
  11. package/src/cli/commands/artifact/delivery/archive.ts +117 -0
  12. package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
  13. package/src/cli/commands/artifact/delivery/environment.ts +104 -0
  14. package/src/cli/commands/artifact/delivery/link.ts +154 -0
  15. package/src/cli/commands/artifact/delivery/output.ts +58 -0
  16. package/src/cli/commands/artifact/finish/command.ts +33 -3
  17. package/src/cli/commands/artifact/finish/engine.ts +47 -3
  18. package/src/cli/commands/artifact/finish/finisher.ts +10 -0
  19. package/src/cli/commands/artifact/finish/openspec.ts +177 -138
  20. package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
  21. package/src/cli/commands/artifact/pending/command.ts +31 -3
  22. package/src/cli/commands/artifact/pending/discovery.ts +21 -57
  23. package/src/cli/commands/cap/graphify.ts +52 -3
  24. package/src/cli/commands/cap/index-cmd.ts +8 -1
  25. package/src/cli/commands/cap/openspec.ts +13 -25
  26. package/src/cli/commands/companion/open.ts +56 -39
  27. package/src/cli/commands/companion/update.ts +75 -42
  28. package/src/cli/commands/doctor.ts +176 -3
  29. package/src/cli/commands/plugin/install.ts +9 -4
  30. package/src/cli/commands/plugin/plugin.ts +3 -2
  31. package/src/cli/commands/report/adapter.ts +11 -2
  32. package/src/cli/commands/report/contract.ts +21 -0
  33. package/src/cli/commands/report/highlight.ts +34 -4
  34. package/src/cli/commands/report/markdown.ts +110 -0
  35. package/src/cli/commands/report/palette.ts +118 -0
  36. package/src/cli/commands/report/renderer.ts +456 -153
  37. package/src/cli/commands/report/types.ts +16 -0
  38. package/src/cli/commands/setup.ts +7 -15
  39. package/src/cli/commands/studio/client-asset.ts +16 -0
  40. package/src/cli/commands/studio/openspec-cli.ts +10 -58
  41. package/src/cli/commands/studio/routes.ts +2 -0
  42. package/src/cli/commands/studio/selection.ts +19 -1
  43. package/src/cli/commands/studio/server.ts +42 -1
  44. package/src/cli/commands/studio/views/client.ts +6 -616
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
  46. package/src/cli/commands/studio/views/document.tsx +137 -69
  47. package/src/cli/commands/studio/views/markdown.tsx +250 -0
  48. package/src/cli/commands/studio/views/palette.ts +134 -0
  49. package/src/cli/commands/studio/views/reports/index.tsx +2 -7
  50. package/src/cli/commands/studio/views/skills/index.tsx +43 -62
  51. package/src/cli/commands/studio/views/specs/index.tsx +7 -15
  52. package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
  53. package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
  54. package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
  55. package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
  56. package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
  57. package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
  58. package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
  59. package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
  60. package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
  61. package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
  62. package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
  63. package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
  64. package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
  65. package/src/cli/commands/studio/views/styles.ts +217 -206
  66. package/src/cli/commands/studio/views/vault/state.ts +3 -5
  67. package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
  68. package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
  69. package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
  70. package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
  71. package/src/cli/commands/wrap.ts +5 -13
  72. package/src/cli/main.ts +4 -1
  73. package/src/cli/parse-flags.ts +25 -7
  74. package/src/cli/setup-selector.tsx +38 -162
  75. package/src/cli/usage.ts +4 -0
  76. package/src/hooks/companion-instructions.ts +190 -0
  77. package/src/hooks/hook-io.ts +63 -0
  78. package/src/hooks/session-banner.ts +21 -29
  79. package/src/hooks/session-guidance.ts +9 -17
  80. package/src/hooks/session-prepare.ts +115 -28
  81. package/src/hooks/validate-artifact-path.ts +82 -24
  82. package/src/index.ts +4 -0
  83. package/src/lib/change-layout.ts +154 -0
  84. package/src/lib/components/wizard-header.tsx +3 -1
  85. package/src/lib/context-mode-package.ts +24 -11
  86. package/src/lib/delivery/archive-change.ts +154 -0
  87. package/src/lib/delivery/branches.ts +108 -0
  88. package/src/lib/delivery/environment.ts +29 -0
  89. package/src/lib/delivery/forge.ts +279 -0
  90. package/src/lib/delivery/overlap.ts +202 -0
  91. package/src/lib/delivery/record-delivery.ts +196 -0
  92. package/src/lib/delivery/record.ts +343 -0
  93. package/src/lib/delivery/refresh.ts +174 -0
  94. package/src/lib/delivery/repository-access.ts +97 -0
  95. package/src/lib/delivery/stages.ts +111 -0
  96. package/src/lib/delivery/token.ts +58 -0
  97. package/src/lib/frontmatter.ts +18 -0
  98. package/src/lib/opencode-plugin-package.ts +5 -5
  99. package/src/lib/openspec-cli.ts +116 -0
  100. package/src/lib/openspec-scopes.ts +86 -0
  101. package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
  102. package/src/lib/orchestrator/adapters/claude.ts +48 -40
  103. package/src/lib/orchestrator/adapters/opencode.ts +2 -0
  104. package/src/lib/orchestrator/editor.ts +9 -9
  105. package/src/lib/orchestrator/openspec-store.ts +14 -54
  106. package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
  107. package/src/lib/orchestrator/projection-claude-links.ts +159 -0
  108. package/src/lib/orchestrator/projection-claude-skills.ts +19 -137
  109. package/src/lib/orchestrator/projection-entries.ts +45 -14
  110. package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
  111. package/src/lib/orchestrator/projection-types.ts +3 -0
  112. package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
  113. package/src/lib/orchestrator/session-prepare.ts +45 -5
  114. package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
  115. package/src/lib/orchestrator/types.ts +2 -2
  116. package/src/lib/orchestrator/wrap-pass.ts +22 -1
  117. package/src/lib/package-paths.ts +8 -0
  118. package/src/opencode/companion-hooks.ts +64 -5
  119. package/src/opencode/companion-policy.ts +37 -18
  120. package/src/opencode/projected-guidance.ts +4 -1
  121. package/src/opencode/session-prepare.ts +2 -1
  122. package/src/opencode/tui.tsx +4 -2
  123. package/src/runtime/artifact-refusal.ts +63 -0
  124. package/src/runtime/claude-delivery-plan.ts +89 -7
  125. package/src/runtime/commit-attribution-guard.ts +181 -0
  126. package/src/runtime/companion-guidance.ts +15 -3
  127. package/src/runtime/companion-instructions.ts +340 -0
  128. package/src/runtime/error-message.ts +4 -0
  129. package/src/runtime/index.ts +5 -0
  130. package/src/runtime/managed-block.ts +117 -0
  131. package/src/runtime/markdown-scan.ts +266 -0
  132. package/src/runtime/policy.ts +15 -1
  133. package/src/runtime/projected-flag.ts +7 -0
  134. package/src/runtime/projected-guidance.ts +1 -0
  135. package/src/runtime/runtime-document-manifest.ts +62 -11
  136. package/src/runtime/secret-guard.ts +148 -0
  137. package/src/runtime/secret-patterns.ts +164 -0
  138. package/src/runtime/secret-scan.ts +90 -0
  139. package/src/runtime/session-kind.ts +13 -0
  140. package/src/runtime/shell-heredoc.ts +81 -0
  141. package/src/runtime/wrap-state.ts +36 -2
  142. package/src/runtime/write-atomic.ts +64 -0
  143. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
  144. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
  145. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
  146. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  147. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
  148. package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
  149. package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
  150. package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
  151. package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
  152. package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
  153. package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
  154. package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
  155. package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
  156. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
  157. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
  158. package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
  159. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
  160. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
  161. package/src/templates/mate-skills/{claude/mate-artifact-publish → agents/mate-publish-artifact}/SKILL.md +91 -23
  162. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  163. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
  164. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
  165. package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
  166. package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
  167. package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
  168. package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
  169. package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
  170. package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
  171. package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
  172. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
  173. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
  174. package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
  175. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
  176. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
  177. package/src/templates/mate-skills/{agents/mate-artifact-publish → claude/mate-publish-artifact}/SKILL.md +91 -23
  178. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  179. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
  180. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
  181. package/src/templates/mate-skills/openspec-references.ts +140 -0
  182. package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
  183. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
  184. package/src/tools/setup/capabilities/context-mode.ts +25 -5
  185. package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
  186. package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
  187. package/src/tools/setup/capabilities/graphify.ts +12 -0
  188. package/src/tools/setup/capabilities/openspec.ts +151 -270
  189. package/src/tools/setup/capabilities/tokensave.ts +36 -3
  190. package/src/tools/setup/context-services.ts +9 -17
  191. package/src/tools/setup/dynamic-plugins/install.ts +24 -28
  192. package/src/tools/setup/engine.ts +97 -21
  193. package/src/tools/setup/managed-output.ts +51 -0
  194. package/src/tools/setup/mate.ts +86 -11
  195. package/src/tools/setup/plugin.ts +35 -0
  196. package/src/tools/setup/plugins/guidance.ts +7 -4
  197. package/src/tools/setup/providers/agent-file-sections.ts +4 -2
  198. package/src/tools/setup/providers/claude-attribution.ts +65 -0
  199. package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
  200. package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
  201. package/src/tools/setup/providers/claude.ts +284 -8
  202. package/src/tools/setup/providers/opencode.ts +180 -1
  203. package/src/tools/setup/utils.ts +14 -4
  204. package/src/tools/setup.ts +126 -10
  205. package/wrappers/bin/graphify +52 -4
  206. package/wrappers/bin/openspec +33 -9
  207. package/wrappers/lib/graphify-gate.mjs +8 -0
  208. package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
  209. package/src/tools/setup/runtime-documents.ts +0 -291
@@ -74,28 +74,28 @@ export const editorWorkspacePath = workspaceDocumentPath;
74
74
 
75
75
  interface WorkspaceDocument {
76
76
  workspacePath: string;
77
- folders: [string, string];
77
+ folders: string[];
78
78
  }
79
79
 
80
80
  /**
81
81
  * Writes the generated `.mate/workspace.code-workspace` file (working
82
- * repository first, companion second) and returns its path and folder
83
- * order. Called by editor-launching `injectEditorFolder`, which backs
84
- * `mate companion open`.
82
+ * repository first, then each companion in the given order) and returns its
83
+ * path and folder order. Called by editor-launching `injectEditorFolder`,
84
+ * which backs `mate companion open`.
85
85
  */
86
86
  async function writeWorkspaceDocument(
87
- companionPath: string,
87
+ companionPaths: string[],
88
88
  repoPath: string,
89
89
  ): Promise<WorkspaceDocument> {
90
- await project("workspace", { repoPath, companionPath });
90
+ await project("workspace", { repoPath, workspaceCompanionPaths: companionPaths });
91
91
  return {
92
92
  workspacePath: workspaceDocumentPath(repoPath),
93
- folders: [path.resolve(repoPath), path.resolve(companionPath)],
93
+ folders: [repoPath, ...companionPaths].map((folder) => path.resolve(folder)),
94
94
  };
95
95
  }
96
96
 
97
97
  export async function injectEditorFolder(
98
- companionPath: string,
98
+ companionPaths: string[],
99
99
  repoPath: string,
100
100
  cli = "code",
101
101
  spawnProcess: SpawnLike = spawn,
@@ -113,7 +113,7 @@ export async function injectEditorFolder(
113
113
 
114
114
  const { binary } = resolved;
115
115
 
116
- const { folders: workspaceFolders } = await writeWorkspaceDocument(companionPath, repoPath);
116
+ const { folders: workspaceFolders } = await writeWorkspaceDocument(companionPaths, repoPath);
117
117
 
118
118
  const child = spawnProcess(binary, ["--add", ...workspaceFolders], {
119
119
  stdio: "ignore",
@@ -1,15 +1,11 @@
1
- import { execFile } from "node:child_process";
2
1
  import fs from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
5
- import { promisify } from "node:util";
6
4
 
7
5
  import { parse as parseYaml } from "yaml";
8
6
 
9
- import { MATE_ENV } from "../../runtime/env-names";
10
- import { runtimeWrapperBinPath } from "../../runtime/runtime-directory";
11
- import { resolveCommandOnPath } from "../../tools/setup/utils";
12
- import { getWrapperBinPath } from "../package-paths";
7
+ import { pathIsDirectory } from "../fs-utils";
8
+ import { runOpenSpec } from "../openspec-cli";
13
9
 
14
10
  /**
15
11
  * The Companion Repository as an OpenSpec store. OpenSpec reports the Working
@@ -22,31 +18,16 @@ import { getWrapperBinPath } from "../package-paths";
22
18
  * register, which OpenSpec answers authoritatively.
23
19
  */
24
20
 
25
- const execFileAsync = promisify(execFile);
26
-
27
21
  /** OpenSpec's one id grammar, shared by store and change ids. */
28
22
  const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
29
23
 
30
24
  const STORE_METADATA_FILE = path.join(".openspec-store", "store.yaml");
31
25
 
32
- /** Injectable so the suite never touches the developer's registry or binary. */
26
+ /** Injectable so the suite never touches the developer's registry; the CLI is `openSpecCliDeps`. */
33
27
  export const openSpecStoreDeps = {
34
28
  env: (): NodeJS.ProcessEnv => process.env,
35
29
  platform: (): NodeJS.Platform => process.platform,
36
30
  homedir: (): string => os.homedir(),
37
- /** Mate's own `openspec` wrappers, which must never answer a registration. */
38
- wrapperBinDirs: (): string[] => {
39
- const launched = openSpecStoreDeps.env()[MATE_ENV.wrapperBinPath];
40
- return [getWrapperBinPath(), runtimeWrapperBinPath(), ...(launched ? [launched] : [])];
41
- },
42
- resolveBinary: (): string | undefined => {
43
- const excluded = new Set(openSpecStoreDeps.wrapperBinDirs().map((dir) => path.resolve(dir)));
44
- const searchPath = (openSpecStoreDeps.env().PATH ?? "")
45
- .split(path.delimiter)
46
- .filter((dir) => dir && !excluded.has(path.resolve(dir)))
47
- .join(path.delimiter);
48
- return resolveCommandOnPath("openspec", searchPath);
49
- },
50
31
  };
51
32
 
52
33
  export type StoreRegistrationCheck =
@@ -67,13 +48,6 @@ async function canonicalPath(candidate: string): Promise<string> {
67
48
  return fs.realpath(candidate).catch(() => path.resolve(candidate));
68
49
  }
69
50
 
70
- async function isDirectory(candidate: string): Promise<boolean> {
71
- return fs
72
- .stat(candidate)
73
- .then((stats) => stats.isDirectory())
74
- .catch(() => false);
75
- }
76
-
77
51
  function sanitizeStoreId(name: string): string {
78
52
  return name
79
53
  .toLowerCase()
@@ -157,23 +131,14 @@ export async function checkStoreRegistration(
157
131
  : { state: "conflict", path: localPath };
158
132
  }
159
133
 
160
- function commandOutput(error: unknown): string {
161
- const { stderr, stdout, message } = error as {
162
- stderr?: string;
163
- stdout?: string;
164
- message: string;
165
- };
166
- return (stderr || stdout || message).trim();
167
- }
168
-
169
134
  /**
170
135
  * Registers the companion when the registry lacks it. A conflicting id fails
171
- * rather than silently picking another one; OpenSpec without stores, or a
136
+ * rather than silently picking another one; a missing bundled CLI, or a
172
137
  * companion that is no OpenSpec root, is skipped.
173
138
  */
174
139
  export async function ensureStoreRegistration(companionPath: string): Promise<StoreRegistration> {
175
140
  const companion = await canonicalPath(companionPath);
176
- if (!(await isDirectory(path.join(companion, "openspec")))) {
141
+ if (!(await pathIsDirectory(path.join(companion, "openspec")))) {
177
142
  return { state: "skipped", reason: `${companion} is not an OpenSpec root` };
178
143
  }
179
144
  const id = await deriveStoreId(companion);
@@ -183,20 +148,15 @@ export async function ensureStoreRegistration(companionPath: string): Promise<St
183
148
  throw new Error(`OpenSpec store id "${id}" is already registered at ${check.path}`);
184
149
  }
185
150
 
186
- const bin = openSpecStoreDeps.resolveBinary();
187
- if (!bin) return { state: "skipped", reason: "the openspec CLI is not on PATH" };
188
- try {
189
- await execFileAsync(bin, ["store", "register", companion, "--id", id, "--yes"], {
190
- cwd: companion,
191
- env: openSpecStoreDeps.env(),
192
- timeout: 30_000,
193
- });
194
- } catch (error) {
195
- const output = commandOutput(error);
196
- if (/unknown command/i.test(output)) {
197
- return { state: "skipped", reason: "the openspec CLI does not support stores" };
198
- }
199
- throw new Error(`openspec store register failed: ${output}`, { cause: error });
151
+ const run = await runOpenSpec(["store", "register", companion, "--id", id, "--yes"], {
152
+ cwd: companion,
153
+ env: openSpecStoreDeps.env(),
154
+ timeout: 30_000,
155
+ });
156
+ if (run.missing) return { state: "skipped", reason: run.stderr };
157
+ if (run.code !== 0) {
158
+ const output = (run.stderr || run.stdout).trim() || `exit code ${run.code ?? "unknown"}`;
159
+ throw new Error(`openspec store register failed: ${output}`);
200
160
  }
201
161
  return { state: "written", id };
202
162
  }
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ import {
4
+ reconcileClaudeLinks,
5
+ removeClaudeLinks,
6
+ type ClaudeLinkRoot,
7
+ } from "./projection-claude-links";
8
+
9
+ /**
10
+ * A companion command directory (e.g. `acme/`) holds at least one
11
+ * `.md` file. Loose command files are not linked: a file symlink needs
12
+ * elevation on Windows, a directory junction does not.
13
+ */
14
+ const COMMANDS: ClaudeLinkRoot = {
15
+ dir: "commands",
16
+ eligible: async (entryPath) => {
17
+ const entries = await fs.readdir(entryPath).catch(() => []);
18
+ return entries.some((name) => name.endsWith(".md"));
19
+ },
20
+ };
21
+
22
+ export function reconcileWorkingRepoClaudeCommandLinks(
23
+ repoPath: string,
24
+ companionPath: string | null,
25
+ enabled: boolean,
26
+ registeredCompanionPaths: string[] = [],
27
+ ): Promise<"written" | "current"> {
28
+ return reconcileClaudeLinks(COMMANDS, repoPath, companionPath, enabled, registeredCompanionPaths);
29
+ }
30
+
31
+ export function removeWorkingRepoClaudeCommandLinks(
32
+ repoPath: string,
33
+ registeredCompanionPaths: string[] = [],
34
+ ): Promise<"removed" | "absent"> {
35
+ return removeClaudeLinks(COMMANDS, repoPath, registeredCompanionPaths);
36
+ }
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { readProjectionFile } from "../../runtime/projection";
5
+ import { realLinkTarget, realOrResolved } from "../fs-utils";
6
+ import {
7
+ links,
8
+ readManifest,
9
+ updateManifest,
10
+ withLinks,
11
+ type LinkRecord,
12
+ } from "../../runtime/runtime-document-manifest";
13
+ import { pruneEmptyAncestors } from "../../tools/setup/utils";
14
+
15
+ /**
16
+ * A `.claude/<dir>` folder whose companion entries Mate links one by one into
17
+ * the Working Repository, e.g. `skills` or `commands`.
18
+ */
19
+ export interface ClaudeLinkRoot {
20
+ /** Folder name under `.claude/`. */
21
+ dir: string;
22
+ /** Whether a companion entry, given by absolute path, is linked. */
23
+ eligible: (entryPath: string) => Promise<boolean>;
24
+ }
25
+
26
+ function rootPath(root: ClaudeLinkRoot): string {
27
+ return path.join(".claude", root.dir);
28
+ }
29
+
30
+ /** The manifest key prefix of a root's links: repo-relative, POSIX separators. */
31
+ function keyPrefix(root: ClaudeLinkRoot): string {
32
+ return `.claude/${root.dir}/`;
33
+ }
34
+
35
+ async function eligibleNames(sourceRoot: string, root: ClaudeLinkRoot): Promise<string[]> {
36
+ const entries = await fs.readdir(sourceRoot, { withFileTypes: true }).catch(() => []);
37
+ const names: string[] = [];
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
40
+ if (await root.eligible(path.join(sourceRoot, entry.name))) names.push(entry.name);
41
+ }
42
+ return names;
43
+ }
44
+
45
+ async function linkTarget(link: string): Promise<string | null> {
46
+ const target = await fs.readlink(link).catch(() => null);
47
+ return target === null ? null : realLinkTarget(link, target);
48
+ }
49
+
50
+ async function registeredRoots(
51
+ root: ClaudeLinkRoot,
52
+ repoPath: string,
53
+ companionPath: string | null,
54
+ registeredCompanionPaths: string[],
55
+ ): Promise<string[]> {
56
+ const projection = readProjectionFile(repoPath);
57
+ const candidates = [
58
+ ...registeredCompanionPaths,
59
+ companionPath ?? "",
60
+ projection?.projection.companionPath ?? "",
61
+ ];
62
+ const roots = await Promise.all(
63
+ candidates
64
+ .filter(Boolean)
65
+ .map((candidate) => realOrResolved(path.resolve(candidate, rootPath(root)))),
66
+ );
67
+ return [...new Set(roots)];
68
+ }
69
+
70
+ async function createDirectoryLink(target: string, link: string): Promise<void> {
71
+ await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
72
+ }
73
+
74
+ /**
75
+ * Recorded link: Mate's while it resolves to its recorded target. Unrecorded:
76
+ * adopted only when it resolves into a registered, current or projected
77
+ * companion. Records follow the filesystem, so a partial failure re-adopts.
78
+ * Only this root's records are read and replaced.
79
+ */
80
+ export async function reconcileClaudeLinks(
81
+ root: ClaudeLinkRoot,
82
+ repoPath: string,
83
+ companionPath: string | null,
84
+ enabled: boolean,
85
+ registeredCompanionPaths: string[] = [],
86
+ ): Promise<"written" | "current"> {
87
+ const scope = { prefix: keyPrefix(root) };
88
+ const recordKey = (name: string): string => `${scope.prefix}${name}`;
89
+ const currentRoot = companionPath ? path.resolve(companionPath, rootPath(root)) : null;
90
+ const roots = await registeredRoots(root, repoPath, companionPath, registeredCompanionPaths);
91
+ const targetRoot = path.join(path.resolve(repoPath), rootPath(root));
92
+ const recorded = links(await readManifest(repoPath), "claude", scope);
93
+ const existing = await fs.readdir(targetRoot, { withFileTypes: true }).catch(() => []);
94
+ const sourceNames: Set<string> =
95
+ currentRoot && enabled ? new Set(await eligibleNames(currentRoot, root)) : new Set<string>();
96
+ const records: Record<string, LinkRecord> = {};
97
+ let changed = false;
98
+
99
+ for (const entry of existing) {
100
+ if (!entry.isSymbolicLink()) continue;
101
+ const link = path.join(targetRoot, entry.name);
102
+ const target = await linkTarget(link);
103
+ if (target === null) continue;
104
+ const record = recorded[recordKey(entry.name)];
105
+ const owned = record
106
+ ? (await realOrResolved(record.target)) === target
107
+ : roots.some((candidate) => path.dirname(target) === candidate);
108
+ if (!owned) continue;
109
+
110
+ const wanted = currentRoot ? path.join(currentRoot, entry.name) : null;
111
+ if (wanted && sourceNames.has(entry.name) && (await realOrResolved(wanted)) === target) {
112
+ records[recordKey(entry.name)] = { runtime: "claude", target: wanted };
113
+ continue;
114
+ }
115
+
116
+ await fs.unlink(link);
117
+ changed = true;
118
+ }
119
+
120
+ if (currentRoot && sourceNames.size > 0) {
121
+ await fs.mkdir(targetRoot, { recursive: true });
122
+ for (const name of sourceNames) {
123
+ const link = path.join(targetRoot, name);
124
+ const existingLink = await fs.lstat(link).catch(() => null);
125
+ if (existingLink) continue;
126
+ const target = path.join(currentRoot, name);
127
+ await createDirectoryLink(target, link);
128
+ records[recordKey(name)] = { runtime: "claude", target };
129
+ changed = true;
130
+ }
131
+ }
132
+
133
+ if (changed || !enabled) {
134
+ await pruneEmptyAncestors(targetRoot, path.resolve(repoPath));
135
+ }
136
+ if (JSON.stringify(sortedEntries(records)) !== JSON.stringify(sortedEntries(recorded))) {
137
+ await updateManifest(repoPath, (manifest) => withLinks(manifest, "claude", records, scope));
138
+ changed = true;
139
+ }
140
+ return changed ? "written" : "current";
141
+ }
142
+
143
+ function sortedEntries(records: Record<string, LinkRecord>): [string, LinkRecord][] {
144
+ return Object.entries(records)
145
+ .map(([key, record]): [string, LinkRecord] => [
146
+ key,
147
+ { runtime: record.runtime, target: record.target },
148
+ ])
149
+ .toSorted(([a], [b]) => a.localeCompare(b));
150
+ }
151
+
152
+ export async function removeClaudeLinks(
153
+ root: ClaudeLinkRoot,
154
+ repoPath: string,
155
+ registeredCompanionPaths: string[] = [],
156
+ ): Promise<"removed" | "absent"> {
157
+ const result = await reconcileClaudeLinks(root, repoPath, null, false, registeredCompanionPaths);
158
+ return result === "written" ? "removed" : "absent";
159
+ }
@@ -1,152 +1,34 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { readProjectionFile } from "../../runtime/projection";
5
- import { realLinkTarget, realOrResolved } from "../fs-utils";
6
4
  import {
7
- links,
8
- readManifest,
9
- updateManifest,
10
- withLinks,
11
- type LinkRecord,
12
- } from "../../runtime/runtime-document-manifest";
13
- import { pruneEmptyAncestors } from "../../tools/setup/utils";
14
-
15
- const CLAUDE_SKILLS_PATH = path.join(".claude", "skills");
16
-
17
- /** The manifest key of a skill link: repo-relative, POSIX separators. */
18
- function recordKey(name: string): string {
19
- return `.claude/skills/${name}`;
20
- }
21
-
22
- async function skillNames(root: string): Promise<string[]> {
23
- const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
24
- const names: string[] = [];
25
- for (const entry of entries) {
26
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
27
- const skillPath = path.join(root, entry.name, "SKILL.md");
28
- if (
29
- await fs
30
- .stat(skillPath)
31
- .then(() => true)
32
- .catch(() => false)
33
- )
34
- names.push(entry.name);
35
- }
36
- return names;
37
- }
38
-
39
- async function linkTarget(link: string): Promise<string | null> {
40
- const target = await fs.readlink(link).catch(() => null);
41
- return target === null ? null : realLinkTarget(link, target);
42
- }
43
-
44
- async function registeredSkillRoots(
45
- repoPath: string,
46
- companionPath: string | null,
47
- registeredCompanionPaths: string[],
48
- ): Promise<string[]> {
49
- const projection = readProjectionFile(repoPath);
50
- const candidates = [
51
- ...registeredCompanionPaths,
52
- companionPath ?? "",
53
- projection?.projection.companionPath ?? "",
54
- ];
55
- const roots = await Promise.all(
56
- candidates
57
- .filter(Boolean)
58
- .map((candidate) => realOrResolved(path.resolve(candidate, CLAUDE_SKILLS_PATH))),
59
- );
60
- return [...new Set(roots)];
61
- }
62
-
63
- async function createSkillLink(target: string, link: string): Promise<void> {
64
- await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
65
- }
66
-
67
- /**
68
- * Recorded link: Mate's while it resolves to its recorded target. Unrecorded:
69
- * adopted only when it resolves into a registered, current or projected
70
- * companion. Records follow the filesystem, so a partial failure re-adopts.
71
- */
72
- export async function reconcileWorkingRepoClaudeSkillLinks(
5
+ reconcileClaudeLinks,
6
+ removeClaudeLinks,
7
+ type ClaudeLinkRoot,
8
+ } from "./projection-claude-links";
9
+
10
+ /** A companion skill is a directory holding `SKILL.md`. */
11
+ const SKILLS: ClaudeLinkRoot = {
12
+ dir: "skills",
13
+ eligible: (entryPath) =>
14
+ fs
15
+ .stat(path.join(entryPath, "SKILL.md"))
16
+ .then(() => true)
17
+ .catch(() => false),
18
+ };
19
+
20
+ export function reconcileWorkingRepoClaudeSkillLinks(
73
21
  repoPath: string,
74
22
  companionPath: string | null,
75
23
  enabled: boolean,
76
24
  registeredCompanionPaths: string[] = [],
77
25
  ): Promise<"written" | "current"> {
78
- const currentRoot = companionPath ? path.resolve(companionPath, CLAUDE_SKILLS_PATH) : null;
79
- const roots = await registeredSkillRoots(repoPath, companionPath, registeredCompanionPaths);
80
- const targetRoot = path.join(path.resolve(repoPath), CLAUDE_SKILLS_PATH);
81
- const recorded = links(await readManifest(repoPath), "claude");
82
- const existing = await fs.readdir(targetRoot, { withFileTypes: true }).catch(() => []);
83
- const sourceNames: Set<string> =
84
- currentRoot && enabled ? new Set(await skillNames(currentRoot)) : new Set<string>();
85
- const records: Record<string, LinkRecord> = {};
86
- let changed = false;
87
-
88
- for (const entry of existing) {
89
- if (!entry.isSymbolicLink()) continue;
90
- const link = path.join(targetRoot, entry.name);
91
- const target = await linkTarget(link);
92
- if (target === null) continue;
93
- const record = recorded[recordKey(entry.name)];
94
- const owned = record
95
- ? (await realOrResolved(record.target)) === target
96
- : roots.some((root) => path.dirname(target) === root);
97
- if (!owned) continue;
98
-
99
- const wanted = currentRoot ? path.join(currentRoot, entry.name) : null;
100
- if (wanted && sourceNames.has(entry.name) && (await realOrResolved(wanted)) === target) {
101
- records[recordKey(entry.name)] = { runtime: "claude", target: wanted };
102
- continue;
103
- }
104
-
105
- await fs.unlink(link);
106
- changed = true;
107
- }
108
-
109
- if (currentRoot && sourceNames.size > 0) {
110
- await fs.mkdir(targetRoot, { recursive: true });
111
- for (const name of sourceNames) {
112
- const link = path.join(targetRoot, name);
113
- const existingLink = await fs.lstat(link).catch(() => null);
114
- if (existingLink) continue;
115
- const target = path.join(currentRoot, name);
116
- await createSkillLink(target, link);
117
- records[recordKey(name)] = { runtime: "claude", target };
118
- changed = true;
119
- }
120
- }
121
-
122
- if (changed || !enabled) {
123
- await pruneEmptyAncestors(targetRoot, path.resolve(repoPath));
124
- }
125
- if (JSON.stringify(sortedEntries(records)) !== JSON.stringify(sortedEntries(recorded))) {
126
- await updateManifest(repoPath, (manifest) => withLinks(manifest, "claude", records));
127
- changed = true;
128
- }
129
- return changed ? "written" : "current";
130
- }
131
-
132
- function sortedEntries(records: Record<string, LinkRecord>): [string, LinkRecord][] {
133
- return Object.entries(records)
134
- .map(([key, record]): [string, LinkRecord] => [
135
- key,
136
- { runtime: record.runtime, target: record.target },
137
- ])
138
- .toSorted(([a], [b]) => a.localeCompare(b));
26
+ return reconcileClaudeLinks(SKILLS, repoPath, companionPath, enabled, registeredCompanionPaths);
139
27
  }
140
28
 
141
- export async function removeWorkingRepoClaudeSkillLinks(
29
+ export function removeWorkingRepoClaudeSkillLinks(
142
30
  repoPath: string,
143
31
  registeredCompanionPaths: string[] = [],
144
32
  ): Promise<"removed" | "absent"> {
145
- const result = await reconcileWorkingRepoClaudeSkillLinks(
146
- repoPath,
147
- null,
148
- false,
149
- registeredCompanionPaths,
150
- );
151
- return result === "written" ? "removed" : "absent";
33
+ return removeClaudeLinks(SKILLS, repoPath, registeredCompanionPaths);
152
34
  }
@@ -27,6 +27,10 @@ import {
27
27
  workingRepoCapabilityExcludesPresent,
28
28
  } from "../../tools/setup/working-repo-local-state";
29
29
  import { companionLinkPresent, linkCompanion, unlinkCompanion } from "./projection-companion-link";
30
+ import {
31
+ reconcileWorkingRepoClaudeCommandLinks,
32
+ removeWorkingRepoClaudeCommandLinks,
33
+ } from "./projection-claude-commands";
30
34
  import {
31
35
  reconcileWorkingRepoClaudeSkillLinks,
32
36
  removeWorkingRepoClaudeSkillLinks,
@@ -147,6 +151,23 @@ async function writeRuntimeDocumentEntry(
147
151
  };
148
152
  }
149
153
 
154
+ /** Links a companion `.claude/` folder's entries when the companion allows Claude. */
155
+ async function writeClaudeLinkEntry(
156
+ input: ProjectionInput,
157
+ reconcile: typeof reconcileWorkingRepoClaudeSkillLinks,
158
+ ): Promise<ProjectionWriteState> {
159
+ if (!input.config) return "skipped";
160
+ const registeredCompanionPaths = input.globalConfigStore
161
+ ? await input.globalConfigStore.list()
162
+ : [];
163
+ return reconcile(
164
+ input.repoPath,
165
+ input.companionPath ?? null,
166
+ input.config.allowedAgents?.includes("claude") ?? false,
167
+ registeredCompanionPaths,
168
+ );
169
+ }
170
+
150
171
  function tokensaveEnabled(input: ProjectionInput): boolean {
151
172
  return (input.config?.capabilities ?? []).some((capability) => capability.name === "tokensave");
152
173
  }
@@ -297,24 +318,32 @@ function buildEntries(): readonly ProjectionEntry[] {
297
318
  scopes: ["wrap"],
298
319
  runtime: "claude",
299
320
  removeOnUnwrap: true,
300
- write: async (input) => {
301
- if (!input.config) return "skipped";
302
- const registeredCompanionPaths = input.globalConfigStore
303
- ? await input.globalConfigStore.list()
304
- : [];
305
- return reconcileWorkingRepoClaudeSkillLinks(
306
- input.repoPath,
307
- input.companionPath ?? null,
308
- input.config.allowedAgents?.includes("claude") ?? false,
309
- registeredCompanionPaths,
310
- );
311
- },
321
+ write: (input) => writeClaudeLinkEntry(input, reconcileWorkingRepoClaudeSkillLinks),
312
322
  removal: {
313
323
  by: "self",
314
324
  remove: (input) =>
315
325
  removeWorkingRepoClaudeSkillLinks(input.repoPath, input.registeredCompanionPaths ?? []),
316
326
  },
317
327
  },
328
+ /**
329
+ * A wrapped repository reaches its companion through
330
+ * `permissions.additionalDirectories`, from which Claude loads no commands,
331
+ * so the companion's commands must be linked in like the skills.
332
+ */
333
+ {
334
+ id: "claude-command-links",
335
+ kind: "merged",
336
+ path: path.join(".claude", "commands"),
337
+ scopes: ["wrap"],
338
+ runtime: "claude",
339
+ removeOnUnwrap: true,
340
+ write: (input) => writeClaudeLinkEntry(input, reconcileWorkingRepoClaudeCommandLinks),
341
+ removal: {
342
+ by: "self",
343
+ remove: (input) =>
344
+ removeWorkingRepoClaudeCommandLinks(input.repoPath, input.registeredCompanionPaths ?? []),
345
+ },
346
+ },
318
347
  /**
319
348
  * Deliberately outside the managed block, so a Capability's exclusion
320
349
  * survives a core-block rewrite — and therefore survives `unproject` too,
@@ -384,9 +413,11 @@ function buildEntries(): readonly ProjectionEntry[] {
384
413
  path: path.join(localDir, WORKSPACE_DOCUMENT_FILE),
385
414
  scopes: ["workspace"],
386
415
  write: async (input) => {
387
- if (!input.companionPath) return "skipped";
416
+ const companionPaths =
417
+ input.workspaceCompanionPaths ?? (input.companionPath ? [input.companionPath] : []);
418
+ if (companionPaths.length === 0) return "skipped";
388
419
  const target = workspaceDocumentPath(input.repoPath);
389
- const folders = [path.resolve(input.repoPath), path.resolve(input.companionPath)];
420
+ const folders = [input.repoPath, ...companionPaths].map((folder) => path.resolve(folder));
390
421
  const next = `${JSON.stringify({ folders: folders.map((candidate) => ({ path: candidate })) }, null, 2)}\n`;
391
422
  if ((await fs.readFile(target, "utf8").catch(() => null)) === next) return "current";
392
423
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -165,14 +165,19 @@ function applyRegion(document: Record<string, unknown>, region: ManagedRegion):
165
165
  return;
166
166
  }
167
167
  if (region.kind === "map") {
168
- const found = isRecord(container[key]) ? (container[key] as Record<string, unknown>) : {};
168
+ const present = isRecord(container[key]);
169
+ const found = present ? (container[key] as Record<string, unknown>) : {};
169
170
  /** Keys an older Mate wrote here, dropped before this Mate's own are set. */
170
171
  const existing = region.stripPrefix
171
172
  ? Object.fromEntries(
172
173
  Object.entries(found).filter(([name]) => !name.startsWith(region.stripPrefix!)),
173
174
  )
174
175
  : found;
175
- container[key] = { ...existing, ...region.entries };
176
+ const next = { ...existing, ...region.entries };
177
+ /** A map only stripped is not created, and one stripped bare is not left as `{}`. */
178
+ if (Object.keys(next).length > 0) container[key] = next;
179
+ else if (present && Object.keys(found).length > 0) delete container[key];
180
+ else if (!present) pruneEmptyContainers(document, region.at);
176
181
  return;
177
182
  }
178
183
  const existing = Array.isArray(container[key]) ? (container[key] as unknown[]) : [];
@@ -212,10 +217,14 @@ function revertRegion(document: Record<string, unknown>, region: ManagedRegion):
212
217
  }
213
218
  }
214
219
 
215
- /** An emptied container Mate created is not left behind as `{}`. */
216
- for (let depth = region.at.length - 1; depth > 0; depth -= 1) {
217
- const parent = containerFor(document, region.at.slice(0, depth), false);
218
- const parentKey = region.at[depth - 1];
220
+ pruneEmptyContainers(document, region.at);
221
+ }
222
+
223
+ /** An emptied container Mate created is not left behind as `{}`. */
224
+ function pruneEmptyContainers(document: Record<string, unknown>, at: string[]): void {
225
+ for (let depth = at.length - 1; depth > 0; depth -= 1) {
226
+ const parent = containerFor(document, at.slice(0, depth), false);
227
+ const parentKey = at[depth - 1];
219
228
  const node = parent?.[parentKey];
220
229
  if (parent && isRecord(node) && Object.keys(node).length === 0) delete parent[parentKey];
221
230
  }