@uniqbit/mate-core 0.18.1-canary.1 → 0.18.1-canary.4
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/session-banner.mjs +1 -1
- package/opencode-plugin/server.ts +5 -0
- package/opencode-plugin/src/add-dir.ts +118 -0
- package/opencode-plugin/src/companion.ts +180 -0
- package/opencode-plugin/src/server.ts +26 -0
- package/opencode-plugin/src/tui.tsx +1 -0
- package/opencode-plugin/tui.tsx +2 -0
- package/package.json +8 -6
- package/src/cli/commands/artifact/finish/engine.ts +1 -1
- package/src/cli/commands/artifact/finish/git.ts +74 -59
- package/src/cli/commands/companion/hub.ts +1 -1
- package/src/cli/commands/companion/link.ts +13 -43
- package/src/cli/commands/doctor.ts +35 -4
- package/src/cli/commands/install.ts +2 -18
- package/src/cli/commands/plugin/plugin.ts +0 -4
- package/src/cli/commands/report/formatter.ts +0 -4
- package/src/cli/commands/studio/selection.ts +3 -0
- package/src/cli/commands/studio/terminal-launch.ts +51 -1
- package/src/cli/commands/studio/terminal.ts +13 -2
- package/src/cli/commands/studio/vault.ts +5 -3
- package/src/cli/commands/studio/views/workflow/steps.ts +5 -4
- package/src/cli/commands/update.ts +4 -20
- package/src/cli/commands/working/cleanup.ts +4 -1
- package/src/cli/launch-selector.tsx +0 -318
- package/src/cli/main.ts +0 -29
- package/src/cli/setup-selector.tsx +1 -1
- package/src/cli/usage.ts +0 -1
- package/src/hooks/session-banner.ts +7 -5
- package/src/hooks/validate-artifact-path.ts +17 -6
- package/src/lib/opencode-plugin-package.ts +7 -28
- package/src/lib/orchestrator/adapters/opencode.ts +28 -67
- package/src/lib/orchestrator/companion-git-sync.ts +22 -104
- package/src/lib/orchestrator/companion-hub.ts +70 -119
- package/src/lib/orchestrator/repo-local-registry.ts +0 -21
- package/src/lib/orchestrator/types.ts +6 -0
- package/src/lib/orchestrator/working-repo-projection.ts +13 -9
- package/src/lib/package-paths.ts +32 -0
- package/src/lib/prebuilt-workspace.ts +0 -3
- package/src/opencode/companion-hooks.ts +15 -10
- package/src/runtime/companion-git.ts +279 -102
- package/src/runtime/companion-sync.ts +34 -44
- package/src/runtime/index.ts +7 -5
- package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +2 -2
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +25 -0
- package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +9 -7
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +34 -4
- package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +2 -2
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +25 -0
- package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +9 -7
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +34 -4
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +68 -77
- package/src/tools/setup/capabilities/graphify.ts +0 -4
- package/src/tools/setup/dynamic-plugins/frozen.ts +3 -24
- package/src/tools/setup/dynamic-plugins/loader.ts +0 -16
- package/src/tools/setup/dynamic-plugins/verify.ts +18 -22
- package/src/tools/setup/providers/opencode-format.ts +4 -0
- package/src/tools/setup/providers/opencode.ts +9 -65
- package/src/tools/setup/runtime-documents.ts +8 -9
- package/src/cli/commands/plugin/verify.ts +0 -25
- package/src/tools/setup/dynamic-plugins/policy.ts +0 -55
- package/src/tools/setup/dynamic-plugins/staging.ts +0 -53
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { Context } from "@opencode/plugin/promise/plugin";
|
|
5
|
+
|
|
6
|
+
type CompanionSkill = {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
path: string;
|
|
11
|
+
content: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function companionSkillPaths(companionPath: string): string[] {
|
|
15
|
+
return [
|
|
16
|
+
path.join(companionPath, ".agents", "skills"),
|
|
17
|
+
path.join(companionPath, ".opencode", "skills"),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
22
|
+
const relative = path.relative(root, path.resolve(candidate));
|
|
23
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseFrontmatter(content: string): { name?: string; description?: string } {
|
|
27
|
+
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
28
|
+
if (!match) return {};
|
|
29
|
+
|
|
30
|
+
const scalar = (key: string) => {
|
|
31
|
+
const value = match[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim();
|
|
32
|
+
return value?.replace(
|
|
33
|
+
/^(?:"([\s\S]*)"|'([\s\S]*)')$/,
|
|
34
|
+
(_all, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted,
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return { name: scalar("name"), description: scalar("description") };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function findSkillFiles(directory: string): Promise<string[]> {
|
|
42
|
+
try {
|
|
43
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
44
|
+
const files: string[] = [];
|
|
45
|
+
const subdirectories: Promise<string[]>[] = [];
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const entryPath = path.join(directory, entry.name);
|
|
48
|
+
if (entry.isFile() && entry.name === "SKILL.md") files.push(entryPath);
|
|
49
|
+
else if (entry.isDirectory()) subdirectories.push(findSkillFiles(entryPath));
|
|
50
|
+
}
|
|
51
|
+
const nested = await Promise.all(subdirectories);
|
|
52
|
+
return [...files, ...nested.flat()];
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function skillID(name: string): string {
|
|
59
|
+
return (
|
|
60
|
+
name
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
63
|
+
.replace(/^-+|-+$/g, "") || "mate-skill"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadCompanionSkills(companionPath: string): Promise<CompanionSkill[]> {
|
|
68
|
+
const files = (await Promise.all(companionSkillPaths(companionPath).map(findSkillFiles))).flat();
|
|
69
|
+
const skills = await Promise.all(
|
|
70
|
+
files.map(async (filePath): Promise<CompanionSkill | null> => {
|
|
71
|
+
try {
|
|
72
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
73
|
+
const metadata = parseFrontmatter(content);
|
|
74
|
+
const name = metadata.name || path.basename(path.dirname(filePath));
|
|
75
|
+
return {
|
|
76
|
+
id: skillID(name),
|
|
77
|
+
name,
|
|
78
|
+
...(metadata.description ? { description: metadata.description } : {}),
|
|
79
|
+
path: filePath,
|
|
80
|
+
content,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
/** An unreadable companion skill must not prevent the session from loading. */
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
return skills.filter((skill) => skill !== null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Grants the session access to the companion and registers its skills. The
|
|
93
|
+
* permission hook only widens access for companion resources; every other
|
|
94
|
+
* evaluation keeps OpenCode's own verdict.
|
|
95
|
+
*/
|
|
96
|
+
export async function registerCompanionAccess(api: Context, companionPath: string): Promise<void> {
|
|
97
|
+
const permissionRoot = path.resolve(companionPath);
|
|
98
|
+
|
|
99
|
+
await api.permission.hook("evaluate", (event) => {
|
|
100
|
+
if (event.resources.some((resource) => isWithin(permissionRoot, resource))) {
|
|
101
|
+
event.effect = "allow";
|
|
102
|
+
delete event.message;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const skills = await loadCompanionSkills(permissionRoot);
|
|
107
|
+
if (skills.length === 0) return;
|
|
108
|
+
|
|
109
|
+
await api.skill.transform((editor) => {
|
|
110
|
+
for (const skill of skills) {
|
|
111
|
+
if (editor.get(skill.id)) {
|
|
112
|
+
editor.update(skill.id, (current) => Object.assign(current, skill));
|
|
113
|
+
} else {
|
|
114
|
+
editor.add(skill as never);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { Context } from "@opencode/plugin/promise/plugin";
|
|
4
|
+
import { MATE_ENV } from "../../src/runtime/env";
|
|
5
|
+
import { COMPANION_POLICY_MARKER, type MateGuidanceFile } from "../../src/runtime/guidance";
|
|
6
|
+
|
|
7
|
+
import type { CompanionContext } from "../../src/opencode/companion-policy";
|
|
8
|
+
import { resolveOpenCodeGuidance } from "../../src/opencode/projected-guidance";
|
|
9
|
+
|
|
10
|
+
function prependPathEntry(pathValue: string | undefined, entry: string): string {
|
|
11
|
+
const entries = (pathValue ?? "").split(path.delimiter).filter(Boolean);
|
|
12
|
+
return [entry, ...entries.filter((value) => value !== entry)].join(path.delimiter);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveWrapperBinPath(): string {
|
|
16
|
+
return process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildStartupError(details: string[]): Error {
|
|
20
|
+
return new Error(
|
|
21
|
+
[
|
|
22
|
+
"Mate OpenCode companion plugin is incomplete and cannot start.",
|
|
23
|
+
...details.map((detail) => `- ${detail}`),
|
|
24
|
+
"Launch through `mate opencode`, or run `mate wrap` so the guidance resolves from the Projection Root.",
|
|
25
|
+
].join("\n"),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Env first, projection second. A session Mate launched carries the payload;
|
|
31
|
+
* an Unmanaged Session in a wrapped repository builds the same payload from the
|
|
32
|
+
* companion the projection names, with the Capability names read live.
|
|
33
|
+
*/
|
|
34
|
+
function loadGuidance(directory: string): MateGuidanceFile | null {
|
|
35
|
+
const { guidance, errors } = resolveOpenCodeGuidance(process.env, directory);
|
|
36
|
+
if (errors.length > 0) throw buildStartupError(errors);
|
|
37
|
+
return guidance;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function materializeCompanionGuidance(guidance: string, context: CompanionContext): string {
|
|
41
|
+
const wrapperBinPath = resolveWrapperBinPath();
|
|
42
|
+
return guidance
|
|
43
|
+
.replaceAll("$MATE_REPO_PATH", context.repositoryPath)
|
|
44
|
+
.replaceAll("$MATE_ARTIFACT_PATH", context.companionPath)
|
|
45
|
+
.replaceAll("$MATE_WRAPPER_BIN_PATH", wrapperBinPath)
|
|
46
|
+
.replaceAll("$MATE_REPO_ID", context.repositoryId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildSystemPrompt(context: CompanionContext, guidance: MateGuidanceFile): string[] {
|
|
50
|
+
const lines = [materializeCompanionGuidance(guidance.companionGuidance, context)];
|
|
51
|
+
|
|
52
|
+
if (guidance.codebaseExplorationGuidance.trim()) {
|
|
53
|
+
lines.push("", guidance.codebaseExplorationGuidance);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (context.agentsMd.trim()) {
|
|
57
|
+
lines.push("", `<agents.md>${context.agentsMd.trim()}</agents.md>`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readSystemText(part: unknown): string {
|
|
64
|
+
if (typeof part === "string") return part;
|
|
65
|
+
if (typeof part !== "object" || part === null || !("text" in part)) return "";
|
|
66
|
+
return typeof part.text === "string" ? part.text : "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Session-scoped variables a spawned shell sees; mutates `env` in place. */
|
|
70
|
+
function applyCompanionShellEnv(
|
|
71
|
+
context: CompanionContext,
|
|
72
|
+
env: Record<string, string | undefined>,
|
|
73
|
+
): void {
|
|
74
|
+
const wrapperBinPath = resolveWrapperBinPath();
|
|
75
|
+
env.MATE_NAME = context.frameworkName;
|
|
76
|
+
env.MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
|
|
77
|
+
env.MATE_ARTIFACT_PATH = context.companionPath;
|
|
78
|
+
env.MATE_WRAPPER_BIN_PATH = wrapperBinPath;
|
|
79
|
+
if (context.repositoryPath) {
|
|
80
|
+
env.MATE_REPO_PATH = context.repositoryPath;
|
|
81
|
+
env.MATE_REPO_ID = context.repositoryId;
|
|
82
|
+
} else {
|
|
83
|
+
delete env.MATE_REPO_PATH;
|
|
84
|
+
delete env.MATE_REPO_ID;
|
|
85
|
+
}
|
|
86
|
+
env.MATE_POLICY_JSON = context.policyJson;
|
|
87
|
+
env.MATE_GRAPHIFY_ENABLED = context.graphifyEnabled ? "1" : "0";
|
|
88
|
+
env.MATE_GIT_AUTO_MODE = context.gitAutoModeEnabled ? "1" : "0";
|
|
89
|
+
/**
|
|
90
|
+
* Session-scoped payload consumed at plugin startup: masked so the multi-KB
|
|
91
|
+
* guidance JSON never leaks into spawned shells (and a nested `mate` launch
|
|
92
|
+
* can never inherit a stale copy).
|
|
93
|
+
*/
|
|
94
|
+
env[MATE_ENV.guidanceJson] = "";
|
|
95
|
+
env.PATH = prependPathEntry(process.env.PATH, wrapperBinPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function companionPathsResult(context: CompanionContext) {
|
|
99
|
+
const wrapperBinPath = resolveWrapperBinPath();
|
|
100
|
+
return {
|
|
101
|
+
content: JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
companionFrameworkPath: context.companionPath,
|
|
104
|
+
wrapperBinPath,
|
|
105
|
+
...(context.repositoryPath
|
|
106
|
+
? { repositoryPath: context.repositoryPath, repositoryId: context.repositoryId }
|
|
107
|
+
: {}),
|
|
108
|
+
policy: JSON.parse(context.policyJson || "{}"),
|
|
109
|
+
},
|
|
110
|
+
null,
|
|
111
|
+
2,
|
|
112
|
+
),
|
|
113
|
+
metadata: {
|
|
114
|
+
companionPath: context.companionPath,
|
|
115
|
+
wrapperBinPath,
|
|
116
|
+
...(context.repositoryPath ? { repositoryPath: context.repositoryPath } : {}),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A wrapped repository lists this plugin in its own project config, so a
|
|
123
|
+
* managed launch may load it twice; the marker keeps the second load inert.
|
|
124
|
+
*/
|
|
125
|
+
function hasCompanionPolicy(system: unknown[]): boolean {
|
|
126
|
+
return system.map(readSystemText).join("\n").includes(COMPANION_POLICY_MARKER);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Collapses the whole system prompt into a single entry. OpenCode sends each
|
|
131
|
+
* `system[]` element as its own system message, and some self-hosted chat
|
|
132
|
+
* templates (e.g. Qwen served via vLLM) reject any system message that is not
|
|
133
|
+
* the very first one.
|
|
134
|
+
*/
|
|
135
|
+
function mergeCompanionSystem(system: unknown[], companion: string): string[] {
|
|
136
|
+
if (hasCompanionPolicy(system)) return system.map(readSystemText);
|
|
137
|
+
const parts: string[] = [];
|
|
138
|
+
for (const text of [...system.map(readSystemText), companion]) {
|
|
139
|
+
const trimmed = text.trim();
|
|
140
|
+
if (trimmed.length > 0) parts.push(trimmed);
|
|
141
|
+
}
|
|
142
|
+
return parts.length > 0 ? [parts.join("\n\n")] : [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Registers guidance, compaction context, shell env, and `companion_paths`. */
|
|
146
|
+
export async function registerCompanion(api: Context, context: CompanionContext): Promise<void> {
|
|
147
|
+
const guidance = loadGuidance(api.location.directory);
|
|
148
|
+
if (!guidance) return;
|
|
149
|
+
|
|
150
|
+
const companion = buildSystemPrompt(context, guidance).join("\n");
|
|
151
|
+
|
|
152
|
+
await api.session.hook("context", (event) => {
|
|
153
|
+
const merged = mergeCompanionSystem(event.system, companion);
|
|
154
|
+
event.system.splice(
|
|
155
|
+
0,
|
|
156
|
+
event.system.length,
|
|
157
|
+
...merged.map((text) => ({ type: "text" as const, text }) as never),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await api.session.hook("compaction", (event) => {
|
|
162
|
+
if (hasCompanionPolicy(event.system)) return;
|
|
163
|
+
event.system.push({ type: "text", text: companion } as never);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await api.shell.hook("create.before", (event) => {
|
|
167
|
+
applyCompanionShellEnv(context, event.env);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await api.tool.transform((editor) => {
|
|
171
|
+
editor.add({
|
|
172
|
+
name: "companion_paths",
|
|
173
|
+
description: `Return active ${context.frameworkName} companion framework and working repository paths.`,
|
|
174
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
175
|
+
async execute() {
|
|
176
|
+
return companionPathsResult(context);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { registerCompanionHooks } from "../../src/opencode/companion-hooks";
|
|
3
|
+
import { readContext } from "../../src/opencode/companion-policy";
|
|
4
|
+
|
|
5
|
+
import { registerCompanionAccess } from "./add-dir";
|
|
6
|
+
import { registerCompanion } from "./companion";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mate OpenCode server plugin (OpenCode 2.x). Loaded through the package's
|
|
10
|
+
* `./server` export; stays inert when no companion resolves from the launch
|
|
11
|
+
* environment or a Projection Root above the session directory.
|
|
12
|
+
*/
|
|
13
|
+
export const MateOpenCodePlugin = Plugin.define({
|
|
14
|
+
id: "mate-opencode-plugin",
|
|
15
|
+
async setup(api) {
|
|
16
|
+
const context = readContext(process.env, api.location.directory);
|
|
17
|
+
if (!context.companionPath) return;
|
|
18
|
+
|
|
19
|
+
await registerCompanionAccess(api, context.companionPath);
|
|
20
|
+
const cleanup = await registerCompanionHooks(api, context);
|
|
21
|
+
await registerCompanion(api, context);
|
|
22
|
+
return cleanup;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export default MateOpenCodePlugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../../src/opencode/tui";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqbit/mate-core",
|
|
3
|
-
"version": "0.18.1-canary.
|
|
3
|
+
"version": "0.18.1-canary.4",
|
|
4
4
|
"description": "Core framework and plugin APIs for Mate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
"src/",
|
|
13
13
|
"wrappers/",
|
|
14
14
|
"claude-plugin/",
|
|
15
|
+
"opencode-plugin/",
|
|
15
16
|
"!src/**/*.test.ts",
|
|
16
|
-
"!src/**/*.test.tsx"
|
|
17
|
+
"!src/**/*.test.tsx",
|
|
18
|
+
"!opencode-plugin/**/*.test.ts"
|
|
17
19
|
],
|
|
18
20
|
"type": "module",
|
|
19
21
|
"exports": {
|
|
@@ -31,10 +33,10 @@
|
|
|
31
33
|
"scripts": {
|
|
32
34
|
"test": "CI=1 bun ../../test/run-isolated.ts",
|
|
33
35
|
"typecheck": "bunx tsc --noEmit -p ../../tsconfig.json",
|
|
34
|
-
"lint": "oxlint src/",
|
|
35
|
-
"lint:fix": "oxlint src/ --fix",
|
|
36
|
-
"format": "oxfmt src/ \"!**/*.generated.*\" \"!**/report-assets/**\"",
|
|
37
|
-
"format:check": "oxfmt src/ \"!**/*.generated.*\" \"!**/report-assets/**\" --check"
|
|
36
|
+
"lint": "oxlint src/ opencode-plugin/",
|
|
37
|
+
"lint:fix": "oxlint src/ opencode-plugin/ --fix",
|
|
38
|
+
"format": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" \"!**/report-assets/**\"",
|
|
39
|
+
"format:check": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" \"!**/report-assets/**\" --check"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
|
40
42
|
"@opencode/plugin": "^2.0.23",
|
|
@@ -181,7 +181,7 @@ export async function runFinishEngine(
|
|
|
181
181
|
await git.fetch();
|
|
182
182
|
} catch (err) {
|
|
183
183
|
// Post-commit failure: retain the commit, do not roll back.
|
|
184
|
-
fail("sync-remote", `mate: fetch failed: ${String(err)}`);
|
|
184
|
+
fail("sync-remote", `mate: fetch failed (commit retained locally): ${String(err)}`);
|
|
185
185
|
return result;
|
|
186
186
|
}
|
|
187
187
|
const rebase = await git.rebaseOntoUpstream();
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
companionGit,
|
|
6
|
+
isAuthenticationFailure,
|
|
7
|
+
type NetworkGitResult,
|
|
8
|
+
} from "../../../../runtime/companion-git";
|
|
6
9
|
|
|
7
10
|
export interface RebaseResult {
|
|
8
11
|
ok: boolean;
|
|
@@ -46,28 +49,23 @@ export interface GitOps {
|
|
|
46
49
|
push(): Promise<PushResult>;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
status: result.status ?? 1,
|
|
68
|
-
out: (result.stdout ?? "").trimEnd(),
|
|
69
|
-
err: (result.stderr ?? "").trim(),
|
|
70
|
-
};
|
|
52
|
+
export interface GitOpsOptions {
|
|
53
|
+
/** Environment Git runs with. */
|
|
54
|
+
env?: Record<string, string | undefined>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Publication never prompts, so a credential failure must say how to supply
|
|
59
|
+
* credentials non-interactively; the retained commit and tag make a re-run safe.
|
|
60
|
+
*/
|
|
61
|
+
function remoteFailureDetail(result: NetworkGitResult): string {
|
|
62
|
+
const detail = result.stderr.trim() || result.stdout.trim() || "unknown Git error";
|
|
63
|
+
if (!isAuthenticationFailure(result)) return detail;
|
|
64
|
+
const remedy =
|
|
65
|
+
result.transport === "ssh"
|
|
66
|
+
? "load the SSH key into an agent (`ssh-add`)"
|
|
67
|
+
: "configure a Git credential helper (`git config --global credential.helper <helper>`)";
|
|
68
|
+
return `${detail}\nPublication does not prompt for credentials: ${remedy}, then re-run \`mate artifact publish\`.`;
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
function resolvedPath(candidate: string): string {
|
|
@@ -109,20 +107,28 @@ function parsePorcelainKind(line: string): WorkingTreeChange {
|
|
|
109
107
|
return status.includes("?") || status.includes("A") ? "new" : "modified";
|
|
110
108
|
}
|
|
111
109
|
|
|
110
|
+
/** Read-only index queries, so they may run concurrently; input order is kept. */
|
|
111
|
+
async function filterPaths(
|
|
112
|
+
paths: string[],
|
|
113
|
+
keep: (filePath: string) => Promise<boolean>,
|
|
114
|
+
): Promise<string[]> {
|
|
115
|
+
const kept = await Promise.all(paths.map(keep));
|
|
116
|
+
return paths.filter((_, index) => kept[index]);
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
export function defaultGitOps(
|
|
113
120
|
companionPath: string,
|
|
114
121
|
workingRepoPath = process.env.MATE_REPO_PATH,
|
|
122
|
+
options: GitOpsOptions = {},
|
|
115
123
|
): GitOps {
|
|
116
124
|
assertSafeCompanionRoot(companionPath, workingRepoPath);
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const execOrThrow = (args: string[]) => {
|
|
125
|
-
const res = exec(args);
|
|
125
|
+
const git = companionGit(companionPath, { prompt: "never", env: options.env });
|
|
126
|
+
const exec = async (args: readonly string[]) => {
|
|
127
|
+
const res = await git.run(args);
|
|
128
|
+
return { status: res.status, out: res.stdout.trimEnd(), err: res.stderr.trim() };
|
|
129
|
+
};
|
|
130
|
+
const execOrThrow = async (args: string[]) => {
|
|
131
|
+
const res = await exec(args);
|
|
126
132
|
if (res.status !== 0) {
|
|
127
133
|
throw new Error(`git ${args.join(" ")} failed: ${res.err || res.out}`);
|
|
128
134
|
}
|
|
@@ -130,75 +136,84 @@ export function defaultGitOps(
|
|
|
130
136
|
};
|
|
131
137
|
return {
|
|
132
138
|
async currentBranch() {
|
|
133
|
-
const res = exec(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
139
|
+
const res = await exec(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
134
140
|
return res.status === 0 && res.out.length > 0 ? res.out : null;
|
|
135
141
|
},
|
|
136
142
|
async defaultBranch() {
|
|
137
143
|
// A configured remote HEAD is the only answer the remote itself asserts; the
|
|
138
144
|
// rest are local conventions, narrowing to git's own default last.
|
|
139
|
-
const remoteHead = exec([
|
|
145
|
+
const remoteHead = await exec([
|
|
146
|
+
"symbolic-ref",
|
|
147
|
+
"--quiet",
|
|
148
|
+
"--short",
|
|
149
|
+
"refs/remotes/origin/HEAD",
|
|
150
|
+
]);
|
|
140
151
|
if (remoteHead.status === 0 && remoteHead.out.length > 0) {
|
|
141
152
|
return remoteHead.out.replace(/^origin\//, "");
|
|
142
153
|
}
|
|
143
|
-
const configured = exec(["config", "--get", "init.defaultBranch"]);
|
|
154
|
+
const configured = await exec(["config", "--get", "init.defaultBranch"]);
|
|
144
155
|
if (configured.status === 0 && configured.out.length > 0) return configured.out;
|
|
145
156
|
return "main";
|
|
146
157
|
},
|
|
147
158
|
async changedPaths() {
|
|
148
|
-
const out = exec(["status", "--porcelain"]).out;
|
|
159
|
+
const out = (await exec(["status", "--porcelain"])).out;
|
|
149
160
|
if (out.length === 0) return [];
|
|
150
161
|
return out.split("\n").map(parsePorcelainPath);
|
|
151
162
|
},
|
|
152
163
|
async changedPathKinds() {
|
|
153
|
-
const out = exec(["status", "--porcelain"]).out;
|
|
164
|
+
const out = (await exec(["status", "--porcelain"])).out;
|
|
154
165
|
if (out.length === 0) return {};
|
|
155
166
|
return Object.fromEntries(
|
|
156
167
|
out.split("\n").map((line) => [parsePorcelainPath(line), parsePorcelainKind(line)]),
|
|
157
168
|
);
|
|
158
169
|
},
|
|
159
170
|
async stagedPaths() {
|
|
160
|
-
const out = exec(["diff", "--cached", "--name-only"]).out;
|
|
171
|
+
const out = (await exec(["diff", "--cached", "--name-only"])).out;
|
|
161
172
|
return out.length === 0 ? [] : out.split("\n");
|
|
162
173
|
},
|
|
163
174
|
async add(paths) {
|
|
164
|
-
const applicable =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
const applicable = await filterPaths(
|
|
176
|
+
paths,
|
|
177
|
+
async (filePath) =>
|
|
178
|
+
(await exec(["ls-files", "--cached", "--others", "--exclude-standard", "--", filePath]))
|
|
179
|
+
.out.length > 0,
|
|
168
180
|
);
|
|
169
181
|
if (applicable.length > 0) {
|
|
170
|
-
execOrThrow(["add", "--", ...applicable]);
|
|
182
|
+
await execOrThrow(["add", "--", ...applicable]);
|
|
171
183
|
}
|
|
172
184
|
},
|
|
173
185
|
async hasStagedChanges(paths) {
|
|
174
186
|
// `diff --cached --quiet` exits 1 when there ARE staged changes.
|
|
175
187
|
const pathspec = paths && paths.length > 0 ? ["--", ...paths] : [];
|
|
176
|
-
return exec(["diff", "--cached", "--quiet", ...pathspec]).status !== 0;
|
|
188
|
+
return (await exec(["diff", "--cached", "--quiet", ...pathspec])).status !== 0;
|
|
177
189
|
},
|
|
178
190
|
async commit(message, paths) {
|
|
179
191
|
// A produced path can be unknown to git entirely — e.g. an active change dir that
|
|
180
192
|
// was never committed and has just been moved into the archive. `git commit` rejects
|
|
181
193
|
// such pathspecs ("did not match any file(s) known to git"), so keep only paths that
|
|
182
194
|
// exist in the index or in HEAD (staged deletions match HEAD, new files the index).
|
|
183
|
-
const known =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
exec(["ls-
|
|
195
|
+
const known = await filterPaths(
|
|
196
|
+
paths ?? [],
|
|
197
|
+
async (filePath) =>
|
|
198
|
+
(await exec(["ls-files", "--cached", "--", filePath])).out.length > 0 ||
|
|
199
|
+
(await exec(["ls-tree", "--name-only", "HEAD", "--", filePath])).out.length > 0,
|
|
187
200
|
);
|
|
188
201
|
const pathspec = known.length > 0 ? ["--", ...known] : [];
|
|
189
202
|
if (paths && paths.length > 0 && known.length === 0) return;
|
|
190
|
-
execOrThrow(["commit", "-m", message, ...pathspec]);
|
|
203
|
+
await execOrThrow(["commit", "-m", message, ...pathspec]);
|
|
191
204
|
},
|
|
192
205
|
async hasUpstream() {
|
|
193
|
-
return
|
|
206
|
+
return (
|
|
207
|
+
(await exec(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])).status === 0
|
|
208
|
+
);
|
|
194
209
|
},
|
|
195
210
|
async fetch() {
|
|
196
|
-
const res =
|
|
197
|
-
if (res.status !== 0) throw new Error(`git fetch failed: ${res
|
|
211
|
+
const res = await git.fetch();
|
|
212
|
+
if (res.status !== 0) throw new Error(`git fetch failed: ${remoteFailureDetail(res)}`);
|
|
198
213
|
},
|
|
199
214
|
async rebaseOntoUpstream() {
|
|
200
|
-
const res = exec(["rebase", "--autostash", "@{u}"]);
|
|
201
|
-
const conflicts = exec(["diff", "--name-only", "--diff-filter=U"]);
|
|
215
|
+
const res = await exec(["rebase", "--autostash", "@{u}"]);
|
|
216
|
+
const conflicts = await exec(["diff", "--name-only", "--diff-filter=U"]);
|
|
202
217
|
if (res.status === 0 && conflicts.out.length === 0) {
|
|
203
218
|
return { ok: true, conflictedPaths: [] };
|
|
204
219
|
}
|
|
@@ -208,14 +223,14 @@ export function defaultGitOps(
|
|
|
208
223
|
};
|
|
209
224
|
},
|
|
210
225
|
async tagExists(name) {
|
|
211
|
-
return exec(["tag", "--list", name]).out.length > 0;
|
|
226
|
+
return (await exec(["tag", "--list", name])).out.length > 0;
|
|
212
227
|
},
|
|
213
228
|
async tag(name, message) {
|
|
214
|
-
execOrThrow(["tag", "-a", name, "-m", message]);
|
|
229
|
+
await execOrThrow(["tag", "-a", name, "-m", message]);
|
|
215
230
|
},
|
|
216
231
|
async push() {
|
|
217
|
-
const res =
|
|
218
|
-
return { ok: res.status === 0, error: res.
|
|
232
|
+
const res = await git.push(["--follow-tags"]);
|
|
233
|
+
return { ok: res.status === 0, error: res.status === 0 ? "" : remoteFailureDetail(res) };
|
|
219
234
|
},
|
|
220
235
|
};
|
|
221
236
|
}
|
|
@@ -58,7 +58,7 @@ async function runHubInit(argv: string[]): Promise<void> {
|
|
|
58
58
|
async function runHubAdd(argv: string[]): Promise<void> {
|
|
59
59
|
const sourceArg =
|
|
60
60
|
positionalArgs(argv)[0] ?? flagValue(argv, "url") ?? flagValue(argv, "companion");
|
|
61
|
-
const source = discoverHubSource(sourceArg ?? (await selectRegisteredCompanion()));
|
|
61
|
+
const source = await discoverHubSource(sourceArg ?? (await selectRegisteredCompanion()));
|
|
62
62
|
const ref = flagValue(argv, "ref");
|
|
63
63
|
if (ref && source.kind === "git") source.ref = ref;
|
|
64
64
|
const member = await addHubMember(process.cwd(), source, {
|