@uniqbit/mate-core 0.18.3-canary.0 → 0.18.3
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/hooks.json +10 -1
- package/claude-plugin/hooks/session-banner.mjs +4 -3
- package/claude-plugin/hooks/session-prepare.mjs +9 -0
- package/opencode-plugin/src/companion.ts +47 -12
- package/opencode-plugin/src/server.ts +39 -4
- package/package.json +3 -3
- package/src/cli/commands/artifact/finish/openspec.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +60 -21
- package/src/cli/commands/companion/link.ts +16 -11
- package/src/cli/commands/companion/list.ts +4 -5
- package/src/cli/commands/companion/open.ts +4 -5
- package/src/cli/commands/companion/tui.ts +1 -1
- package/src/cli/commands/doctor.ts +1 -1
- package/src/cli/commands/report/renderer.ts +9 -19
- package/src/cli/commands/session/prepare.ts +119 -0
- package/src/cli/commands/shared/companion-selection.ts +2 -2
- package/src/cli/commands/studio/reports.ts +0 -1
- package/src/cli/commands/studio/server.ts +6 -2
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +53 -59
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +3 -1
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +43 -8
- package/src/lib/orchestrator/adapters/claude.ts +130 -29
- package/src/lib/orchestrator/adapters/opencode.ts +17 -19
- package/src/lib/orchestrator/companion-git-sync.ts +28 -1
- package/src/lib/orchestrator/companion-hub.ts +2 -1
- package/src/lib/orchestrator/config-store.ts +56 -2
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
- package/src/lib/orchestrator/projection-entries.ts +78 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-record.ts +7 -1
- package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
- package/src/lib/orchestrator/projection-types.ts +38 -33
- package/src/lib/orchestrator/repository-link.ts +473 -0
- package/src/lib/orchestrator/root-context.ts +6 -2
- package/src/lib/orchestrator/session-prepare.ts +425 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/types.ts +4 -1
- package/src/lib/orchestrator/working-repo-projection.ts +71 -159
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +131 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +24 -132
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/session-notes.ts +76 -0
- package/src/opencode/session-prepare.ts +139 -0
- package/src/opencode/tui.tsx +56 -9
- package/src/runtime/claude-delivery-plan.ts +73 -0
- package/src/runtime/claude-hook-group.ts +21 -0
- package/src/runtime/companion-lock.ts +121 -0
- package/src/runtime/companion-sync.ts +66 -9
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +335 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +90 -0
- package/src/runtime/write-atomic.ts +27 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
- package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
- package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
- package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
- package/src/tools/setup/engine.ts +15 -4
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +11 -18
- package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
- package/src/tools/setup/providers/claude.ts +23 -6
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/working-repo-local-state.ts +82 -13
- package/src/tools/setup.ts +2 -2
- package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
- package/src/lib/orchestrator/companion-resolver.ts +0 -179
- package/src/lib/orchestrator/companion-store.ts +0 -94
- package/src/lib/orchestrator/repo-local-registry.ts +0 -143
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
- package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
- package/src/templates/report-assets/README.md +0 -32
- package/src/templates/report-assets/mermaid.LICENSE +0 -21
- package/src/templates/report-assets/mermaid.min.js +0 -4376
|
@@ -1,20 +1,23 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { runtimeOpenCodePluginRoot } from "../../runtime/runtime-directory";
|
|
1
4
|
import {
|
|
2
5
|
CLAUDE_LOCAL_CONFIG_DOCUMENT,
|
|
3
6
|
CLAUDE_SETTINGS_DOCUMENT,
|
|
4
7
|
OPENCODE_CONFIG_DOCUMENT,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "../../lib/orchestrator/projection-types";
|
|
10
|
-
import { getOpenCodePluginRoot } from "../../lib/package-paths";
|
|
8
|
+
OPENCODE_MCP_SERVERS_AT,
|
|
9
|
+
type ManagedRegion,
|
|
10
|
+
type RenderedRuntimeDocument,
|
|
11
|
+
} from "../../runtime/runtime-document-manifest";
|
|
11
12
|
import type { CapabilityContributionInput, SetupContext } from "./plugin";
|
|
12
13
|
import {
|
|
13
14
|
mateBannerHookGroups,
|
|
14
15
|
mateGuardHookGroups,
|
|
15
16
|
mateGuidanceHookGroups,
|
|
17
|
+
mateSessionPrepareHookGroups,
|
|
16
18
|
} from "./providers/claude-plugin-hooks";
|
|
17
19
|
import {
|
|
20
|
+
renderManagedClaudeHookGroups,
|
|
18
21
|
renderManagedClaudeMcpServers,
|
|
19
22
|
renderManagedClaudePermissionEntries,
|
|
20
23
|
} from "./providers/claude";
|
|
@@ -22,6 +25,7 @@ import {
|
|
|
22
25
|
renderCompanionExternalDirectoryPermissions,
|
|
23
26
|
renderManagedOpenCodeMcpServers,
|
|
24
27
|
} from "./providers/opencode";
|
|
28
|
+
import { isRecord, readOpenCodeConfig } from "./providers/opencode-format";
|
|
25
29
|
import { projectionRepoRoot } from "./surface-target";
|
|
26
30
|
|
|
27
31
|
/**
|
|
@@ -47,40 +51,39 @@ function document(documentPath: string, regions: ManagedRegion[]): RenderedRunti
|
|
|
47
51
|
/**
|
|
48
52
|
* The Mate plugin root is what makes an Unmanaged OpenCode session load the
|
|
49
53
|
* companion guidance at all: OpenCode has no config channel for a generated
|
|
50
|
-
* system prompt, so the plugin is the only injection point
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* A managed launch lists the same root in its `OPENCODE_CONFIG_CONTENT`
|
|
55
|
-
* overlay; OpenCode dedupes identical plugin paths across config sources.
|
|
54
|
+
* system prompt, so the plugin is the only injection point. It is named through
|
|
55
|
+
* the runtime directory, so a reinstall does not strand a wrapped repository.
|
|
56
|
+
* The managed launch carries no overlay of its own, so this document is the one
|
|
57
|
+
* place every OpenCode session in the repository loads it from.
|
|
56
58
|
*/
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* tool input, the guidance hook because it yields nothing under a launch
|
|
65
|
-
* environment, and the banner because the copy carried here is flagged
|
|
66
|
-
* `--projected` and defers to the one the launch's own plugin prints.
|
|
67
|
-
*
|
|
68
|
-
* `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` is what a managed launch sets
|
|
69
|
-
* beside `--add-dir`. Without it the companion directories this document
|
|
70
|
-
* already permits are reachable but their `CLAUDE.md` is not loaded, so the
|
|
71
|
-
* permission alone delivers no guidance.
|
|
61
|
+
* Loaded by a managed session beside the launch's plugin. Mate hooks here run
|
|
62
|
+
* twice there, inertly: guard is input-pure, guidance/prepare yield under a
|
|
63
|
+
* launch env, banner is `--projected`. Capability groups run once: the launch
|
|
64
|
+
* omits them from `--settings`. A launch passes no `--add-dir`; it relies on
|
|
65
|
+
* `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` for companion `CLAUDE.md`.
|
|
72
66
|
*/
|
|
73
67
|
function claudeSettingsRegions(
|
|
74
68
|
ctx: SetupContext,
|
|
75
69
|
inputs: CapabilityContributionInput[],
|
|
76
70
|
): ManagedRegion[] {
|
|
71
|
+
const capabilityHooks = renderManagedClaudeHookGroups(inputs);
|
|
72
|
+
const mateHooks: Record<string, unknown[]> = {
|
|
73
|
+
PreToolUse: mateGuardHookGroups(),
|
|
74
|
+
SessionStart: [
|
|
75
|
+
...mateBannerHookGroups(),
|
|
76
|
+
...mateGuidanceHookGroups(),
|
|
77
|
+
...mateSessionPrepareHookGroups(),
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
const events = [...new Set([...Object.keys(mateHooks), ...Object.keys(capabilityHooks)])];
|
|
77
81
|
return [
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
at: ["hooks", "SessionStart"],
|
|
82
|
+
...events.map((event): ManagedRegion => ({
|
|
83
|
+
at: ["hooks", event],
|
|
81
84
|
kind: "list",
|
|
82
|
-
values: [...
|
|
83
|
-
},
|
|
85
|
+
values: [...(mateHooks[event] ?? []), ...(capabilityHooks[event] ?? [])],
|
|
86
|
+
})),
|
|
84
87
|
/**
|
|
85
88
|
* `stripPrefix` heals a repository an older Mate wrapped. That version wrote
|
|
86
89
|
* the whole launch environment here, and Claude Code applies `env` to the
|
|
@@ -152,10 +155,10 @@ export function renderDocumentsForTarget(
|
|
|
152
155
|
{
|
|
153
156
|
at: ["plugins"],
|
|
154
157
|
kind: "list",
|
|
155
|
-
values: [
|
|
158
|
+
values: [runtimeOpenCodePluginRoot()],
|
|
156
159
|
},
|
|
157
160
|
{
|
|
158
|
-
at: [
|
|
161
|
+
at: [...OPENCODE_MCP_SERVERS_AT],
|
|
159
162
|
kind: "map",
|
|
160
163
|
entries: renderManagedOpenCodeMcpServers(inputs),
|
|
161
164
|
},
|
|
@@ -164,8 +167,125 @@ export function renderDocumentsForTarget(
|
|
|
164
167
|
kind: "list",
|
|
165
168
|
values: renderCompanionExternalDirectoryPermissions(ctx.companionPath),
|
|
166
169
|
},
|
|
170
|
+
{
|
|
171
|
+
at: ["skills"],
|
|
172
|
+
kind: "list",
|
|
173
|
+
values: [path.join(ctx.companionPath, ".agents", "skills")],
|
|
174
|
+
},
|
|
175
|
+
{ at: ["references"], kind: "map", entries: { mate: ctx.companionPath } },
|
|
167
176
|
]),
|
|
168
177
|
);
|
|
169
178
|
}
|
|
170
179
|
return documents;
|
|
171
180
|
}
|
|
181
|
+
|
|
182
|
+
/** Keys Mate renders its own regions for; the companion's values join those regions. */
|
|
183
|
+
const MATE_OPENCODE_KEYS = new Set(["plugins", "permissions", "skills", "mcp", "references"]);
|
|
184
|
+
|
|
185
|
+
function relativePathValues(value: unknown, at: string[]): string[] {
|
|
186
|
+
if (typeof value === "string") {
|
|
187
|
+
return value.startsWith("./") || value.startsWith("../") ? [at.join(".")] : [];
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
return value.flatMap((entry, index) => relativePathValues(entry, [...at, String(index)]));
|
|
191
|
+
}
|
|
192
|
+
if (isRecord(value)) {
|
|
193
|
+
return Object.entries(value).flatMap(([key, entry]) => relativePathValues(entry, [...at, key]));
|
|
194
|
+
}
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Replaces the region at `at` with what `merge` builds from it, or appends one. */
|
|
199
|
+
function upsertRegion(
|
|
200
|
+
regions: ManagedRegion[],
|
|
201
|
+
at: string[],
|
|
202
|
+
merge: (found: ManagedRegion | undefined) => ManagedRegion,
|
|
203
|
+
): void {
|
|
204
|
+
const key = JSON.stringify(at);
|
|
205
|
+
const index = regions.findIndex((region) => JSON.stringify(region.at) === key);
|
|
206
|
+
if (index === -1) regions.push(merge(undefined));
|
|
207
|
+
else regions[index] = merge(regions[index]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Mate's own values stay; companion values it does not already list go first. */
|
|
211
|
+
function mergeListRegion(regions: ManagedRegion[], at: string[], extra: unknown[]): void {
|
|
212
|
+
upsertRegion(regions, at, (found) => {
|
|
213
|
+
const own = found?.kind === "list" ? found.values : [];
|
|
214
|
+
const seen = new Set(own.map((value) => JSON.stringify(value)));
|
|
215
|
+
const values = [...extra.filter((value) => !seen.has(JSON.stringify(value))), ...own];
|
|
216
|
+
return { at, kind: "list", values };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Mate's own entries win over companion entries of the same name. */
|
|
221
|
+
function mergeMapRegion(
|
|
222
|
+
regions: ManagedRegion[],
|
|
223
|
+
at: string[],
|
|
224
|
+
extra: Record<string, unknown>,
|
|
225
|
+
): void {
|
|
226
|
+
upsertRegion(regions, at, (found) => {
|
|
227
|
+
const own = found?.kind === "map" ? found.entries : {};
|
|
228
|
+
return { at, kind: "map", entries: { ...extra, ...own } };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Relative reference paths resolved against the companion's `.opencode/`. */
|
|
233
|
+
function resolveReferences(
|
|
234
|
+
references: Record<string, unknown>,
|
|
235
|
+
companionDir: string,
|
|
236
|
+
): Record<string, unknown> {
|
|
237
|
+
return Object.fromEntries(
|
|
238
|
+
Object.entries(references).map(([name, reference]) => [
|
|
239
|
+
name,
|
|
240
|
+
typeof reference === "string" && !path.isAbsolute(reference)
|
|
241
|
+
? path.resolve(companionDir, reference)
|
|
242
|
+
: reference,
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Folds the companion's `.opencode/opencode.json` into the wrapped document.
|
|
249
|
+
* Mate's regions beat companion values; other top-level keys yield to the
|
|
250
|
+
* repository's. `references` resolve against the companion's `.opencode/`;
|
|
251
|
+
* other relative paths are reported, not rewritten.
|
|
252
|
+
*/
|
|
253
|
+
export async function mergeCompanionOpenCodeConfig(
|
|
254
|
+
companionPath: string,
|
|
255
|
+
documents: RenderedRuntimeDocument[],
|
|
256
|
+
): Promise<{ documents: RenderedRuntimeDocument[]; warnings: string[] }> {
|
|
257
|
+
const index = documents.findIndex((document) => document.path === OPENCODE_CONFIG_DOCUMENT);
|
|
258
|
+
const companionConfigPath = path.join(companionPath, ".opencode", "opencode.json");
|
|
259
|
+
const companion = index === -1 ? null : await readOpenCodeConfig(companionConfigPath);
|
|
260
|
+
if (!companion?.present) return { documents, warnings: [] };
|
|
261
|
+
|
|
262
|
+
const regions = [...documents[index]!.regions] as ManagedRegion[];
|
|
263
|
+
const warnings: string[] = [];
|
|
264
|
+
|
|
265
|
+
const companionDir = path.dirname(companionConfigPath);
|
|
266
|
+
for (const [key, value] of Object.entries(companion.config)) {
|
|
267
|
+
if (key === "$schema") continue;
|
|
268
|
+
if (key === "references" && isRecord(value)) {
|
|
269
|
+
mergeMapRegion(regions, ["references"], resolveReferences(value, companionDir));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
for (const at of relativePathValues(value, [key])) {
|
|
273
|
+
warnings.push(
|
|
274
|
+
`${companionConfigPath}: ${at} is a relative path, which resolves against the working repository once merged into ${OPENCODE_CONFIG_DOCUMENT}; use an absolute path.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (MATE_OPENCODE_KEYS.has(key)) {
|
|
278
|
+
if (key === "mcp" && isRecord(value) && isRecord(value.servers)) {
|
|
279
|
+
mergeMapRegion(regions, [...OPENCODE_MCP_SERVERS_AT], value.servers);
|
|
280
|
+
} else if (Array.isArray(value)) {
|
|
281
|
+
mergeListRegion(regions, [key], value);
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
regions.push({ at: [key], kind: "value", value, yields: true });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const next = [...documents];
|
|
289
|
+
next[index] = { path: OPENCODE_CONFIG_DOCUMENT, regions };
|
|
290
|
+
return { documents: next, warnings };
|
|
291
|
+
}
|
|
@@ -5,8 +5,17 @@ import { resolveGitInfoExcludePath } from "./git-utils";
|
|
|
5
5
|
|
|
6
6
|
const MANAGED_BLOCK_START = "# mate managed: start";
|
|
7
7
|
const MANAGED_BLOCK_END = "# mate managed: end";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Documents no directory entry covers. `.opencode/` is per file: the repository's
|
|
10
|
+
* own files there stay visible; copies get the copies block.
|
|
11
|
+
*/
|
|
12
|
+
const CORE_EXCLUDE_ENTRIES = [
|
|
13
|
+
"/.mate/",
|
|
14
|
+
"/.claude/",
|
|
15
|
+
"/.opencode/opencode.json",
|
|
16
|
+
"/.agents/",
|
|
17
|
+
"/.mcp.json",
|
|
18
|
+
];
|
|
10
19
|
const LEGACY_CORE_EXCLUDE_ENTRIES = new Set([".mate/", ".claude/settings.local.json"]);
|
|
11
20
|
|
|
12
21
|
async function readExclude(excludePath: string): Promise<string> {
|
|
@@ -60,16 +69,43 @@ async function writeExclude(
|
|
|
60
69
|
return true;
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
/** The first `startMarker`…`endMarker` pair, inclusive; `null` when either is missing. */
|
|
73
|
+
function findBlock(
|
|
74
|
+
lines: string[],
|
|
75
|
+
startMarker: string,
|
|
76
|
+
endMarker: string,
|
|
77
|
+
): { start: number; end: number } | null {
|
|
78
|
+
const start = lines.findIndex((line) => line.trim() === startMarker);
|
|
79
|
+
if (start === -1) return null;
|
|
80
|
+
const end = lines.findIndex((line, index) => index > start && line.trim() === endMarker);
|
|
81
|
+
return end === -1 ? null : { start, end };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withoutLegacy(lines: string[]): string[] {
|
|
85
|
+
return withoutManagedBlocks(lines).filter(
|
|
86
|
+
(line) => !LEGACY_CORE_EXCLUDE_ENTRIES.has(line.trim()),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
export async function ensureWorkingRepoLocalExcludes(repoPath: string): Promise<boolean> {
|
|
64
91
|
const excludePath = await resolveGitInfoExcludePath(repoPath);
|
|
65
92
|
if (!excludePath) return false;
|
|
66
93
|
const existing = await readExclude(excludePath);
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
const current = contentLines(existing);
|
|
95
|
+
const block = [MANAGED_BLOCK_START, ...CORE_EXCLUDE_ENTRIES, MANAGED_BLOCK_END];
|
|
96
|
+
/**
|
|
97
|
+
* Rewritten where it stands, not moved to the end: a block that follows it,
|
|
98
|
+
* like the companion copies', would otherwise trade places with it on every
|
|
99
|
+
* pass and turn an unchanged wrap into a write.
|
|
100
|
+
*/
|
|
101
|
+
const managed = findBlock(current, MANAGED_BLOCK_START, MANAGED_BLOCK_END);
|
|
102
|
+
const lines = managed
|
|
103
|
+
? trimTrailingEmptyLines([
|
|
104
|
+
...withoutLegacy(current.slice(0, managed.start)),
|
|
105
|
+
...block,
|
|
106
|
+
...withoutLegacy(current.slice(managed.end + 1)),
|
|
107
|
+
])
|
|
108
|
+
: [...trimTrailingEmptyLines(withoutLegacy(current)), ...block];
|
|
73
109
|
return writeExclude(excludePath, existing, lines);
|
|
74
110
|
}
|
|
75
111
|
|
|
@@ -114,10 +150,43 @@ export async function removeWorkingRepoLocalExcludes(repoPath: string): Promise<
|
|
|
114
150
|
const excludePath = await resolveGitInfoExcludePath(repoPath);
|
|
115
151
|
if (!excludePath) return false;
|
|
116
152
|
const existing = await readExclude(excludePath);
|
|
117
|
-
const lines = trimTrailingEmptyLines(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
153
|
+
const lines = trimTrailingEmptyLines(withoutLegacy(contentLines(existing)));
|
|
154
|
+
return writeExclude(excludePath, existing, lines);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const COPIES_BLOCK_START = "# mate managed copies: start";
|
|
158
|
+
const COPIES_BLOCK_END = "# mate managed copies: end";
|
|
159
|
+
|
|
160
|
+
/** A literal repo-relative path as an anchored exclude pattern. */
|
|
161
|
+
function literalExcludeEntry(relativePath: string): string {
|
|
162
|
+
const escaped = relativePath.replace(/[\\*?[\]]/g, "\\$&");
|
|
163
|
+
return `/${escaped}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function withoutCopiesBlock(lines: string[]): string[] {
|
|
167
|
+
const block = findBlock(lines, COPIES_BLOCK_START, COPIES_BLOCK_END);
|
|
168
|
+
return block ? [...lines.slice(0, block.start), ...lines.slice(block.end + 1)] : lines;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One exclude line per owned companion copy, in a block of its own so the core
|
|
173
|
+
* block's rewrite leaves it alone. Per file rather than `/.opencode/`, so a file
|
|
174
|
+
* the repository adds there itself stays visible to Git.
|
|
175
|
+
*/
|
|
176
|
+
export async function reconcileWorkingRepoCopyExcludes(
|
|
177
|
+
repoPath: string,
|
|
178
|
+
relativePaths: readonly string[],
|
|
179
|
+
): Promise<boolean> {
|
|
180
|
+
const excludePath = await resolveGitInfoExcludePath(repoPath);
|
|
181
|
+
if (!excludePath) return false;
|
|
182
|
+
const existing = await readExclude(excludePath);
|
|
183
|
+
const lines = trimTrailingEmptyLines(withoutCopiesBlock(contentLines(existing)));
|
|
184
|
+
if (relativePaths.length > 0) {
|
|
185
|
+
lines.push(
|
|
186
|
+
COPIES_BLOCK_START,
|
|
187
|
+
...relativePaths.toSorted().map(literalExcludeEntry),
|
|
188
|
+
COPIES_BLOCK_END,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
122
191
|
return writeExclude(excludePath, existing, lines);
|
|
123
192
|
}
|
package/src/tools/setup.ts
CHANGED
|
@@ -40,9 +40,9 @@ import {
|
|
|
40
40
|
buildSetupInstallationPlan,
|
|
41
41
|
executeSetupInstallationPlan,
|
|
42
42
|
renderRuntimeDocuments,
|
|
43
|
+
type RenderedRuntimeDocuments,
|
|
43
44
|
type SetupInstallationOutcome,
|
|
44
45
|
} from "./setup/engine";
|
|
45
|
-
import type { RenderedRuntimeDocument } from "../lib/orchestrator/projection-types";
|
|
46
46
|
|
|
47
47
|
export interface SetupInput {
|
|
48
48
|
allowedAgents?: string[];
|
|
@@ -81,7 +81,7 @@ export async function renderWorkingRuntimeDocuments(
|
|
|
81
81
|
config: FrameworkConfig,
|
|
82
82
|
repoPath: string,
|
|
83
83
|
plugins: PluginRegistration[] = getActiveDistribution().registry.getAll(),
|
|
84
|
-
): Promise<
|
|
84
|
+
): Promise<RenderedRuntimeDocuments> {
|
|
85
85
|
const plan = buildSetupInstallationPlan(config, plugins);
|
|
86
86
|
const ctx: SetupContext = {
|
|
87
87
|
companionPath,
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { parse } from "yaml";
|
|
5
|
-
|
|
6
|
-
import { FRAMEWORK_NAME } from "../../framework";
|
|
7
|
-
import type { LinkedRepository } from "./types";
|
|
8
|
-
|
|
9
|
-
export function companionRegistryPath(companionPath: string): string {
|
|
10
|
-
return path.join(path.resolve(companionPath), `.${FRAMEWORK_NAME}`, "config", "registry.yaml");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface CompanionRegistryReadResult {
|
|
14
|
-
repos: LinkedRepository[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Parses a companion's `registry.yaml` without the writes {@link
|
|
19
|
-
* CompanionRegistryStore.load} performs (legacy-field migration, stale-pointer
|
|
20
|
-
* cleanup). Callers that must not mutate registry state on a read (e.g.
|
|
21
|
-
* inventory, materialization) use this instead. Throws ENOENT when the file
|
|
22
|
-
* doesn't exist; throws on unparsable YAML.
|
|
23
|
-
*/
|
|
24
|
-
export async function readCompanionRegistry(
|
|
25
|
-
companionPath: string,
|
|
26
|
-
): Promise<CompanionRegistryReadResult> {
|
|
27
|
-
const raw = await fs.readFile(companionRegistryPath(companionPath), "utf8");
|
|
28
|
-
const parsed = parse(raw) as { repos?: unknown } | null;
|
|
29
|
-
const rawRepos = Array.isArray(parsed?.repos) ? parsed.repos : [];
|
|
30
|
-
const repos = rawRepos
|
|
31
|
-
.filter(
|
|
32
|
-
(entry): entry is LinkedRepository =>
|
|
33
|
-
Boolean(entry) && typeof entry.id === "string" && typeof entry.path === "string",
|
|
34
|
-
)
|
|
35
|
-
.map(({ id, path: repoPath }) => ({ id, path: repoPath }));
|
|
36
|
-
return { repos };
|
|
37
|
-
}
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { parse } from "yaml";
|
|
5
|
-
|
|
6
|
-
import { readCompanionRegistry } from "./companion-registry-reader";
|
|
7
|
-
import type { GlobalConfigStore } from "./global-config-store";
|
|
8
|
-
import {
|
|
9
|
-
findRepoLocalRegistryFile,
|
|
10
|
-
pathIsDirectory,
|
|
11
|
-
repoLocalRegistryPath,
|
|
12
|
-
type RepoLocalCompanionPointer,
|
|
13
|
-
RepoLocalRegistryStore,
|
|
14
|
-
} from "./repo-local-registry";
|
|
15
|
-
|
|
16
|
-
export interface CompanionMatch {
|
|
17
|
-
companionPath: string;
|
|
18
|
-
repositoryId: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface CompanionResolutionFailure {
|
|
22
|
-
companionPath: string;
|
|
23
|
-
message: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface CompanionResolutionResult {
|
|
27
|
-
match: CompanionMatch | null;
|
|
28
|
-
/**
|
|
29
|
-
* All companions whose registry links the same repo path at the best
|
|
30
|
-
* (longest) match length as `match`. Populated with more than one entry
|
|
31
|
-
* only when the same working repo is linked from more than one distinct
|
|
32
|
-
* companion, so callers that must pick a single companion (e.g. launch
|
|
33
|
-
* commands) can detect the ambiguity and prompt the user instead of
|
|
34
|
-
* silently using whichever companion happened to be listed first.
|
|
35
|
-
*/
|
|
36
|
-
ambiguousMatches: CompanionMatch[];
|
|
37
|
-
failures: CompanionResolutionFailure[];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class CompanionResolver {
|
|
41
|
-
constructor(private readonly globalConfigStore: GlobalConfigStore) {}
|
|
42
|
-
|
|
43
|
-
async resolve(cwd: string): Promise<CompanionMatch | null> {
|
|
44
|
-
return (await this.resolveWithDiagnostics(cwd, { logFailures: true })).match;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async resolveWithDiagnostics(
|
|
48
|
-
cwd: string,
|
|
49
|
-
options: { logFailures?: boolean } = {},
|
|
50
|
-
): Promise<CompanionResolutionResult> {
|
|
51
|
-
const resolvedCwd = path.resolve(cwd);
|
|
52
|
-
|
|
53
|
-
const repoLocalResult = await this.resolveFromRepoLocalRegistry(resolvedCwd, options);
|
|
54
|
-
if (repoLocalResult) return repoLocalResult;
|
|
55
|
-
|
|
56
|
-
return this.resolveFromGlobalRegistry(resolvedCwd);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Fallback for when the repo-local cache is missing or unusable: scans
|
|
61
|
-
* every companion the global registry knows about and matches by
|
|
62
|
-
* repository path. A single match self-heals the repo-local cache so the
|
|
63
|
-
* fast path succeeds on the next resolution without repeating this scan.
|
|
64
|
-
*/
|
|
65
|
-
private async resolveFromGlobalRegistry(resolvedCwd: string): Promise<CompanionResolutionResult> {
|
|
66
|
-
const candidates = await this.globalConfigStore.list();
|
|
67
|
-
const found = await Promise.all(
|
|
68
|
-
candidates.map(async (candidate) => {
|
|
69
|
-
const companionPath = path.resolve(candidate);
|
|
70
|
-
try {
|
|
71
|
-
const { repos } = await readCompanionRegistry(companionPath);
|
|
72
|
-
const repo = repos.find((r) => path.resolve(r.path) === resolvedCwd);
|
|
73
|
-
return repo ? { companionPath, repositoryId: repo.id } : null;
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}),
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
const seenCompanionPaths = new Set<string>();
|
|
81
|
-
const matches = found.filter((match): match is CompanionMatch => {
|
|
82
|
-
if (!match || seenCompanionPaths.has(match.companionPath)) return false;
|
|
83
|
-
seenCompanionPaths.add(match.companionPath);
|
|
84
|
-
return true;
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
if (matches.length === 0) return { match: null, ambiguousMatches: [], failures: [] };
|
|
88
|
-
|
|
89
|
-
if (matches.length === 1) {
|
|
90
|
-
const store = new RepoLocalRegistryStore(repoLocalRegistryPath(resolvedCwd));
|
|
91
|
-
await store.save({
|
|
92
|
-
companions: [{ path: matches[0]!.companionPath, repositoryId: matches[0]!.repositoryId }],
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
match: matches[0]!,
|
|
98
|
-
ambiguousMatches: matches.length > 1 ? matches : [],
|
|
99
|
-
failures: [],
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Fast path: reads the working repo's own `.mate/config/registry.yaml`
|
|
105
|
-
* before scanning every registered companion. Treats the file as the
|
|
106
|
-
* working repo's source of truth for companion pointers: existing
|
|
107
|
-
* directories are accepted immediately, stale paths are dropped, and
|
|
108
|
-
* absence of usable local data means the repo is not linked.
|
|
109
|
-
*/
|
|
110
|
-
private async resolveFromRepoLocalRegistry(
|
|
111
|
-
resolvedCwd: string,
|
|
112
|
-
options: { logFailures?: boolean },
|
|
113
|
-
): Promise<CompanionResolutionResult | null> {
|
|
114
|
-
const found = await findRepoLocalRegistryFile(resolvedCwd);
|
|
115
|
-
if (!found) return null;
|
|
116
|
-
|
|
117
|
-
let pointers: RepoLocalCompanionPointer[];
|
|
118
|
-
try {
|
|
119
|
-
const raw = await fs.readFile(found.registryPath, "utf8");
|
|
120
|
-
const parsed = parse(raw) as { companions?: unknown } | null;
|
|
121
|
-
if (!parsed || !Array.isArray(parsed.companions)) return null;
|
|
122
|
-
pointers = parsed.companions as RepoLocalCompanionPointer[];
|
|
123
|
-
} catch {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (pointers.length === 0) return null;
|
|
128
|
-
|
|
129
|
-
const survivors: RepoLocalCompanionPointer[] = [];
|
|
130
|
-
const validMatches: CompanionMatch[] = [];
|
|
131
|
-
let changed = false;
|
|
132
|
-
|
|
133
|
-
const checkedPointers = await Promise.all(
|
|
134
|
-
pointers.map(async (pointer) => {
|
|
135
|
-
const companionPath = path.resolve(pointer.path);
|
|
136
|
-
return { pointer, companionPath, exists: await pathIsDirectory(companionPath) };
|
|
137
|
-
}),
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
for (const { pointer, companionPath, exists } of checkedPointers) {
|
|
141
|
-
if (exists) {
|
|
142
|
-
survivors.push({
|
|
143
|
-
path: companionPath,
|
|
144
|
-
repositoryId: pointer.repositoryId,
|
|
145
|
-
source: pointer.source,
|
|
146
|
-
});
|
|
147
|
-
validMatches.push({ companionPath, repositoryId: pointer.repositoryId });
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
changed = true;
|
|
152
|
-
if (options.logFailures) {
|
|
153
|
-
console.error(
|
|
154
|
-
`mate: dropping stale repo-local companion pointer (no longer exists): ${companionPath}`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (changed) {
|
|
160
|
-
const store = new RepoLocalRegistryStore(found.registryPath);
|
|
161
|
-
await store.save({ companions: survivors });
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (validMatches.length === 0) return null;
|
|
165
|
-
|
|
166
|
-
const seenCompanionPaths = new Set<string>();
|
|
167
|
-
const ambiguousMatches = validMatches.filter((match) => {
|
|
168
|
-
if (seenCompanionPaths.has(match.companionPath)) return false;
|
|
169
|
-
seenCompanionPaths.add(match.companionPath);
|
|
170
|
-
return true;
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
match: ambiguousMatches[0]!,
|
|
175
|
-
ambiguousMatches: ambiguousMatches.length > 1 ? ambiguousMatches : [],
|
|
176
|
-
failures: [],
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
}
|