@uniqbit/mate-core 0.18.4 → 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 (231) hide show
  1. package/claude-plugin/hooks/companion-instructions.mjs +9 -0
  2. package/claude-plugin/hooks/hooks.json +12 -2
  3. package/opencode-plugin/src/add-dir.ts +76 -10
  4. package/opencode-plugin/src/companion.ts +1 -8
  5. package/opencode-plugin/src/server.ts +2 -15
  6. package/package.json +10 -2
  7. package/src/brand/art.generated.ts +69 -0
  8. package/src/brand/index.ts +12 -0
  9. package/src/brand/palette.ts +19 -0
  10. package/src/brand/svg.generated.ts +8 -0
  11. package/src/brand/terminal.ts +83 -0
  12. package/src/brand/types.ts +33 -0
  13. package/src/cli/commands/artifact/artifact.ts +8 -0
  14. package/src/cli/commands/artifact/delivery/archive.ts +117 -0
  15. package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
  16. package/src/cli/commands/artifact/delivery/environment.ts +104 -0
  17. package/src/cli/commands/artifact/delivery/link.ts +154 -0
  18. package/src/cli/commands/artifact/delivery/output.ts +58 -0
  19. package/src/cli/commands/artifact/finish/command.ts +33 -3
  20. package/src/cli/commands/artifact/finish/engine.ts +47 -3
  21. package/src/cli/commands/artifact/finish/finisher.ts +10 -0
  22. package/src/cli/commands/artifact/finish/openspec.ts +200 -129
  23. package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
  24. package/src/cli/commands/artifact/pending/command.ts +31 -3
  25. package/src/cli/commands/artifact/pending/discovery.ts +21 -57
  26. package/src/cli/commands/cap/graphify.ts +52 -3
  27. package/src/cli/commands/cap/index-cmd.ts +8 -1
  28. package/src/cli/commands/cap/openspec.ts +13 -25
  29. package/src/cli/commands/companion/open.ts +56 -39
  30. package/src/cli/commands/companion/update.ts +75 -42
  31. package/src/cli/commands/doctor.ts +257 -78
  32. package/src/cli/commands/install.ts +0 -8
  33. package/src/cli/commands/plugin/install.ts +9 -4
  34. package/src/cli/commands/plugin/plugin.ts +3 -2
  35. package/src/cli/commands/report/adapter.ts +11 -2
  36. package/src/cli/commands/report/contract.ts +21 -0
  37. package/src/cli/commands/report/highlight.ts +34 -4
  38. package/src/cli/commands/report/markdown.ts +110 -0
  39. package/src/cli/commands/report/palette.ts +118 -0
  40. package/src/cli/commands/report/renderer.ts +456 -153
  41. package/src/cli/commands/report/types.ts +16 -0
  42. package/src/cli/commands/setup.ts +7 -15
  43. package/src/cli/commands/studio/client-asset.ts +16 -0
  44. package/src/cli/commands/studio/openspec-cli.ts +10 -58
  45. package/src/cli/commands/studio/routes.ts +2 -0
  46. package/src/cli/commands/studio/selection.ts +19 -1
  47. package/src/cli/commands/studio/server.ts +42 -1
  48. package/src/cli/commands/studio/terminal-launch.ts +7 -16
  49. package/src/cli/commands/studio/views/client.ts +6 -616
  50. package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
  51. package/src/cli/commands/studio/views/document.tsx +137 -69
  52. package/src/cli/commands/studio/views/markdown.tsx +250 -0
  53. package/src/cli/commands/studio/views/palette.ts +134 -0
  54. package/src/cli/commands/studio/views/reports/index.tsx +2 -7
  55. package/src/cli/commands/studio/views/skills/index.tsx +43 -62
  56. package/src/cli/commands/studio/views/specs/index.tsx +7 -15
  57. package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
  58. package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
  59. package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
  60. package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
  61. package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
  62. package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
  63. package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
  64. package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
  65. package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
  66. package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
  67. package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
  68. package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
  69. package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
  70. package/src/cli/commands/studio/views/styles.ts +217 -206
  71. package/src/cli/commands/studio/views/vault/state.ts +3 -5
  72. package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
  73. package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
  74. package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
  75. package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
  76. package/src/cli/commands/wrap.ts +9 -42
  77. package/src/cli/main.ts +4 -1
  78. package/src/cli/parse-flags.ts +25 -7
  79. package/src/cli/setup-selector.tsx +38 -162
  80. package/src/cli/usage.ts +5 -2
  81. package/src/hooks/companion-instructions.ts +190 -0
  82. package/src/hooks/hook-io.ts +63 -0
  83. package/src/hooks/session-banner.ts +21 -29
  84. package/src/hooks/session-guidance.ts +10 -36
  85. package/src/hooks/session-prepare.ts +115 -28
  86. package/src/hooks/validate-artifact-path.ts +82 -24
  87. package/src/index.ts +4 -0
  88. package/src/lib/change-layout.ts +154 -0
  89. package/src/lib/components/wizard-header.tsx +3 -1
  90. package/src/lib/context-mode-package.ts +24 -11
  91. package/src/lib/delivery/archive-change.ts +154 -0
  92. package/src/lib/delivery/branches.ts +108 -0
  93. package/src/lib/delivery/environment.ts +29 -0
  94. package/src/lib/delivery/forge.ts +279 -0
  95. package/src/lib/delivery/overlap.ts +202 -0
  96. package/src/lib/delivery/record-delivery.ts +196 -0
  97. package/src/lib/delivery/record.ts +343 -0
  98. package/src/lib/delivery/refresh.ts +174 -0
  99. package/src/lib/delivery/repository-access.ts +97 -0
  100. package/src/lib/delivery/stages.ts +111 -0
  101. package/src/lib/delivery/token.ts +58 -0
  102. package/src/lib/frontmatter.ts +18 -0
  103. package/src/lib/opencode-plugin-package.ts +5 -5
  104. package/src/lib/openspec-cli.ts +116 -0
  105. package/src/lib/openspec-scopes.ts +86 -0
  106. package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
  107. package/src/lib/orchestrator/adapters/claude.ts +60 -162
  108. package/src/lib/orchestrator/adapters/opencode.ts +9 -24
  109. package/src/lib/orchestrator/config-store.ts +2 -70
  110. package/src/lib/orchestrator/editor.ts +9 -9
  111. package/src/lib/orchestrator/openspec-store.ts +14 -54
  112. package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
  113. package/src/lib/orchestrator/projection-claude-links.ts +159 -0
  114. package/src/lib/orchestrator/projection-claude-skills.ts +19 -190
  115. package/src/lib/orchestrator/projection-entries.ts +45 -15
  116. package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
  117. package/src/lib/orchestrator/projection-types.ts +3 -2
  118. package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
  119. package/src/lib/orchestrator/session-prepare.ts +49 -87
  120. package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
  121. package/src/lib/orchestrator/types.ts +2 -2
  122. package/src/lib/orchestrator/wrap-pass.ts +22 -88
  123. package/src/lib/package-paths.ts +8 -0
  124. package/src/opencode/companion-hooks.ts +64 -5
  125. package/src/opencode/companion-policy.ts +37 -18
  126. package/src/opencode/projected-guidance.ts +4 -1
  127. package/src/opencode/session-prepare.ts +2 -1
  128. package/src/opencode/tui.tsx +4 -2
  129. package/src/runtime/artifact-refusal.ts +63 -0
  130. package/src/runtime/claude-delivery-plan.ts +89 -7
  131. package/src/runtime/commit-attribution-guard.ts +181 -0
  132. package/src/runtime/companion-guidance.ts +15 -3
  133. package/src/runtime/companion-instructions.ts +340 -0
  134. package/src/runtime/error-message.ts +4 -0
  135. package/src/runtime/guidance.ts +0 -17
  136. package/src/runtime/index.ts +5 -0
  137. package/src/runtime/managed-block.ts +117 -0
  138. package/src/runtime/markdown-scan.ts +266 -0
  139. package/src/runtime/policy.ts +16 -15
  140. package/src/runtime/projected-flag.ts +7 -0
  141. package/src/runtime/projected-guidance.ts +1 -0
  142. package/src/runtime/runtime-document-manifest.ts +62 -28
  143. package/src/runtime/secret-guard.ts +148 -0
  144. package/src/runtime/secret-patterns.ts +164 -0
  145. package/src/runtime/secret-scan.ts +90 -0
  146. package/src/runtime/session-kind.ts +13 -0
  147. package/src/runtime/shell-heredoc.ts +81 -0
  148. package/src/runtime/wrap-state.ts +36 -6
  149. package/src/runtime/write-atomic.ts +64 -0
  150. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
  151. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
  152. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
  153. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  154. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
  155. package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
  156. package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
  157. package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
  158. package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
  159. package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
  160. package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
  161. package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
  162. package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
  163. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
  164. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
  165. package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
  166. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
  167. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
  168. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
  169. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  170. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
  171. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
  172. package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
  173. package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
  174. package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
  175. package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
  176. package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
  177. package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
  178. package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
  179. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
  180. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
  181. package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
  182. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
  183. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
  184. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
  185. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  186. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
  187. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
  188. package/src/templates/mate-skills/openspec-references.ts +140 -0
  189. package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
  190. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
  191. package/src/tools/setup/capabilities/context-mode.ts +25 -5
  192. package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
  193. package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
  194. package/src/tools/setup/capabilities/graphify.ts +12 -0
  195. package/src/tools/setup/capabilities/openspec.ts +273 -270
  196. package/src/tools/setup/capabilities/tokensave.ts +36 -3
  197. package/src/tools/setup/context-services.ts +9 -17
  198. package/src/tools/setup/dynamic-plugins/audiences.ts +7 -39
  199. package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
  200. package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
  201. package/src/tools/setup/dynamic-plugins/install.ts +24 -28
  202. package/src/tools/setup/engine.ts +93 -148
  203. package/src/tools/setup/managed-output.ts +51 -0
  204. package/src/tools/setup/mate.ts +86 -11
  205. package/src/tools/setup/plugin.ts +37 -17
  206. package/src/tools/setup/plugins/gitignore.ts +1 -7
  207. package/src/tools/setup/plugins/guidance.ts +7 -4
  208. package/src/tools/setup/providers/agent-file-sections.ts +4 -2
  209. package/src/tools/setup/providers/claude-attribution.ts +65 -0
  210. package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
  211. package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
  212. package/src/tools/setup/providers/claude.ts +285 -34
  213. package/src/tools/setup/providers/opencode.ts +180 -11
  214. package/src/tools/setup/registry.ts +2 -24
  215. package/src/tools/setup/surface-target.ts +4 -13
  216. package/src/tools/setup/utils.ts +14 -4
  217. package/src/tools/setup.ts +131 -18
  218. package/wrappers/bin/graphify +52 -4
  219. package/wrappers/bin/openspec +33 -9
  220. package/wrappers/lib/graphify-gate.mjs +8 -0
  221. package/opencode-plugin/src/audience-layer.ts +0 -91
  222. package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
  223. package/src/runtime/audience-layer.ts +0 -84
  224. package/src/runtime/opencode-audience-layer.ts +0 -176
  225. package/src/tools/setup/audience-isolation.fixture.ts +0 -325
  226. package/src/tools/setup/audience-layer.ts +0 -207
  227. package/src/tools/setup/audience-registrations.ts +0 -52
  228. package/src/tools/setup/contribution-ledger.ts +0 -207
  229. package/src/tools/setup/isolation-diagnostics.ts +0 -139
  230. package/src/tools/setup/runtime-documents.ts +0 -291
  231. package/src/tools/setup/upgrade-sweep.ts +0 -153
