@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.
- package/claude-plugin/hooks/companion-instructions.mjs +9 -0
- package/claude-plugin/hooks/hooks.json +12 -2
- package/package.json +10 -2
- package/src/brand/art.generated.ts +69 -0
- package/src/brand/index.ts +12 -0
- package/src/brand/palette.ts +19 -0
- package/src/brand/svg.generated.ts +8 -0
- package/src/brand/terminal.ts +83 -0
- package/src/brand/types.ts +33 -0
- package/src/cli/commands/artifact/artifact.ts +8 -0
- package/src/cli/commands/artifact/delivery/archive.ts +117 -0
- package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
- package/src/cli/commands/artifact/delivery/environment.ts +104 -0
- package/src/cli/commands/artifact/delivery/link.ts +154 -0
- package/src/cli/commands/artifact/delivery/output.ts +58 -0
- package/src/cli/commands/artifact/finish/command.ts +33 -3
- package/src/cli/commands/artifact/finish/engine.ts +47 -3
- package/src/cli/commands/artifact/finish/finisher.ts +10 -0
- package/src/cli/commands/artifact/finish/openspec.ts +177 -138
- package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
- package/src/cli/commands/artifact/pending/command.ts +31 -3
- package/src/cli/commands/artifact/pending/discovery.ts +21 -57
- package/src/cli/commands/cap/graphify.ts +52 -3
- package/src/cli/commands/cap/index-cmd.ts +8 -1
- package/src/cli/commands/cap/openspec.ts +13 -25
- package/src/cli/commands/companion/open.ts +56 -39
- package/src/cli/commands/companion/update.ts +75 -42
- package/src/cli/commands/doctor.ts +176 -3
- package/src/cli/commands/plugin/install.ts +9 -4
- package/src/cli/commands/plugin/plugin.ts +3 -2
- package/src/cli/commands/report/adapter.ts +11 -2
- package/src/cli/commands/report/contract.ts +21 -0
- package/src/cli/commands/report/highlight.ts +34 -4
- package/src/cli/commands/report/markdown.ts +110 -0
- package/src/cli/commands/report/palette.ts +118 -0
- package/src/cli/commands/report/renderer.ts +456 -153
- package/src/cli/commands/report/types.ts +16 -0
- package/src/cli/commands/setup.ts +7 -15
- package/src/cli/commands/studio/client-asset.ts +16 -0
- package/src/cli/commands/studio/openspec-cli.ts +10 -58
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +19 -1
- package/src/cli/commands/studio/server.ts +42 -1
- package/src/cli/commands/studio/views/client.ts +6 -616
- package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
- package/src/cli/commands/studio/views/document.tsx +137 -69
- package/src/cli/commands/studio/views/markdown.tsx +250 -0
- package/src/cli/commands/studio/views/palette.ts +134 -0
- package/src/cli/commands/studio/views/reports/index.tsx +2 -7
- package/src/cli/commands/studio/views/skills/index.tsx +43 -62
- package/src/cli/commands/studio/views/specs/index.tsx +7 -15
- package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
- package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
- package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
- package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
- package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
- package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
- package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
- package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
- package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
- package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
- package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
- package/src/cli/commands/studio/views/styles.ts +217 -206
- package/src/cli/commands/studio/views/vault/state.ts +3 -5
- package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
- package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
- package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
- package/src/cli/commands/wrap.ts +5 -13
- package/src/cli/main.ts +4 -1
- package/src/cli/parse-flags.ts +25 -7
- package/src/cli/setup-selector.tsx +38 -162
- package/src/cli/usage.ts +4 -0
- package/src/hooks/companion-instructions.ts +190 -0
- package/src/hooks/hook-io.ts +63 -0
- package/src/hooks/session-banner.ts +21 -29
- package/src/hooks/session-guidance.ts +9 -17
- package/src/hooks/session-prepare.ts +115 -28
- package/src/hooks/validate-artifact-path.ts +82 -24
- package/src/index.ts +4 -0
- package/src/lib/change-layout.ts +154 -0
- package/src/lib/components/wizard-header.tsx +3 -1
- package/src/lib/context-mode-package.ts +24 -11
- package/src/lib/delivery/archive-change.ts +154 -0
- package/src/lib/delivery/branches.ts +108 -0
- package/src/lib/delivery/environment.ts +29 -0
- package/src/lib/delivery/forge.ts +279 -0
- package/src/lib/delivery/overlap.ts +202 -0
- package/src/lib/delivery/record-delivery.ts +196 -0
- package/src/lib/delivery/record.ts +343 -0
- package/src/lib/delivery/refresh.ts +174 -0
- package/src/lib/delivery/repository-access.ts +97 -0
- package/src/lib/delivery/stages.ts +111 -0
- package/src/lib/delivery/token.ts +58 -0
- package/src/lib/frontmatter.ts +18 -0
- package/src/lib/opencode-plugin-package.ts +5 -5
- package/src/lib/openspec-cli.ts +116 -0
- package/src/lib/openspec-scopes.ts +86 -0
- package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
- package/src/lib/orchestrator/adapters/claude.ts +48 -40
- package/src/lib/orchestrator/adapters/opencode.ts +2 -0
- package/src/lib/orchestrator/editor.ts +9 -9
- package/src/lib/orchestrator/openspec-store.ts +14 -54
- package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
- package/src/lib/orchestrator/projection-claude-links.ts +159 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +19 -137
- package/src/lib/orchestrator/projection-entries.ts +45 -14
- package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
- package/src/lib/orchestrator/projection-types.ts +3 -0
- package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
- package/src/lib/orchestrator/session-prepare.ts +45 -5
- package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
- package/src/lib/orchestrator/types.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +22 -1
- package/src/lib/package-paths.ts +8 -0
- package/src/opencode/companion-hooks.ts +64 -5
- package/src/opencode/companion-policy.ts +37 -18
- package/src/opencode/projected-guidance.ts +4 -1
- package/src/opencode/session-prepare.ts +2 -1
- package/src/opencode/tui.tsx +4 -2
- package/src/runtime/artifact-refusal.ts +63 -0
- package/src/runtime/claude-delivery-plan.ts +89 -7
- package/src/runtime/commit-attribution-guard.ts +181 -0
- package/src/runtime/companion-guidance.ts +15 -3
- package/src/runtime/companion-instructions.ts +340 -0
- package/src/runtime/error-message.ts +4 -0
- package/src/runtime/index.ts +5 -0
- package/src/runtime/managed-block.ts +117 -0
- package/src/runtime/markdown-scan.ts +266 -0
- package/src/runtime/policy.ts +15 -1
- package/src/runtime/projected-flag.ts +7 -0
- package/src/runtime/projected-guidance.ts +1 -0
- package/src/runtime/runtime-document-manifest.ts +62 -11
- package/src/runtime/secret-guard.ts +148 -0
- package/src/runtime/secret-patterns.ts +164 -0
- package/src/runtime/secret-scan.ts +90 -0
- package/src/runtime/session-kind.ts +13 -0
- package/src/runtime/shell-heredoc.ts +81 -0
- package/src/runtime/wrap-state.ts +36 -2
- package/src/runtime/write-atomic.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
- package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
- package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/{claude/mate-artifact-publish → agents/mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/{agents/mate-artifact-publish → claude/mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/openspec-references.ts +140 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
- package/src/tools/setup/capabilities/context-mode.ts +25 -5
- package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
- package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
- package/src/tools/setup/capabilities/graphify.ts +12 -0
- package/src/tools/setup/capabilities/openspec.ts +151 -270
- package/src/tools/setup/capabilities/tokensave.ts +36 -3
- package/src/tools/setup/context-services.ts +9 -17
- package/src/tools/setup/dynamic-plugins/install.ts +24 -28
- package/src/tools/setup/engine.ts +97 -21
- package/src/tools/setup/managed-output.ts +51 -0
- package/src/tools/setup/mate.ts +86 -11
- package/src/tools/setup/plugin.ts +35 -0
- package/src/tools/setup/plugins/guidance.ts +7 -4
- package/src/tools/setup/providers/agent-file-sections.ts +4 -2
- package/src/tools/setup/providers/claude-attribution.ts +65 -0
- package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
- package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
- package/src/tools/setup/providers/claude.ts +284 -8
- package/src/tools/setup/providers/opencode.ts +180 -1
- package/src/tools/setup/utils.ts +14 -4
- package/src/tools/setup.ts +126 -10
- package/wrappers/bin/graphify +52 -4
- package/wrappers/bin/openspec +33 -9
- package/wrappers/lib/graphify-gate.mjs +8 -0
- package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
- 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: [
|
|
77
|
+
folders: string[];
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* Writes the generated `.mate/workspace.code-workspace` file (working
|
|
82
|
-
* repository first, companion
|
|
83
|
-
* order. Called by editor-launching `injectEditorFolder`,
|
|
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
|
-
|
|
87
|
+
companionPaths: string[],
|
|
88
88
|
repoPath: string,
|
|
89
89
|
): Promise<WorkspaceDocument> {
|
|
90
|
-
await project("workspace", { repoPath,
|
|
90
|
+
await project("workspace", { repoPath, workspaceCompanionPaths: companionPaths });
|
|
91
91
|
return {
|
|
92
92
|
workspacePath: workspaceDocumentPath(repoPath),
|
|
93
|
-
folders: [
|
|
93
|
+
folders: [repoPath, ...companionPaths].map((folder) => path.resolve(folder)),
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export async function injectEditorFolder(
|
|
98
|
-
|
|
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(
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
29
|
+
export function removeWorkingRepoClaudeSkillLinks(
|
|
142
30
|
repoPath: string,
|
|
143
31
|
registeredCompanionPaths: string[] = [],
|
|
144
32
|
): Promise<"removed" | "absent"> {
|
|
145
|
-
|
|
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:
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
}
|