@uniqbit/mate-core 0.15.5 → 0.16.0-canary.1
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/.claude-plugin/plugin.json +2 -2
- package/claude-plugin/hooks/hooks.json +3 -6
- package/claude-plugin/hooks/session-guidance.mjs +8 -0
- package/claude-plugin/hooks/ts-loader.mjs +19 -0
- package/package.json +7 -5
- package/src/cli/commands/artifact/artifact.ts +19 -3
- package/src/cli/commands/artifact/finish/command.ts +183 -57
- package/src/cli/commands/artifact/finish/engine.ts +80 -91
- package/src/cli/commands/artifact/finish/finisher.ts +26 -33
- package/src/cli/commands/artifact/finish/git.ts +34 -23
- package/src/cli/commands/artifact/finish/index.ts +9 -3
- package/src/cli/commands/artifact/finish/openspec.ts +225 -97
- package/src/cli/commands/artifact/pending/command.ts +175 -0
- package/src/cli/commands/artifact/pending/discovery.ts +249 -0
- package/src/cli/commands/artifact/pending/index.ts +17 -0
- package/src/cli/commands/cap/index-cmd.ts +9 -1
- package/src/cli/commands/cap/index.ts +2 -6
- package/src/cli/commands/cap/tokensave.ts +4 -4
- package/src/cli/commands/companion/companion.ts +5 -1
- package/src/cli/commands/companion/link.ts +2 -2
- package/src/cli/commands/companion/sync.ts +92 -0
- package/src/cli/commands/doctor.ts +0 -3
- package/src/cli/commands/launch/shared.ts +23 -5
- package/src/cli/commands/report/collector.ts +72 -78
- package/src/cli/commands/report/contract.ts +40 -1
- package/src/cli/commands/report/highlight.ts +27 -0
- package/src/cli/commands/report/index.ts +11 -18
- package/src/cli/commands/report/renderer.ts +199 -2
- package/src/cli/commands/report/types.ts +26 -1
- package/src/cli/commands/shared/companion-selection.ts +107 -10
- package/src/cli/commands/studio/areas.ts +68 -0
- package/src/cli/commands/studio/index.ts +69 -0
- package/src/cli/commands/studio/inventory.ts +55 -0
- package/src/cli/commands/studio/mate-inventory.ts +43 -0
- package/src/cli/commands/studio/openspec-cli.ts +198 -0
- package/src/cli/commands/studio/payload.ts +184 -0
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +61 -0
- package/src/cli/commands/studio/server.ts +201 -0
- package/src/cli/commands/studio/snapshot.ts +63 -0
- package/src/cli/commands/studio/topology.ts +199 -0
- package/src/cli/commands/studio/views/client.ts +197 -0
- package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
- package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
- package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
- package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
- package/src/cli/commands/studio/views/document.tsx +256 -0
- package/src/cli/commands/studio/views/error.tsx +20 -0
- package/src/cli/commands/studio/views/model.ts +34 -0
- package/src/cli/commands/studio/views/pairings.tsx +38 -0
- package/src/cli/commands/studio/views/skills/index.tsx +87 -0
- package/src/cli/commands/studio/views/specs/index.tsx +101 -0
- package/src/cli/commands/studio/views/styles.ts +389 -0
- package/src/cli/commands/studio/views/warnings.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
- package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
- package/src/cli/commands/unwrap.ts +70 -0
- package/src/cli/commands/wrap.ts +164 -0
- package/src/cli/main.ts +68 -19
- package/src/cli/parse-flags.ts +36 -11
- package/src/cli/usage.ts +12 -3
- package/src/framework.ts +1 -7
- package/src/hooks/session-banner.ts +64 -11
- package/src/hooks/session-guidance.ts +40 -0
- package/src/hooks/validate-artifact-path.ts +108 -35
- package/src/lib/fs-utils.ts +9 -0
- package/src/lib/install.ts +33 -0
- package/src/lib/orchestrator/adapters/base.ts +14 -125
- package/src/lib/orchestrator/adapters/claude.ts +0 -11
- package/src/lib/orchestrator/adapters/opencode.ts +2 -32
- package/src/lib/orchestrator/companion-git-sync.ts +94 -84
- package/src/lib/orchestrator/config-store.ts +2 -21
- package/src/lib/orchestrator/editor.ts +12 -22
- package/src/lib/orchestrator/framework-context.ts +17 -6
- package/src/lib/orchestrator/global-config-store.ts +1 -1
- package/src/lib/orchestrator/launcher.ts +97 -7
- package/src/lib/orchestrator/opencode-guidance.ts +4 -56
- package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
- package/src/lib/orchestrator/projection-companion-link.ts +62 -0
- package/src/lib/orchestrator/projection-entries.ts +377 -0
- package/src/lib/orchestrator/projection-record.ts +56 -0
- package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
- package/src/lib/orchestrator/projection-types.ts +169 -0
- package/src/lib/orchestrator/repo-local-registry.ts +37 -133
- package/src/lib/orchestrator/repo-local-store.ts +96 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
- package/src/lib/orchestrator/types.ts +1 -0
- package/src/lib/orchestrator/working-repo-projection.ts +366 -0
- package/src/lib/orchestrator/workspace-inventory.ts +1 -1
- package/src/lib/package-paths.ts +11 -1
- package/src/lib/public-npm.ts +2 -1
- package/src/lib/update-checker.ts +15 -9
- package/src/opencode/companion-hooks.ts +89 -245
- package/src/opencode/companion-policy.ts +35 -10
- package/src/opencode/index.ts +1 -0
- package/src/opencode/projected-guidance.ts +56 -0
- package/src/opencode/tui.tsx +13 -4
- package/src/playbooks/companion-guidance.ts +32 -116
- package/src/plugins.ts +0 -1
- package/src/runtime/companion-git-state.ts +156 -0
- package/src/runtime/companion-git.ts +203 -0
- package/src/runtime/companion-guidance.ts +222 -0
- package/src/runtime/companion-sync.ts +298 -0
- package/src/runtime/env-names.ts +30 -0
- package/src/runtime/env.ts +67 -35
- package/src/runtime/framework.ts +10 -0
- package/src/runtime/freshness.ts +58 -0
- package/src/runtime/index.ts +104 -0
- package/src/runtime/install.ts +30 -0
- package/src/runtime/policy.ts +66 -0
- package/src/runtime/projected-guidance.ts +45 -0
- package/src/runtime/projection.ts +224 -0
- package/src/runtime/repo-local.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
- package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
- package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/report-assets/README.md +32 -0
- package/src/templates/report-assets/mermaid.LICENSE +21 -0
- package/src/templates/report-assets/mermaid.min.js +4376 -0
- package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
- package/src/tools/setup/capabilities/graphify.ts +16 -7
- package/src/tools/setup/capabilities/openspec.ts +63 -56
- package/src/tools/setup/capabilities/tokensave.ts +116 -2
- package/src/tools/setup/engine.ts +34 -6
- package/src/tools/setup/mate.ts +42 -13
- package/src/tools/setup/plugin.ts +9 -0
- package/src/tools/setup/plugins/guidance.ts +11 -1
- package/src/tools/setup/providers/claude-format.ts +49 -4
- package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
- package/src/tools/setup/providers/claude.ts +55 -220
- package/src/tools/setup/providers/opencode.ts +41 -14
- package/src/tools/setup/runtime-documents.ts +174 -0
- package/src/tools/setup/surface-target.ts +50 -0
- package/src/tools/setup/working-repo-cleanup.ts +33 -26
- package/src/tools/setup/working-repo-local-state.ts +21 -1
- package/src/tools/setup.ts +25 -3
- package/wrappers/bin/graphify +57 -8
- package/wrappers/bin/openspec +50 -3
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
- package/src/cli/commands/cap/headroom.ts +0 -52
- package/src/cli/commands/workspace/list.ts +0 -25
- package/src/cli/commands/workspace/materialize.ts +0 -46
- package/src/cli/commands/workspace/workspace.ts +0 -22
- package/src/hooks/artifact-finish-nudge.ts +0 -244
- package/src/lib/orchestrator/headroom/proxy.ts +0 -116
- package/src/lib/orchestrator/workspace-materialize.ts +0 -80
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
- package/src/tools/setup/capabilities/headroom.ts +0 -57
- /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { TOKENSAVE_CLAUDE_MD_MARKER } from "../../tools/setup/capabilities/tokensave-shared";
|
|
5
|
+
import { cutFromMarker } from "../../tools/setup/providers/agent-file-sections";
|
|
6
|
+
import {
|
|
7
|
+
filterClaudeHookGroups,
|
|
8
|
+
isManagedHookGroup,
|
|
9
|
+
removeManagedHookGroups,
|
|
10
|
+
serializeClaudeSettings,
|
|
11
|
+
type ClaudeSettings,
|
|
12
|
+
} from "../../tools/setup/providers/claude-format";
|
|
13
|
+
import { isMateGuardHookGroup } from "../../tools/setup/providers/claude-plugin-hooks";
|
|
14
|
+
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
15
|
+
import { GlobalConfigStore } from "./global-config-store";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The working-repo half of the Claude Runtime Surface, as one Managed Projection
|
|
19
|
+
* entry. Depends on the Claude settings *format* only: it never reaches back
|
|
20
|
+
* into the provider, so a second Agent Runtime writing the same destination adds
|
|
21
|
+
* a sibling module rather than a branch here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const CLAUDE_WORKING_SETTINGS_PATH = path.join(".claude", "settings.local.json");
|
|
25
|
+
export const LEGACY_TOKENSAVE_CLAUDE_MD_PATH = "CLAUDE.md";
|
|
26
|
+
|
|
27
|
+
function settingsPath(repoPath: string): string {
|
|
28
|
+
return path.join(path.resolve(repoPath), CLAUDE_WORKING_SETTINGS_PATH);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function claudeMdPath(repoPath: string): string {
|
|
32
|
+
return path.join(path.resolve(repoPath), LEGACY_TOKENSAVE_CLAUDE_MD_PATH);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readRaw(filePath: string): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
return await fs.readFile(filePath, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseSettings(raw: string | null): ClaudeSettings {
|
|
44
|
+
if (raw === null) return {};
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
47
|
+
if (parsed && typeof parsed === "object") return parsed as ClaudeSettings;
|
|
48
|
+
} catch {
|
|
49
|
+
/* unparseable — start from an empty object */
|
|
50
|
+
}
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strips the managed hook groups earlier releases wrote here, but leaves the
|
|
56
|
+
* guard group the `wrap` scope places: that one is managed by another entry,
|
|
57
|
+
* and stripping it would disarm every Unmanaged Session on the next launch.
|
|
58
|
+
*/
|
|
59
|
+
function withoutForeignManagedHookGroups(settings: ClaudeSettings): ClaudeSettings {
|
|
60
|
+
const hooks = filterClaudeHookGroups(
|
|
61
|
+
settings.hooks ?? {},
|
|
62
|
+
(group) => isMateGuardHookGroup(group) || !isManagedHookGroup(group),
|
|
63
|
+
);
|
|
64
|
+
const next = { ...settings };
|
|
65
|
+
if (Object.keys(hooks).length > 0) next.hooks = hooks;
|
|
66
|
+
else delete next.hooks;
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reconciles `permissions.additionalDirectories` so the active companion is
|
|
72
|
+
* reachable and no other registered companion lingers, and strips hook groups
|
|
73
|
+
* earlier releases wrote here. Everything else in the document is preserved
|
|
74
|
+
* byte-for-byte: an unchanged document is not rewritten, so `current` really
|
|
75
|
+
* means untouched.
|
|
76
|
+
*/
|
|
77
|
+
export async function syncWorkingRepoClaudeAdditionalDirectories(
|
|
78
|
+
repoPath: string,
|
|
79
|
+
companionPath: string,
|
|
80
|
+
globalConfigStore: GlobalConfigStore = new GlobalConfigStore(),
|
|
81
|
+
): Promise<"written" | "current"> {
|
|
82
|
+
const target = settingsPath(repoPath);
|
|
83
|
+
const raw = await readRaw(target);
|
|
84
|
+
const existing = withoutForeignManagedHookGroups(parseSettings(raw));
|
|
85
|
+
const permissions = { ...existing.permissions };
|
|
86
|
+
const existingAdditionalDirectories = Array.isArray(permissions.additionalDirectories)
|
|
87
|
+
? permissions.additionalDirectories
|
|
88
|
+
: [];
|
|
89
|
+
const registeredCompanionPaths = new Set(
|
|
90
|
+
(await globalConfigStore.list()).map((registeredCompanionPath) =>
|
|
91
|
+
path.resolve(registeredCompanionPath),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
const resolvedCompanionPath = path.resolve(companionPath);
|
|
95
|
+
const additionalDirectories = Array.from(
|
|
96
|
+
new Set(
|
|
97
|
+
existingAdditionalDirectories
|
|
98
|
+
.map((directory) => path.resolve(directory))
|
|
99
|
+
.filter(
|
|
100
|
+
(directory) =>
|
|
101
|
+
!registeredCompanionPaths.has(directory) || directory === resolvedCompanionPath,
|
|
102
|
+
)
|
|
103
|
+
.concat(resolvedCompanionPath),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const next = serializeClaudeSettings({
|
|
108
|
+
...existing,
|
|
109
|
+
permissions: {
|
|
110
|
+
...permissions,
|
|
111
|
+
additionalDirectories,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (next === raw) return "current";
|
|
115
|
+
|
|
116
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
117
|
+
await fs.writeFile(target, next, "utf8");
|
|
118
|
+
return "written";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Strips Mate's entries and leaves the document Mate found. A document carrying
|
|
123
|
+
* none of them is not rewritten at all, so its formatting survives; a document
|
|
124
|
+
* left empty is deleted along with any directory Mate created to hold it.
|
|
125
|
+
*/
|
|
126
|
+
export async function removeWorkingRepoClaudeSettings(
|
|
127
|
+
repoPath: string,
|
|
128
|
+
registeredCompanionPaths: string[] = [],
|
|
129
|
+
): Promise<"removed" | "absent"> {
|
|
130
|
+
const target = settingsPath(repoPath);
|
|
131
|
+
const raw = await readRaw(target);
|
|
132
|
+
if (raw === null) return "absent";
|
|
133
|
+
|
|
134
|
+
const existing = parseSettings(raw);
|
|
135
|
+
const settings = removeManagedHookGroups(existing);
|
|
136
|
+
const permissions = { ...settings.permissions };
|
|
137
|
+
const managedPaths = new Set(
|
|
138
|
+
registeredCompanionPaths.map((candidate) => path.resolve(candidate)),
|
|
139
|
+
);
|
|
140
|
+
const currentDirectories = Array.isArray(permissions.additionalDirectories)
|
|
141
|
+
? permissions.additionalDirectories
|
|
142
|
+
: [];
|
|
143
|
+
const additionalDirectories = currentDirectories.filter(
|
|
144
|
+
(candidate) => !managedPaths.has(path.resolve(candidate)),
|
|
145
|
+
);
|
|
146
|
+
if (additionalDirectories.length > 0) {
|
|
147
|
+
permissions.additionalDirectories = additionalDirectories;
|
|
148
|
+
} else {
|
|
149
|
+
delete permissions.additionalDirectories;
|
|
150
|
+
}
|
|
151
|
+
if (Object.keys(permissions).length > 0) {
|
|
152
|
+
settings.permissions = permissions;
|
|
153
|
+
} else {
|
|
154
|
+
delete settings.permissions;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (JSON.stringify(settings) === JSON.stringify(existing)) return "absent";
|
|
158
|
+
if (Object.keys(settings).length === 0) {
|
|
159
|
+
await fs.unlink(target);
|
|
160
|
+
await pruneEmptyAncestors(path.dirname(target), path.resolve(repoPath));
|
|
161
|
+
return "removed";
|
|
162
|
+
}
|
|
163
|
+
await fs.writeFile(target, serializeClaudeSettings(settings), "utf8");
|
|
164
|
+
return "removed";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Present when Mate's own key is in the document, not merely when it exists. */
|
|
168
|
+
export async function workingRepoClaudeSettingsPresent(repoPath: string): Promise<boolean> {
|
|
169
|
+
const settings = parseSettings(await readRaw(settingsPath(repoPath)));
|
|
170
|
+
return Array.isArray(settings.permissions?.additionalDirectories);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The TokenSave installer appends a block to a Working Repository's `CLAUDE.md`.
|
|
175
|
+
* Mate never writes it, which is why the entry is removal-only: a launch strips
|
|
176
|
+
* it the same way `unproject` does.
|
|
177
|
+
*/
|
|
178
|
+
export async function stripTokensaveClaudeMdAppend(
|
|
179
|
+
repoPath: string,
|
|
180
|
+
): Promise<"removed" | "absent"> {
|
|
181
|
+
const target = claudeMdPath(repoPath);
|
|
182
|
+
const content = await readRaw(target);
|
|
183
|
+
if (content === null) return "absent";
|
|
184
|
+
|
|
185
|
+
const stripped = cutFromMarker(content, TOKENSAVE_CLAUDE_MD_MARKER);
|
|
186
|
+
if (stripped === content) return "absent";
|
|
187
|
+
if (stripped.length > 0) {
|
|
188
|
+
await fs.writeFile(target, stripped, "utf8");
|
|
189
|
+
} else {
|
|
190
|
+
await fs.unlink(target);
|
|
191
|
+
}
|
|
192
|
+
return "removed";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function legacyTokensaveClaudeMdPresent(repoPath: string): Promise<boolean> {
|
|
196
|
+
const content = await readRaw(claudeMdPath(repoPath));
|
|
197
|
+
return content !== null && content.includes(TOKENSAVE_CLAUDE_MD_MARKER);
|
|
198
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readProjectionFile } from "../../runtime/projection";
|
|
5
|
+
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
6
|
+
|
|
7
|
+
const CLAUDE_SKILLS_PATH = path.join(".claude", "skills");
|
|
8
|
+
|
|
9
|
+
async function skillNames(root: string): Promise<string[]> {
|
|
10
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
11
|
+
const names: string[] = [];
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
14
|
+
const skillPath = path.join(root, entry.name, "SKILL.md");
|
|
15
|
+
if (
|
|
16
|
+
await fs
|
|
17
|
+
.stat(skillPath)
|
|
18
|
+
.then(() => true)
|
|
19
|
+
.catch(() => false)
|
|
20
|
+
)
|
|
21
|
+
names.push(entry.name);
|
|
22
|
+
}
|
|
23
|
+
return names;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function linkTarget(link: string): Promise<string | null> {
|
|
27
|
+
const target = await fs.readlink(link).catch(() => null);
|
|
28
|
+
return target === null ? null : path.resolve(path.dirname(link), target);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function isManagedSkillLink(link: string, roots: string[]): Promise<boolean> {
|
|
32
|
+
const target = await linkTarget(link);
|
|
33
|
+
if (target === null) return false;
|
|
34
|
+
return roots.some((root) => path.dirname(target) === root);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function registeredSkillRoots(
|
|
38
|
+
repoPath: string,
|
|
39
|
+
companionPath: string | null,
|
|
40
|
+
registeredCompanionPaths: string[],
|
|
41
|
+
): Promise<string[]> {
|
|
42
|
+
const projection = readProjectionFile(repoPath);
|
|
43
|
+
const candidates = [
|
|
44
|
+
...registeredCompanionPaths,
|
|
45
|
+
companionPath ?? "",
|
|
46
|
+
projection?.projection.companionPath ?? "",
|
|
47
|
+
];
|
|
48
|
+
return [
|
|
49
|
+
...new Set(
|
|
50
|
+
candidates.filter(Boolean).map((candidate) => path.resolve(candidate, CLAUDE_SKILLS_PATH)),
|
|
51
|
+
),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function createSkillLink(target: string, link: string): Promise<void> {
|
|
56
|
+
await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function reconcileWorkingRepoClaudeSkillLinks(
|
|
60
|
+
repoPath: string,
|
|
61
|
+
companionPath: string | null,
|
|
62
|
+
enabled: boolean,
|
|
63
|
+
registeredCompanionPaths: string[] = [],
|
|
64
|
+
): Promise<"written" | "current"> {
|
|
65
|
+
const currentRoot = companionPath ? path.resolve(companionPath, CLAUDE_SKILLS_PATH) : null;
|
|
66
|
+
const roots = await registeredSkillRoots(repoPath, companionPath, registeredCompanionPaths);
|
|
67
|
+
const targetRoot = path.join(path.resolve(repoPath), CLAUDE_SKILLS_PATH);
|
|
68
|
+
const existing = await fs.readdir(targetRoot, { withFileTypes: true }).catch(() => []);
|
|
69
|
+
const sourceNames: Set<string> =
|
|
70
|
+
currentRoot && enabled ? new Set(await skillNames(currentRoot)) : new Set<string>();
|
|
71
|
+
let changed = false;
|
|
72
|
+
|
|
73
|
+
for (const entry of existing) {
|
|
74
|
+
if (!entry.isSymbolicLink()) continue;
|
|
75
|
+
const link = path.join(targetRoot, entry.name);
|
|
76
|
+
if (!(await isManagedSkillLink(link, roots))) continue;
|
|
77
|
+
|
|
78
|
+
const target = await linkTarget(link);
|
|
79
|
+
if (
|
|
80
|
+
currentRoot &&
|
|
81
|
+
enabled &&
|
|
82
|
+
target === path.join(currentRoot, entry.name) &&
|
|
83
|
+
sourceNames.has(entry.name)
|
|
84
|
+
) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await fs.unlink(link);
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (currentRoot && enabled && sourceNames.size > 0) {
|
|
93
|
+
await fs.mkdir(targetRoot, { recursive: true });
|
|
94
|
+
for (const name of sourceNames) {
|
|
95
|
+
const link = path.join(targetRoot, name);
|
|
96
|
+
const existingLink = await fs.lstat(link).catch(() => null);
|
|
97
|
+
if (existingLink) continue;
|
|
98
|
+
await createSkillLink(path.join(currentRoot, name), link);
|
|
99
|
+
changed = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (changed || !enabled) {
|
|
104
|
+
await pruneEmptyAncestors(targetRoot, path.resolve(repoPath));
|
|
105
|
+
}
|
|
106
|
+
return changed ? "written" : "current";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function removeWorkingRepoClaudeSkillLinks(
|
|
110
|
+
repoPath: string,
|
|
111
|
+
registeredCompanionPaths: string[] = [],
|
|
112
|
+
): Promise<"removed" | "absent"> {
|
|
113
|
+
const result = await reconcileWorkingRepoClaudeSkillLinks(
|
|
114
|
+
repoPath,
|
|
115
|
+
null,
|
|
116
|
+
false,
|
|
117
|
+
registeredCompanionPaths,
|
|
118
|
+
);
|
|
119
|
+
return result === "written" ? "removed" : "absent";
|
|
120
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { companionLinkPath } from "../../runtime/repo-local";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one filesystem fact behind "separated but visible": a link at the
|
|
8
|
+
* Projection Root that makes the Companion Repository reachable by ordinary
|
|
9
|
+
* path traversal, for every tool, editor and GUI at once. It replaces no
|
|
10
|
+
* per-runtime directory allow-list — those stay as the fallback for a platform
|
|
11
|
+
* that permits no link at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A Windows symbolic link needs Developer Mode or elevation; a directory
|
|
16
|
+
* junction needs neither, and Node creates one through the same call.
|
|
17
|
+
*/
|
|
18
|
+
async function createLink(target: string, link: string): Promise<void> {
|
|
19
|
+
await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Absolute, so the link survives the Working Repository being moved into place. */
|
|
23
|
+
async function currentTarget(link: string): Promise<string | null> {
|
|
24
|
+
const target = await fs.readlink(link).catch(() => null);
|
|
25
|
+
return target === null ? null : path.resolve(path.dirname(link), target);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function linkCompanion(
|
|
29
|
+
repoPath: string,
|
|
30
|
+
companionPath: string,
|
|
31
|
+
): Promise<"written" | "current"> {
|
|
32
|
+
const link = companionLinkPath(repoPath);
|
|
33
|
+
const target = path.resolve(companionPath);
|
|
34
|
+
|
|
35
|
+
const existing = await currentTarget(link);
|
|
36
|
+
if (existing === target) return "current";
|
|
37
|
+
|
|
38
|
+
await fs.mkdir(path.dirname(link), { recursive: true });
|
|
39
|
+
if (existing !== null) await fs.unlink(link);
|
|
40
|
+
await createLink(target, link);
|
|
41
|
+
return "written";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `unlink` acts on the link itself. Nothing here may recurse: a recursive
|
|
46
|
+
* removal at this path would walk into the Companion Repository and delete the
|
|
47
|
+
* Artifacts the link exists to expose.
|
|
48
|
+
*/
|
|
49
|
+
export async function unlinkCompanion(repoPath: string): Promise<"removed" | "absent"> {
|
|
50
|
+
const link = companionLinkPath(repoPath);
|
|
51
|
+
if (!(await fs.lstat(link).catch(() => null))) return "absent";
|
|
52
|
+
await fs.unlink(link);
|
|
53
|
+
return "removed";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `lstat`, not `access`: a link whose target is gone is still Mate's to remove. */
|
|
57
|
+
export async function companionLinkPresent(repoPath: string): Promise<boolean> {
|
|
58
|
+
return fs
|
|
59
|
+
.lstat(companionLinkPath(repoPath))
|
|
60
|
+
.then(() => true)
|
|
61
|
+
.catch(() => false);
|
|
62
|
+
}
|