@@ -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,205 +1,34 @@
1
- // oxlint-disable no-await-in-loop
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
 
5
- import { CURRENT_LAYER_LINK } from "../../runtime/audience-layer";
6
- import { readProjectionFile } from "../../runtime/projection";
7
- import { realLinkTarget, realOrResolved } from "../fs-utils";
8
4
  import {
9
- links,
10
- readManifest,
11
- updateManifest,
12
- withLinks,
13
- type LinkRecord,
14
- } from "../../runtime/runtime-document-manifest";
15
- import { pruneEmptyAncestors } from "../../tools/setup/utils";
16
-
17
- const CLAUDE_SKILLS_PATH = path.join(".claude", "skills");
18
-
19
- /** The manifest key of a skill link: repo-relative, POSIX separators. */
20
- function recordKey(name: string): string {
21
- return `.claude/skills/${name}`;
22
- }
23
-
24
- async function skillNames(root: string): Promise<string[]> {
25
- const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
26
- const names: string[] = [];
27
- for (const entry of entries) {
28
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
29
- const skillPath = path.join(root, entry.name, "SKILL.md");
30
- if (
31
- await fs
32
- .stat(skillPath)
33
- .then(() => true)
34
- .catch(() => false)
35
- )
36
- names.push(entry.name);
37
- }
38
- return names;
39
- }
40
-
41
- async function linkTarget(link: string): Promise<string | null> {
42
- const target = await fs.readlink(link).catch(() => null);
43
- return target === null ? null : realLinkTarget(link, target);
44
- }
45
-
46
- async function registeredSkillRoots(
47
- repoPath: string,
48
- companionPath: string | null,
49
- registeredCompanionPaths: string[],
50
- ): Promise<string[]> {
51
- const projection = readProjectionFile(repoPath);
52
- const candidates = [
53
- ...registeredCompanionPaths,
54
- companionPath ?? "",
55
- projection?.projection.companionPath ?? "",
56
- ];
57
- const roots = await Promise.all(
58
- candidates
59
- .filter(Boolean)
60
- .map((candidate) => realOrResolved(path.resolve(candidate, CLAUDE_SKILLS_PATH))),
61
- );
62
- return [...new Set(roots)];
63
- }
64
-
65
- async function createSkillLink(target: string, link: string): Promise<void> {
66
- await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
67
- }
68
-
69
- const CLAUDE_AGENTS_PATH = path.join(".claude", "agents");
70
-
71
- /** The manifest key of an agent link: repo-relative, POSIX separators. */
72
- function agentKey(file: string): string {
73
- return `.claude/agents/${file}`;
74
- }
75
-
76
- async function agentFiles(root: string): Promise<string[]> {
77
- const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
78
- return entries
79
- .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md"))
80
- .map((entry) => entry.name);
81
- }
82
-
83
- /**
84
- * Every link the wrap wants, by manifest key: the companion's skills, then the
85
- * wrapped audience's skills and agents. An audience link targets the layer
86
- * through its `current` link, so a re-rendered layer never strands it. A
87
- * companion skill wins a name both provide.
88
- */
89
- async function wantedLinks(
90
- currentRoot: string | null,
91
- layerRoot: string | null,
92
- ): Promise<Map<string, string>> {
93
- const wanted = new Map<string, string>();
94
- if (currentRoot) {
95
- for (const name of await skillNames(currentRoot)) {
96
- wanted.set(recordKey(name), path.join(currentRoot, name));
97
- }
98
- }
99
- if (layerRoot) {
100
- const layerSkills = path.join(layerRoot, "skills");
101
- for (const name of await skillNames(layerSkills)) {
102
- if (!wanted.has(recordKey(name))) wanted.set(recordKey(name), path.join(layerSkills, name));
103
- }
104
- const layerAgents = path.join(layerRoot, "agents");
105
- for (const file of await agentFiles(layerAgents)) {
106
- wanted.set(agentKey(file), path.join(layerAgents, file));
107
- }
108
- }
109
- return wanted;
110
- }
111
-
112
- /**
113
- * Recorded link: Mate's while it resolves to its recorded target. Unrecorded:
114
- * a skill link is adopted only when it resolves into a registered, current or
115
- * projected companion; an agent link never is. Records follow the filesystem,
116
- * so a partial failure re-adopts.
117
- */
118
- 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(
119
21
  repoPath: string,
120
22
  companionPath: string | null,
121
23
  enabled: boolean,
122
24
  registeredCompanionPaths: string[] = [],
123
- audienceLayerDir: string | null = null,
124
25
  ): Promise<"written" | "current"> {
125
- const repoRoot = path.resolve(repoPath);
126
- const currentRoot = companionPath ? path.resolve(companionPath, CLAUDE_SKILLS_PATH) : null;
127
- const layerRoot =
128
- audienceLayerDir && enabled ? path.join(audienceLayerDir, CURRENT_LAYER_LINK, ".claude") : null;
129
- const roots = await registeredSkillRoots(repoPath, companionPath, registeredCompanionPaths);
130
- const recorded = links(await readManifest(repoPath), "claude");
131
- const wanted = await wantedLinks(enabled ? currentRoot : null, layerRoot);
132
- const records: Record<string, LinkRecord> = {};
133
- let changed = false;
134
-
135
- const linkDirs = [
136
- { dir: path.join(repoRoot, CLAUDE_SKILLS_PATH), keyOf: recordKey, adoptable: true },
137
- { dir: path.join(repoRoot, CLAUDE_AGENTS_PATH), keyOf: agentKey, adoptable: false },
138
- ];
139
- for (const { dir, keyOf, adoptable } of linkDirs) {
140
- const existing = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
141
- for (const entry of existing) {
142
- if (!entry.isSymbolicLink()) continue;
143
- const link = path.join(dir, entry.name);
144
- const target = await linkTarget(link);
145
- if (target === null) continue;
146
- const key = keyOf(entry.name);
147
- const record = recorded[key];
148
- const owned = record
149
- ? (await realOrResolved(record.target)) === target
150
- : adoptable && roots.some((root) => path.dirname(target) === root);
151
- if (!owned) continue;
152
-
153
- const want = wanted.get(key);
154
- if (want && (await realOrResolved(want)) === target) {
155
- records[key] = { runtime: "claude", target: want };
156
- continue;
157
- }
158
-
159
- await fs.unlink(link);
160
- changed = true;
161
- }
162
- }
163
-
164
- for (const [key, target] of wanted) {
165
- const link = path.join(repoRoot, ...key.split("/"));
166
- const existingLink = await fs.lstat(link).catch(() => null);
167
- if (existingLink) continue;
168
- await fs.mkdir(path.dirname(link), { recursive: true });
169
- if (key.startsWith(".claude/agents/")) await fs.symlink(target, link, "file");
170
- else await createSkillLink(target, link);
171
- records[key] = { runtime: "claude", target };
172
- changed = true;
173
- }
174
-
175
- if (changed || !enabled) {
176
- for (const { dir } of linkDirs) await pruneEmptyAncestors(dir, repoRoot);
177
- }
178
- if (JSON.stringify(sortedEntries(records)) !== JSON.stringify(sortedEntries(recorded))) {
179
- await updateManifest(repoPath, (manifest) => withLinks(manifest, "claude", records));
180
- changed = true;
181
- }
182
- return changed ? "written" : "current";
183
- }
184
-
185
- function sortedEntries(records: Record<string, LinkRecord>): [string, LinkRecord][] {
186
- return Object.entries(records)
187
- .map(([key, record]): [string, LinkRecord] => [
188
- key,
189
- { runtime: record.runtime, target: record.target },
190
- ])
191
- .toSorted(([a], [b]) => a.localeCompare(b));
26
+ return reconcileClaudeLinks(SKILLS, repoPath, companionPath, enabled, registeredCompanionPaths);
192
27
  }
