@uniqbit/mate-core 0.15.0 → 0.15.1-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/package.json +1 -1
- package/src/cli/commands/doctor.ts +4 -4
- package/src/cli/commands/report/index.ts +5 -3
- package/src/index.ts +1 -0
- package/src/lib/context-mode-package.ts +94 -0
- package/src/lib/opencode-plugin-package.ts +27 -30
- package/src/lib/orchestrator/adapters/claude.ts +20 -0
- package/src/lib/orchestrator/adapters/opencode.ts +4 -3
- package/src/lib/orchestrator/config-store.ts +22 -2
- package/src/lib/orchestrator/launcher.ts +52 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +8 -0
- package/src/lib/orchestrator/types.ts +1 -0
- package/src/opencode/companion-hooks.ts +1 -1
- package/src/plugins.ts +2 -0
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +160 -0
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-openspec-artifact-finish → mate-artifact-finish}/SKILL.md +1 -1
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-openspec-artifact-finish → mate-artifact-finish}/references/openspec.md +1 -1
- package/src/tools/setup/capabilities/context-mode.ts +197 -0
- package/src/tools/setup/capabilities/graphify.ts +1 -1
- package/src/tools/setup/capabilities/headroom.ts +3 -98
- package/src/tools/setup/capabilities/openspec.ts +20 -7
- package/src/tools/setup/capabilities/rtk.ts +107 -0
- package/src/tools/setup/context-services.ts +6 -2
- package/src/tools/setup/plugin.ts +10 -2
- package/src/tools/setup/providers/claude.ts +4 -18
- package/src/tools/setup.ts +1 -1
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-openspec-artifact-finish.sh +0 -236
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// oxlint-disable no-await-in-loop -- config files are reconciled sequentially with shared ownership state
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CONTEXT_MODE_PACKAGE_NAME,
|
|
7
|
+
getContextModeInstallDir,
|
|
8
|
+
getContextModePackageReference,
|
|
9
|
+
installContextModePackage,
|
|
10
|
+
isContextModePackageReference,
|
|
11
|
+
validateContextModePackage,
|
|
12
|
+
} from "../../../lib/context-mode-package";
|
|
13
|
+
import { warmOpenCodePackageCache } from "../../../lib/opencode-plugin-package";
|
|
14
|
+
import type { CapabilityPlugin, LaunchPreflightContext, SetupContext } from "../plugin";
|
|
15
|
+
import { pruneEmptyAncestors } from "../utils";
|
|
16
|
+
|
|
17
|
+
type Config = Record<string, unknown>;
|
|
18
|
+
interface ContextModePluginDeps {
|
|
19
|
+
installPackage?: typeof installContextModePackage;
|
|
20
|
+
validatePackage?: typeof validateContextModePackage;
|
|
21
|
+
warmOpenCodePackage?: typeof warmOpenCodePackageCache;
|
|
22
|
+
}
|
|
23
|
+
interface OwnershipState {
|
|
24
|
+
opencodeConfigs: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CONTEXT_MODE_OPENCODE_GUIDANCE = `## Context Mode
|
|
28
|
+
|
|
29
|
+
Use Context Mode tools for commands or tool calls whose output may be large, for sandboxed analysis of large files or structured data, and when indexing large fetched content.
|
|
30
|
+
|
|
31
|
+
Canonical Mate policy and any more specific tool-routing instructions remain authoritative. In particular, follow explicit requirements to use TokenSave for codebase exploration, Context7 for library documentation, and specialized tools for their assigned operations; Context Mode complements rather than replaces them.`;
|
|
32
|
+
|
|
33
|
+
function getOwnershipPath(companionPath: string): string {
|
|
34
|
+
return path.join(companionPath, ".mate", "state", "context-mode.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readOwnership(companionPath: string): Promise<OwnershipState> {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(await fs.readFile(getOwnershipPath(companionPath), "utf8")) as OwnershipState;
|
|
40
|
+
} catch {
|
|
41
|
+
return { opencodeConfigs: [] };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function writeOwnership(companionPath: string, state: OwnershipState): Promise<void> {
|
|
46
|
+
const statePath = getOwnershipPath(companionPath);
|
|
47
|
+
if (state.opencodeConfigs.length === 0) {
|
|
48
|
+
await fs.rm(statePath, { force: true });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
52
|
+
await fs.writeFile(statePath, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Config {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readConfig(filePath: string): Promise<Config> {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
|
62
|
+
return isRecord(parsed) ? parsed : {};
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasContextModeMcp(config: Config): boolean {
|
|
69
|
+
const servers = isRecord(config.mcpServers)
|
|
70
|
+
? config.mcpServers
|
|
71
|
+
: isRecord(config.mcp)
|
|
72
|
+
? config.mcp
|
|
73
|
+
: {};
|
|
74
|
+
return Object.entries(servers).some(
|
|
75
|
+
([name, value]) =>
|
|
76
|
+
name.toLowerCase().includes("context-mode") || JSON.stringify(value).includes("context-mode"),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function assertNoMcpConflict(ctx: SetupContext, provider: "claude" | "opencode") {
|
|
81
|
+
const configPath =
|
|
82
|
+
provider === "claude"
|
|
83
|
+
? path.join(ctx.companionPath, ".mcp.json")
|
|
84
|
+
: path.join(ctx.companionPath, ".opencode", "opencode.json");
|
|
85
|
+
if (hasContextModeMcp(await readConfig(configPath))) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Cannot enable context-mode for ${provider}: ${configPath} already contains a context-mode MCP registration. Remove the duplicate registration or deselect the context-mode capability; Mate did not modify it.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function updateOpenCodeConfig(ctx: SetupContext, enabled: boolean): Promise<void> {
|
|
93
|
+
const reference = getContextModePackageReference();
|
|
94
|
+
const ownership = await readOwnership(ctx.companionPath);
|
|
95
|
+
for (const name of ["opencode.json", "tui.json"]) {
|
|
96
|
+
const configPath = path.join(ctx.companionPath, ".opencode", name);
|
|
97
|
+
const owned = ownership.opencodeConfigs.includes(name);
|
|
98
|
+
if (!enabled && !owned) continue;
|
|
99
|
+
const config = await readConfig(configPath);
|
|
100
|
+
const plugins = Array.isArray(config.plugin) ? config.plugin : [];
|
|
101
|
+
const contextModeReferences = plugins.filter(isContextModePackageReference);
|
|
102
|
+
if (enabled && contextModeReferences.some((entry) => entry !== reference)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Cannot enable ${reference}: ${configPath} contains user-owned context-mode reference ${contextModeReferences.join(", ")}. Mate did not modify it.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const preserved = plugins.filter((entry) => !(entry === reference && owned));
|
|
108
|
+
if (enabled) {
|
|
109
|
+
if (!contextModeReferences.includes(reference)) {
|
|
110
|
+
config.plugin = [...plugins, reference];
|
|
111
|
+
ownership.opencodeConfigs.push(name);
|
|
112
|
+
}
|
|
113
|
+
} else if (preserved.length > 0) {
|
|
114
|
+
config.plugin = preserved;
|
|
115
|
+
} else {
|
|
116
|
+
delete config.plugin;
|
|
117
|
+
}
|
|
118
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
119
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
120
|
+
}
|
|
121
|
+
if (!enabled) ownership.opencodeConfigs = [];
|
|
122
|
+
await writeOwnership(ctx.companionPath, ownership);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function validateOpenCodeReferences(ctx: LaunchPreflightContext): Promise<string[]> {
|
|
126
|
+
const expected = getContextModePackageReference();
|
|
127
|
+
const diagnostics: string[] = [];
|
|
128
|
+
for (const name of ["opencode.json", "tui.json"]) {
|
|
129
|
+
const configPath = path.join(ctx.companionPath, ".opencode", name);
|
|
130
|
+
const config = await readConfig(configPath);
|
|
131
|
+
const plugins = Array.isArray(config.plugin) ? config.plugin : [];
|
|
132
|
+
if (!plugins.includes(expected)) {
|
|
133
|
+
diagnostics.push(
|
|
134
|
+
`Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return diagnostics;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createContextModePlugin(deps: ContextModePluginDeps = {}): CapabilityPlugin {
|
|
142
|
+
const installPackage = deps.installPackage ?? installContextModePackage;
|
|
143
|
+
const validatePackage = deps.validatePackage ?? validateContextModePackage;
|
|
144
|
+
const warmOpenCodePackage = deps.warmOpenCodePackage ?? warmOpenCodePackageCache;
|
|
145
|
+
return {
|
|
146
|
+
id: "context-mode",
|
|
147
|
+
kind: "capability",
|
|
148
|
+
label: "Context Mode",
|
|
149
|
+
description: "Protect context windows with pinned native Claude and OpenCode plugins.",
|
|
150
|
+
defaultSelected: false,
|
|
151
|
+
isEnabled: (config) =>
|
|
152
|
+
(config.capabilities ?? []).some((capability) => capability.name === "context-mode"),
|
|
153
|
+
async apply(ctx) {
|
|
154
|
+
if (ctx.mode === "setup") {
|
|
155
|
+
await installPackage(ctx.companionPath);
|
|
156
|
+
} else {
|
|
157
|
+
await validatePackage(ctx.companionPath);
|
|
158
|
+
}
|
|
159
|
+
await ctx.instructions?.append(CONTEXT_MODE_OPENCODE_GUIDANCE, {
|
|
160
|
+
providers: ["opencode"],
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
async teardown(ctx) {
|
|
164
|
+
await fs.rm(getContextModeInstallDir(ctx.companionPath), { recursive: true, force: true });
|
|
165
|
+
await pruneEmptyAncestors(getContextModeInstallDir(ctx.companionPath), ctx.companionPath);
|
|
166
|
+
},
|
|
167
|
+
forProvider: {
|
|
168
|
+
claude: {
|
|
169
|
+
async apply(ctx) {
|
|
170
|
+
await assertNoMcpConflict(ctx, "claude");
|
|
171
|
+
},
|
|
172
|
+
async teardown() {},
|
|
173
|
+
},
|
|
174
|
+
opencode: {
|
|
175
|
+
preflight: validateOpenCodeReferences,
|
|
176
|
+
async apply(ctx) {
|
|
177
|
+
await assertNoMcpConflict(ctx, "opencode");
|
|
178
|
+
await updateOpenCodeConfig(ctx, true);
|
|
179
|
+
if (ctx.mode === "setup") {
|
|
180
|
+
const result = await warmOpenCodePackage(
|
|
181
|
+
CONTEXT_MODE_PACKAGE_NAME,
|
|
182
|
+
getContextModePackageReference(),
|
|
183
|
+
);
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
process.stderr.write(
|
|
186
|
+
`mate: could not pre-fetch ${getContextModePackageReference()} for OpenCode: ${result.detail ?? "unknown error"}\n`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
async teardown(ctx) {
|
|
192
|
+
await updateOpenCodeConfig(ctx, false);
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -72,7 +72,7 @@ const CLAUDE_MANAGED_HOOK_COMMAND_SUFFIXES = new Set([
|
|
|
72
72
|
"/.claude/hooks/validate-artifact-path",
|
|
73
73
|
"/.claude/hooks/mate-session-banner",
|
|
74
74
|
"/.claude/hooks/react-doctor.sh",
|
|
75
|
-
"/.claude/hooks/mate-
|
|
75
|
+
"/.claude/hooks/mate-artifact-finish.sh",
|
|
76
76
|
]);
|
|
77
77
|
|
|
78
78
|
export function deriveGraphifyProviders(activeProviders: string[]): string[] {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { CapabilityPlugin
|
|
1
|
+
import type { CapabilityPlugin } from "../plugin";
|
|
2
2
|
import type { InstallRequirement } from "../install-contract";
|
|
3
|
-
import { isCommandOnPath, runShellCommand
|
|
3
|
+
import { isCommandOnPath, runShellCommand } from "../utils";
|
|
4
4
|
import { confirm } from "../../../cli/confirm";
|
|
5
5
|
import { isInstalledViaUvTool } from "../package-managers/uv";
|
|
6
6
|
|
|
@@ -8,55 +8,16 @@ const HEADROOM_GITIGNORE_ENTRIES = ["# headroom", ".headroom/"];
|
|
|
8
8
|
|
|
9
9
|
const HEADROOM_INSTALL_CMD = `uv tool install --python ">=3.13" "headroom-ai[all]"`;
|
|
10
10
|
|
|
11
|
-
const RTK_INSTALL_CMD_BREW = "brew install rtk-ai/tap/rtk";
|
|
12
|
-
const RTK_INSTALL_CMD_FALLBACK = "curl -fsSL https://rtk.ai/install | bash";
|
|
13
|
-
|
|
14
|
-
function defaultIsRtkOnPath(): boolean {
|
|
15
|
-
const pathValue = process.env.PATH ?? "";
|
|
16
|
-
return isCommandOnPath("rtk", pathValue);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function defaultIsBrewAvailable(): boolean {
|
|
20
|
-
const pathValue = process.env.PATH ?? "";
|
|
21
|
-
return isCommandOnPath("brew", pathValue);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const RTK_INIT_COMMANDS: Record<string, string> = {
|
|
25
|
-
claude: "rtk init -g --auto-patch",
|
|
26
|
-
opencode: "rtk init -g --opencode --auto-patch",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const RTK_UNINSTALL_COMMANDS: Record<string, string> = {
|
|
30
|
-
claude: "rtk init -g --uninstall",
|
|
31
|
-
opencode: "rtk init -g --uninstall --opencode",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
11
|
type HeadroomDeps = {
|
|
35
12
|
confirm?: typeof confirm;
|
|
36
13
|
isCommandOnPath?: typeof isCommandOnPath;
|
|
37
14
|
isInstalledViaUvTool?: (pkgName: string) => boolean;
|
|
38
|
-
isRtkOnPath?: () => boolean;
|
|
39
|
-
isBrewAvailable?: () => boolean;
|
|
40
|
-
runRtkInstallCmd?: (cmd: string) => Promise<void>;
|
|
41
|
-
runShellCommandSilently?: (cmd: string) => Promise<void>;
|
|
42
15
|
};
|
|
43
16
|
|
|
44
17
|
export function createHeadroomPlugin(deps: HeadroomDeps = {}): CapabilityPlugin {
|
|
45
18
|
const askConfirm = deps.confirm ?? confirm;
|
|
46
19
|
const checkPath = deps.isCommandOnPath ?? isCommandOnPath;
|
|
47
20
|
const checkUvTool = deps.isInstalledViaUvTool ?? isInstalledViaUvTool;
|
|
48
|
-
const checkRtkPath = deps.isRtkOnPath ?? defaultIsRtkOnPath;
|
|
49
|
-
const checkBrew = deps.isBrewAvailable ?? defaultIsBrewAvailable;
|
|
50
|
-
const runRtkInstall = deps.runRtkInstallCmd ?? runShellCommand;
|
|
51
|
-
const runSilently = deps.runRtkInstallCmd ?? runShellCommandSilently;
|
|
52
|
-
|
|
53
|
-
const hasOtherActiveRtkProvider = (
|
|
54
|
-
currentProviderId: string,
|
|
55
|
-
activeProviders: string[],
|
|
56
|
-
): boolean =>
|
|
57
|
-
Object.keys(RTK_INIT_COMMANDS).some(
|
|
58
|
-
(pid) => pid !== currentProviderId && activeProviders.includes(pid),
|
|
59
|
-
);
|
|
60
21
|
|
|
61
22
|
return {
|
|
62
23
|
id: "headroom",
|
|
@@ -78,75 +39,19 @@ export function createHeadroomPlugin(deps: HeadroomDeps = {}): CapabilityPlugin
|
|
|
78
39
|
install: () => runShellCommand(HEADROOM_INSTALL_CMD),
|
|
79
40
|
verify: () => checkPath("headroom", process.env.PATH ?? "") || checkUvTool("headroom-ai"),
|
|
80
41
|
},
|
|
81
|
-
{
|
|
82
|
-
id: "capability:rtk",
|
|
83
|
-
label: "RTK CLI",
|
|
84
|
-
group: "companion",
|
|
85
|
-
source: "Headroom capability",
|
|
86
|
-
command: checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK,
|
|
87
|
-
fingerprint: `rtk:${checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK}`,
|
|
88
|
-
detect: checkRtkPath,
|
|
89
|
-
install: () => runRtkInstall(checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK),
|
|
90
|
-
verify: checkRtkPath,
|
|
91
|
-
},
|
|
92
42
|
],
|
|
93
43
|
async apply(ctx) {
|
|
94
44
|
const pathValue = process.env.PATH ?? "";
|
|
95
|
-
|
|
45
|
+
const isHeadroomInstalled = checkPath("headroom", pathValue) || checkUvTool("headroom-ai");
|
|
96
46
|
|
|
97
47
|
if (!isHeadroomInstalled && ctx.mode === "setup") {
|
|
98
48
|
process.stdout.write(`headroom binary not found. To install:\n ${HEADROOM_INSTALL_CMD}\n`);
|
|
99
49
|
const ok = await askConfirm("Run this install command now?");
|
|
100
50
|
if (ok) {
|
|
101
51
|
await runShellCommand(HEADROOM_INSTALL_CMD);
|
|
102
|
-
isHeadroomInstalled = true;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!isHeadroomInstalled) {
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
let isRtkInstalled = checkRtkPath();
|
|
111
|
-
if (!isRtkInstalled && ctx.mode === "setup") {
|
|
112
|
-
const useBrew = checkBrew();
|
|
113
|
-
const rtkCmd = useBrew ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK;
|
|
114
|
-
process.stdout.write(`rtk binary not found. To install:\n ${rtkCmd}\n`);
|
|
115
|
-
const ok = await askConfirm("Run this install command now?");
|
|
116
|
-
if (ok) {
|
|
117
|
-
await runRtkInstall(rtkCmd);
|
|
118
|
-
isRtkInstalled = true;
|
|
119
52
|
}
|
|
120
53
|
}
|
|
121
54
|
},
|
|
122
55
|
async teardown(_ctx) {},
|
|
123
|
-
forProvider: {
|
|
124
|
-
claude: {
|
|
125
|
-
async apply(ctx: SetupContext) {
|
|
126
|
-
if (checkRtkPath()) {
|
|
127
|
-
const exec = ctx.mode === "sync" ? runSilently : runRtkInstall;
|
|
128
|
-
await exec(RTK_INIT_COMMANDS.claude);
|
|
129
|
-
}
|
|
130
|
-
},
|
|
131
|
-
async teardown(ctx: SetupContext) {
|
|
132
|
-
if (checkRtkPath() && !hasOtherActiveRtkProvider("claude", ctx.activeProviders)) {
|
|
133
|
-
await runRtkInstall(RTK_UNINSTALL_COMMANDS.claude);
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
opencode: {
|
|
138
|
-
async apply(ctx: SetupContext) {
|
|
139
|
-
if (checkRtkPath()) {
|
|
140
|
-
const exec = ctx.mode === "sync" ? runSilently : runRtkInstall;
|
|
141
|
-
await exec(RTK_INIT_COMMANDS.opencode);
|
|
142
|
-
}
|
|
143
|
-
},
|
|
144
|
-
async teardown(ctx: SetupContext) {
|
|
145
|
-
if (checkRtkPath() && !hasOtherActiveRtkProvider("opencode", ctx.activeProviders)) {
|
|
146
|
-
await runRtkInstall(RTK_UNINSTALL_COMMANDS.opencode);
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
56
|
};
|
|
152
57
|
}
|
|
@@ -29,7 +29,8 @@ export const OPENSPEC_SKILLS = [
|
|
|
29
29
|
|
|
30
30
|
// Mate-authored skills (not generated by the openspec CLI) that ship alongside the
|
|
31
31
|
// openspec workflow skills and are installed per active provider.
|
|
32
|
-
export const
|
|
32
|
+
export const MATE_ARTIFACT_SKILLS = ["mate-artifact-finish"] as const;
|
|
33
|
+
const LEGACY_CLAUDE_STATE_PATTERN = /^mate-artifact-finish\..+\.json$/;
|
|
33
34
|
|
|
34
35
|
const MATE_SKILLS_SOURCE = path.join(
|
|
35
36
|
import.meta.dirname,
|
|
@@ -43,7 +44,7 @@ const MATE_V1_SCHEMA_SOURCE = path.join(
|
|
|
43
44
|
|
|
44
45
|
const CLAUDE_HOOK_SOURCE = path.join(
|
|
45
46
|
import.meta.dirname,
|
|
46
|
-
"../../../templates/capabilities/openspec-cap/claude/hooks/mate-
|
|
47
|
+
"../../../templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh",
|
|
47
48
|
);
|
|
48
49
|
|
|
49
50
|
const OPENSPEC_TOOL_DIRS = {
|
|
@@ -108,7 +109,7 @@ export async function teardownOpenspecSkills(
|
|
|
108
109
|
skillsDir: string,
|
|
109
110
|
companionPath: string,
|
|
110
111
|
): Promise<void> {
|
|
111
|
-
for (const skill of [...OPENSPEC_SKILLS, ...
|
|
112
|
+
for (const skill of [...OPENSPEC_SKILLS, ...MATE_ARTIFACT_SKILLS]) {
|
|
112
113
|
try {
|
|
113
114
|
await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
|
|
114
115
|
} catch {
|
|
@@ -119,7 +120,7 @@ export async function teardownOpenspecSkills(
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
async function teardownMateOpenspecSkills(skillsDir: string, companionPath: string): Promise<void> {
|
|
122
|
-
for (const skill of
|
|
123
|
+
for (const skill of MATE_ARTIFACT_SKILLS) {
|
|
123
124
|
try {
|
|
124
125
|
await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
|
|
125
126
|
} catch {
|
|
@@ -139,7 +140,7 @@ async function applyMateOpenspecSkills(
|
|
|
139
140
|
): Promise<void> {
|
|
140
141
|
for (const tool of tools) {
|
|
141
142
|
const skillsDir = getSkillsDir(companionPath, tool);
|
|
142
|
-
for (const skill of
|
|
143
|
+
for (const skill of MATE_ARTIFACT_SKILLS) {
|
|
143
144
|
await mergeDir(path.join(MATE_SKILLS_SOURCE, skill), path.join(skillsDir, skill));
|
|
144
145
|
}
|
|
145
146
|
}
|
|
@@ -365,16 +366,28 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
|
|
|
365
366
|
ctx.companionPath,
|
|
366
367
|
".claude",
|
|
367
368
|
"hooks",
|
|
368
|
-
"mate-
|
|
369
|
+
"mate-artifact-finish.sh",
|
|
369
370
|
);
|
|
370
371
|
await fs.mkdir(path.dirname(hookDest), { recursive: true });
|
|
371
372
|
await fs.copyFile(CLAUDE_HOOK_SOURCE, hookDest);
|
|
372
373
|
await fs.chmod(hookDest, 0o755);
|
|
374
|
+
const stateDir = path.join(ctx.companionPath, ".claude", "state");
|
|
375
|
+
try {
|
|
376
|
+
const entries = await fs.readdir(stateDir);
|
|
377
|
+
await Promise.all(
|
|
378
|
+
entries
|
|
379
|
+
.filter((entry) => LEGACY_CLAUDE_STATE_PATTERN.test(entry))
|
|
380
|
+
.map((entry) => fs.unlink(path.join(stateDir, entry))),
|
|
381
|
+
);
|
|
382
|
+
await pruneEmptyAncestors(stateDir, ctx.companionPath);
|
|
383
|
+
} catch {
|
|
384
|
+
/* no legacy state */
|
|
385
|
+
}
|
|
373
386
|
},
|
|
374
387
|
async teardown(ctx: SetupContext) {
|
|
375
388
|
try {
|
|
376
389
|
await fs.unlink(
|
|
377
|
-
path.join(ctx.companionPath, ".claude", "hooks", "mate-
|
|
390
|
+
path.join(ctx.companionPath, ".claude", "hooks", "mate-artifact-finish.sh"),
|
|
378
391
|
);
|
|
379
392
|
} catch {
|
|
380
393
|
/* not present */
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CapabilityPlugin, SetupContext } from "../plugin";
|
|
2
|
+
import type { InstallRequirement } from "../install-contract";
|
|
3
|
+
import { isCommandOnPath, runShellCommand, runShellCommandSilently } from "../utils";
|
|
4
|
+
import { confirm } from "../../../cli/confirm";
|
|
5
|
+
|
|
6
|
+
export const RTK_INSTALL_CMD_BREW = "brew install rtk-ai/tap/rtk";
|
|
7
|
+
export const RTK_INSTALL_CMD_FALLBACK = "curl -fsSL https://rtk.ai/install | bash";
|
|
8
|
+
|
|
9
|
+
const RTK_INIT_COMMANDS: Record<string, string> = {
|
|
10
|
+
claude: "rtk init -g --auto-patch",
|
|
11
|
+
opencode: "rtk init -g --opencode --auto-patch",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const RTK_UNINSTALL_COMMANDS: Record<string, string> = {
|
|
15
|
+
claude: "rtk init -g --uninstall",
|
|
16
|
+
opencode: "rtk init -g --uninstall --opencode",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function defaultIsRtkOnPath(): boolean {
|
|
20
|
+
return isCommandOnPath("rtk", process.env.PATH ?? "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function defaultIsBrewAvailable(): boolean {
|
|
24
|
+
return isCommandOnPath("brew", process.env.PATH ?? "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RtkDeps = {
|
|
28
|
+
confirm?: typeof confirm;
|
|
29
|
+
isRtkOnPath?: () => boolean;
|
|
30
|
+
isBrewAvailable?: () => boolean;
|
|
31
|
+
runRtkInstallCmd?: (cmd: string) => Promise<void>;
|
|
32
|
+
runRtkCommandSilently?: (cmd: string) => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createRtkPlugin(deps: RtkDeps = {}): CapabilityPlugin {
|
|
36
|
+
const askConfirm = deps.confirm ?? confirm;
|
|
37
|
+
const checkRtkPath = deps.isRtkOnPath ?? defaultIsRtkOnPath;
|
|
38
|
+
const checkBrew = deps.isBrewAvailable ?? defaultIsBrewAvailable;
|
|
39
|
+
const runRtkInstall = deps.runRtkInstallCmd ?? runShellCommand;
|
|
40
|
+
const runSilently = deps.runRtkCommandSilently ?? runShellCommandSilently;
|
|
41
|
+
const hasOtherActiveRtkProvider = (currentProviderId: string, activeProviders: string[]) =>
|
|
42
|
+
Object.keys(RTK_INIT_COMMANDS).some(
|
|
43
|
+
(providerId) => providerId !== currentProviderId && activeProviders.includes(providerId),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
id: "rtk",
|
|
48
|
+
kind: "capability",
|
|
49
|
+
label: "RTK",
|
|
50
|
+
description: "Patch supported agent providers with RTK when selected.",
|
|
51
|
+
defaultSelected: false,
|
|
52
|
+
isEnabled: (config) =>
|
|
53
|
+
(config.capabilities ?? []).some((capability) => capability.name === "rtk"),
|
|
54
|
+
getInstallRequirements: (): InstallRequirement[] => {
|
|
55
|
+
const command = checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK;
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
id: "capability:rtk",
|
|
59
|
+
label: "RTK CLI",
|
|
60
|
+
group: "companion",
|
|
61
|
+
source: "RTK capability",
|
|
62
|
+
command,
|
|
63
|
+
fingerprint: `rtk:${command}`,
|
|
64
|
+
detect: checkRtkPath,
|
|
65
|
+
install: () => runRtkInstall(command),
|
|
66
|
+
verify: checkRtkPath,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
},
|
|
70
|
+
async apply(ctx) {
|
|
71
|
+
if (checkRtkPath() || ctx.mode !== "setup") return;
|
|
72
|
+
|
|
73
|
+
const command = checkBrew() ? RTK_INSTALL_CMD_BREW : RTK_INSTALL_CMD_FALLBACK;
|
|
74
|
+
process.stdout.write(`rtk binary not found. To install:\n ${command}\n`);
|
|
75
|
+
if (await askConfirm("Run this install command now?")) {
|
|
76
|
+
await runRtkInstall(command);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async teardown(_ctx) {},
|
|
80
|
+
forProvider: {
|
|
81
|
+
claude: {
|
|
82
|
+
async apply(ctx: SetupContext) {
|
|
83
|
+
if (!checkRtkPath()) return;
|
|
84
|
+
const run = ctx.mode === "sync" ? runSilently : runRtkInstall;
|
|
85
|
+
await run(RTK_INIT_COMMANDS.claude);
|
|
86
|
+
},
|
|
87
|
+
async teardown(ctx: SetupContext) {
|
|
88
|
+
if (checkRtkPath() && !hasOtherActiveRtkProvider("claude", ctx.activeProviders)) {
|
|
89
|
+
await runRtkInstall(RTK_UNINSTALL_COMMANDS.claude);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
opencode: {
|
|
94
|
+
async apply(ctx: SetupContext) {
|
|
95
|
+
if (!checkRtkPath()) return;
|
|
96
|
+
const run = ctx.mode === "sync" ? runSilently : runRtkInstall;
|
|
97
|
+
await run(RTK_INIT_COMMANDS.opencode);
|
|
98
|
+
},
|
|
99
|
+
async teardown(ctx: SetupContext) {
|
|
100
|
+
if (checkRtkPath() && !hasOtherActiveRtkProvider("opencode", ctx.activeProviders)) {
|
|
101
|
+
await runRtkInstall(RTK_UNINSTALL_COMMANDS.opencode);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -234,8 +234,12 @@ export class ContextServiceMediator {
|
|
|
234
234
|
};
|
|
235
235
|
|
|
236
236
|
const instructions: InstructionsService = {
|
|
237
|
-
append: async (content: string) => {
|
|
238
|
-
const hosts = hostingProviders.filter(
|
|
237
|
+
append: async (content: string, options) => {
|
|
238
|
+
const hosts = hostingProviders.filter(
|
|
239
|
+
(provider) =>
|
|
240
|
+
provider.hosting.instructions &&
|
|
241
|
+
(options?.providers === undefined || options.providers.includes(provider.id)),
|
|
242
|
+
);
|
|
239
243
|
if (hosts.length === 0) {
|
|
240
244
|
warn(`${pluginId}: no active provider hosts instruction injection; skipping append`);
|
|
241
245
|
return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FrameworkConfig } from "../../lib/orchestrator/types";
|
|
1
|
+
import type { FrameworkConfig, LinkedRepository } from "../../lib/orchestrator/types";
|
|
2
2
|
import type { InstallRequirement, InstallRequirementContext } from "./install-contract";
|
|
3
3
|
|
|
4
4
|
export interface SetupContext {
|
|
@@ -18,6 +18,13 @@ export interface SetupContext {
|
|
|
18
18
|
templates?: TemplatesService;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface LaunchPreflightContext {
|
|
22
|
+
companionPath: string;
|
|
23
|
+
config: FrameworkConfig;
|
|
24
|
+
repository: LinkedRepository;
|
|
25
|
+
providerId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
/** Provider-agnostic description of an MCP server to register. */
|
|
22
29
|
export interface McpServerDescriptor {
|
|
23
30
|
name: string;
|
|
@@ -32,7 +39,7 @@ export interface McpService {
|
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export interface InstructionsService {
|
|
35
|
-
append(content: string): Promise<void>;
|
|
42
|
+
append(content: string, options?: { providers?: string[] }): Promise<void>;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface TemplatesService {
|
|
@@ -113,6 +120,7 @@ export interface CapabilityPlugin extends Plugin {
|
|
|
113
120
|
{
|
|
114
121
|
apply(ctx: SetupContext): Promise<void>;
|
|
115
122
|
teardown(ctx: SetupContext): Promise<void>;
|
|
123
|
+
preflight?(ctx: LaunchPreflightContext): Promise<string[]>;
|
|
116
124
|
}
|
|
117
125
|
>;
|
|
118
126
|
}
|
|
@@ -46,7 +46,7 @@ const MANAGED_HOOK_MARKERS = [
|
|
|
46
46
|
"validate-artifact-path",
|
|
47
47
|
"mate-session-banner",
|
|
48
48
|
"react-doctor.sh",
|
|
49
|
-
"mate-
|
|
49
|
+
"mate-artifact-finish.sh",
|
|
50
50
|
"tokensave",
|
|
51
51
|
];
|
|
52
52
|
|
|
@@ -71,12 +71,12 @@ function getCapabilityPermissionEntries(): Record<string, string[]> {
|
|
|
71
71
|
"Skill(openspec-propose)",
|
|
72
72
|
"Skill(openspec-apply-change)",
|
|
73
73
|
"Skill(openspec-archive-change)",
|
|
74
|
-
"Skill(mate-
|
|
74
|
+
"Skill(mate-artifact-finish)",
|
|
75
75
|
"Bash(openspec:*)",
|
|
76
76
|
"Bash(mate cap graphify:*)",
|
|
77
77
|
`Bash(${path.join(wrapperBinPath, "openspec")}:*)`,
|
|
78
78
|
],
|
|
79
|
-
|
|
79
|
+
rtk: ["Bash(rtk:*)"],
|
|
80
80
|
graphify: [
|
|
81
81
|
"Skill(graphify)",
|
|
82
82
|
"Bash(graphify:*)",
|
|
@@ -273,26 +273,12 @@ function buildManagedClaudeSettings(
|
|
|
273
273
|
hooks: [
|
|
274
274
|
{
|
|
275
275
|
type: "command",
|
|
276
|
-
command: `sh "${companionPath}/.claude/hooks/mate-
|
|
276
|
+
command: `sh "${companionPath}/.claude/hooks/mate-artifact-finish.sh"`,
|
|
277
277
|
},
|
|
278
278
|
],
|
|
279
279
|
},
|
|
280
280
|
...(hooks.PostToolUse ?? []),
|
|
281
281
|
];
|
|
282
|
-
// End-of-turn catch-all: an archive created by anything other than a matched Bash
|
|
283
|
-
// command (or as the last action of a turn) still gets a finish nudge on Stop.
|
|
284
|
-
hooks.Stop = [
|
|
285
|
-
{
|
|
286
|
-
hooks: [
|
|
287
|
-
{
|
|
288
|
-
type: "command",
|
|
289
|
-
command: `sh "${companionPath}/.claude/hooks/mate-openspec-artifact-finish.sh"`,
|
|
290
|
-
timeout: 10,
|
|
291
|
-
},
|
|
292
|
-
],
|
|
293
|
-
},
|
|
294
|
-
...(hooks.Stop ?? []),
|
|
295
|
-
];
|
|
296
282
|
}
|
|
297
283
|
if (reactDoctorEnabled) {
|
|
298
284
|
// Record edits cheaply, then scan once when the edited turn finishes.
|
package/src/tools/setup.ts
CHANGED
|
@@ -121,7 +121,7 @@ function mateFolderReadme(): string {
|
|
|
121
121
|
`Edit \`.${n}/config/framework.yaml\` to configure:`,
|
|
122
122
|
``,
|
|
123
123
|
`- **profiles** — per-profile allowed agents list`,
|
|
124
|
-
`- **capabilities** — skill and CLI tool capabilities (e.g. react-doctor, openspec, tokensave, headroom)`,
|
|
124
|
+
`- **capabilities** — skill and CLI tool capabilities (e.g. react-doctor, openspec, tokensave, headroom, rtk)`,
|
|
125
125
|
`- **git** — set to \`auto\` to synchronize the companion before agent launches`,
|
|
126
126
|
``,
|
|
127
127
|
].join("\n");
|