@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.
- package/claude-plugin/hooks/companion-instructions.mjs +9 -0
- package/claude-plugin/hooks/hooks.json +12 -2
- package/opencode-plugin/src/add-dir.ts +76 -10
- package/opencode-plugin/src/companion.ts +1 -8
- package/opencode-plugin/src/server.ts +2 -15
- 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 +200 -129
- 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 +257 -78
- package/src/cli/commands/install.ts +0 -8
- 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/terminal-launch.ts +7 -16
- 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 +9 -42
- 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 +5 -2
- 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 +10 -36
- 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 +60 -162
- package/src/lib/orchestrator/adapters/opencode.ts +9 -24
- package/src/lib/orchestrator/config-store.ts +2 -70
- 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 -190
- package/src/lib/orchestrator/projection-entries.ts +45 -15
- package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
- package/src/lib/orchestrator/projection-types.ts +3 -2
- package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
- package/src/lib/orchestrator/session-prepare.ts +49 -87
- 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 -88
- 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/guidance.ts +0 -17
- 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 +16 -15
- package/src/runtime/projected-flag.ts +7 -0
- package/src/runtime/projected-guidance.ts +1 -0
- package/src/runtime/runtime-document-manifest.ts +62 -28
- 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 -6
- 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/agents/{mate-artifact-publish → 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/claude/{mate-artifact-publish → 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 +273 -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/audiences.ts +7 -39
- package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
- package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
- package/src/tools/setup/dynamic-plugins/install.ts +24 -28
- package/src/tools/setup/engine.ts +93 -148
- package/src/tools/setup/managed-output.ts +51 -0
- package/src/tools/setup/mate.ts +86 -11
- package/src/tools/setup/plugin.ts +37 -17
- package/src/tools/setup/plugins/gitignore.ts +1 -7
- 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 +285 -34
- package/src/tools/setup/providers/opencode.ts +180 -11
- package/src/tools/setup/registry.ts +2 -24
- package/src/tools/setup/surface-target.ts +4 -13
- package/src/tools/setup/utils.ts +14 -4
- package/src/tools/setup.ts +131 -18
- package/wrappers/bin/graphify +52 -4
- package/wrappers/bin/openspec +33 -9
- package/wrappers/lib/graphify-gate.mjs +8 -0
- package/opencode-plugin/src/audience-layer.ts +0 -91
- package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
- package/src/runtime/audience-layer.ts +0 -84
- package/src/runtime/opencode-audience-layer.ts +0 -176
- package/src/tools/setup/audience-isolation.fixture.ts +0 -325
- package/src/tools/setup/audience-layer.ts +0 -207
- package/src/tools/setup/audience-registrations.ts +0 -52
- package/src/tools/setup/contribution-ledger.ts +0 -207
- package/src/tools/setup/isolation-diagnostics.ts +0 -139
- package/src/tools/setup/runtime-documents.ts +0 -291
- 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 {
|
|
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,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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
29
|
+
export function removeWorkingRepoClaudeSkillLinks(
|
|
195
30
|
repoPath: string,
|
|
196
31
|
registeredCompanionPaths: string[] = [],
|
|
197
32
|
): Promise<"removed" | "absent"> {
|
|
198
|
-
|
|
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:
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
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
|
}
|