193
28
 
194
- export async function removeWorkingRepoClaudeSkillLinks(
29
+ export function removeWorkingRepoClaudeSkillLinks(
195
30
  repoPath: string,
196
31
  registeredCompanionPaths: string[] = [],
197
32
  ): Promise<"removed" | "absent"> {
198
- const result = await reconcileWorkingRepoClaudeSkillLinks(
199
- repoPath,
200
- null,
201
- false,
202
- registeredCompanionPaths,
203
- );
204
- return result === "written" ? "removed" : "absent";
33
+ return removeClaudeLinks(SKILLS, repoPath, registeredCompanionPaths);
205
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,25 +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
- input.audienceLayerDir ?? null,
311
- );
312
- },
321
+ write: (input) => writeClaudeLinkEntry(input, reconcileWorkingRepoClaudeSkillLinks),
313
322
  removal: {
314
323
  by: "self",
315
324
  remove: (input) =>
316
325
  removeWorkingRepoClaudeSkillLinks(input.repoPath, input.registeredCompanionPaths ?? []),
317
326
  },
318
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
+ },
319
347
  /**
320
348
  * Deliberately outside the managed block, so a Capability's exclusion
321
349
  * survives a core-block rewrite — and therefore survives `unproject` too,
@@ -385,9 +413,11 @@ function buildEntries(): readonly ProjectionEntry[] {
385
413
  path: path.join(localDir, WORKSPACE_DOCUMENT_FILE),
386
414
  scopes: ["workspace"],
387
415
  write: async (input) => {
388
- if (!input.companionPath) return "skipped";
416
+ const companionPaths =
417
+ input.workspaceCompanionPaths ?? (input.companionPath ? [input.companionPath] : []);
418
+ if (companionPaths.length === 0) return "skipped";
389
419
  const target = workspaceDocumentPath(input.repoPath);
390
- const folders = [path.resolve(input.repoPath), path.resolve(input.companionPath)];
420
+ const folders = [input.repoPath, ...companionPaths].map((folder) => path.resolve(folder));
391
421
  const next = `${JSON.stringify({ folders: folders.map((candidate) => ({ path: candidate })) }, null, 2)}\n`;
392
422
  if ((await fs.readFile(target, "utf8").catch(() => null)) === next) return "current";
393
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
  }