@uniqbit/mate-core 0.18.1-canary.0 → 0.18.1-canary.2
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 +13 -6
- package/src/cli/commands/artifact/finish/engine.ts +1 -1
- package/src/cli/commands/artifact/finish/git.ts +75 -51
- package/src/cli/commands/companion/hub.ts +1 -1
- package/src/cli/commands/companion/link.ts +13 -59
- package/src/cli/commands/doctor.ts +30 -4
- package/src/cli/commands/install.ts +17 -1
- 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 +1 -51
- package/src/cli/commands/studio/terminal.ts +2 -13
- 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 +29 -93
- package/src/lib/orchestrator/companion-hub.ts +71 -110
- package/src/lib/orchestrator/repo-local-registry.ts +0 -21
- package/src/lib/orchestrator/types.ts +0 -6
- 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 +327 -84
- package/src/runtime/companion-sync.ts +34 -43
- 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 +24 -3
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +55 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +22 -18
- 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 -29
|
@@ -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,14 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqbit/mate-core",
|
|
3
|
-
"version": "0.18.1-canary.
|
|
3
|
+
"version": "0.18.1-canary.2",
|
|
4
4
|
"description": "Core framework and plugin APIs for Mate.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/uniqbit-ag/mate-cli.git",
|
|
9
|
+
"directory": "packages/mate-core"
|
|
10
|
+
},
|
|
6
11
|
"files": [
|
|
7
12
|
"src/",
|
|
8
13
|
"wrappers/",
|
|
9
14
|
"claude-plugin/",
|
|
15
|
+
"opencode-plugin/",
|
|
10
16
|
"!src/**/*.test.ts",
|
|
11
|
-
"!src/**/*.test.tsx"
|
|
17
|
+
"!src/**/*.test.tsx",
|
|
18
|
+
"!opencode-plugin/**/*.test.ts"
|
|
12
19
|
],
|
|
13
20
|
"type": "module",
|
|
14
21
|
"exports": {
|
|
@@ -26,10 +33,10 @@
|
|
|
26
33
|
"scripts": {
|
|
27
34
|
"test": "CI=1 bun ../../test/run-isolated.ts",
|
|
28
35
|
"typecheck": "bunx tsc --noEmit -p ../../tsconfig.json",
|
|
29
|
-
"lint": "oxlint src/",
|
|
30
|
-
"lint:fix": "oxlint src/ --fix",
|
|
31
|
-
"format": "oxfmt src/ \"!**/*.generated.*\" \"!**/report-assets/**\"",
|
|
32
|
-
"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"
|
|
33
40
|
},
|
|
34
41
|
"dependencies": {
|
|
35
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,7 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
|
|
4
|
+
import {
|
|
5
|
+
companionGit,
|
|
6
|
+
isAuthenticationFailure,
|
|
7
|
+
type NetworkGitResult,
|
|
8
|
+
} from "../../../../runtime/companion-git";
|
|
9
|
+
|
|
5
10
|
export interface RebaseResult {
|
|
6
11
|
ok: boolean;
|
|
7
12
|
conflictedPaths: string[];
|
|
@@ -44,28 +49,23 @@ export interface GitOps {
|
|
|
44
49
|
push(): Promise<PushResult>;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
return {
|
|
65
|
-
status: result.status ?? 1,
|
|
66
|
-
out: (result.stdout ?? "").trimEnd(),
|
|
67
|
-
err: (result.stderr ?? "").trim(),
|
|
68
|
-
};
|
|
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\`.`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function resolvedPath(candidate: string): string {
|
|
@@ -107,14 +107,28 @@ function parsePorcelainKind(line: string): WorkingTreeChange {
|
|
|
107
107
|
return status.includes("?") || status.includes("A") ? "new" : "modified";
|
|
108
108
|
}
|
|
109
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
|
+
|
|
110
119
|
export function defaultGitOps(
|
|
111
120
|
companionPath: string,
|
|
112
121
|
workingRepoPath = process.env.MATE_REPO_PATH,
|
|
122
|
+
options: GitOpsOptions = {},
|
|
113
123
|
): GitOps {
|
|
114
124
|
assertSafeCompanionRoot(companionPath, workingRepoPath);
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const res =
|
|
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);
|
|
118
132
|
if (res.status !== 0) {
|
|
119
133
|
throw new Error(`git ${args.join(" ")} failed: ${res.err || res.out}`);
|
|
120
134
|
}
|
|
@@ -122,74 +136,84 @@ export function defaultGitOps(
|
|
|
122
136
|
};
|
|
123
137
|
return {
|
|
124
138
|
async currentBranch() {
|
|
125
|
-
const res = exec(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
139
|
+
const res = await exec(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
126
140
|
return res.status === 0 && res.out.length > 0 ? res.out : null;
|
|
127
141
|
},
|
|
128
142
|
async defaultBranch() {
|
|
129
143
|
// A configured remote HEAD is the only answer the remote itself asserts; the
|
|
130
144
|
// rest are local conventions, narrowing to git's own default last.
|
|
131
|
-
const remoteHead = exec([
|
|
145
|
+
const remoteHead = await exec([
|
|
146
|
+
"symbolic-ref",
|
|
147
|
+
"--quiet",
|
|
148
|
+
"--short",
|
|
149
|
+
"refs/remotes/origin/HEAD",
|
|
150
|
+
]);
|
|
132
151
|
if (remoteHead.status === 0 && remoteHead.out.length > 0) {
|
|
133
152
|
return remoteHead.out.replace(/^origin\//, "");
|
|
134
153
|
}
|
|
135
|
-
const configured = exec(["config", "--get", "init.defaultBranch"]);
|
|
154
|
+
const configured = await exec(["config", "--get", "init.defaultBranch"]);
|
|
136
155
|
if (configured.status === 0 && configured.out.length > 0) return configured.out;
|
|
137
156
|
return "main";
|
|
138
157
|
},
|
|
139
158
|
async changedPaths() {
|
|
140
|
-
const out = exec(["status", "--porcelain"]).out;
|
|
159
|
+
const out = (await exec(["status", "--porcelain"])).out;
|
|
141
160
|
if (out.length === 0) return [];
|
|
142
161
|
return out.split("\n").map(parsePorcelainPath);
|
|
143
162
|
},
|
|
144
163
|
async changedPathKinds() {
|
|
145
|
-
const out = exec(["status", "--porcelain"]).out;
|
|
164
|
+
const out = (await exec(["status", "--porcelain"])).out;
|
|
146
165
|
if (out.length === 0) return {};
|
|
147
166
|
return Object.fromEntries(
|
|
148
167
|
out.split("\n").map((line) => [parsePorcelainPath(line), parsePorcelainKind(line)]),
|
|
149
168
|
);
|
|
150
169
|
},
|
|
151
170
|
async stagedPaths() {
|
|
152
|
-
const out = exec(["diff", "--cached", "--name-only"]).out;
|
|
171
|
+
const out = (await exec(["diff", "--cached", "--name-only"])).out;
|
|
153
172
|
return out.length === 0 ? [] : out.split("\n");
|
|
154
173
|
},
|
|
155
174
|
async add(paths) {
|
|
156
|
-
const applicable =
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
175
|
+
const applicable = await filterPaths(
|
|
176
|
+
paths,
|
|
177
|
+
async (filePath) =>
|
|
178
|
+
(await exec(["ls-files", "--cached", "--others", "--exclude-standard", "--", filePath]))
|
|
179
|
+
.out.length > 0,
|
|
160
180
|
);
|
|
161
181
|
if (applicable.length > 0) {
|
|
162
|
-
execOrThrow(["add", "--", ...applicable]);
|
|
182
|
+
await execOrThrow(["add", "--", ...applicable]);
|
|
163
183
|
}
|
|
164
184
|
},
|
|
165
185
|
async hasStagedChanges(paths) {
|
|
166
186
|
// `diff --cached --quiet` exits 1 when there ARE staged changes.
|
|
167
187
|
const pathspec = paths && paths.length > 0 ? ["--", ...paths] : [];
|
|
168
|
-
return exec(["diff", "--cached", "--quiet", ...pathspec]).status !== 0;
|
|
188
|
+
return (await exec(["diff", "--cached", "--quiet", ...pathspec])).status !== 0;
|
|
169
189
|
},
|
|
170
190
|
async commit(message, paths) {
|
|
171
191
|
// A produced path can be unknown to git entirely — e.g. an active change dir that
|
|
172
192
|
// was never committed and has just been moved into the archive. `git commit` rejects
|
|
173
193
|
// such pathspecs ("did not match any file(s) known to git"), so keep only paths that
|
|
174
194
|
// exist in the index or in HEAD (staged deletions match HEAD, new files the index).
|
|
175
|
-
const known =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
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,
|
|
179
200
|
);
|
|
180
201
|
const pathspec = known.length > 0 ? ["--", ...known] : [];
|
|
181
202
|
if (paths && paths.length > 0 && known.length === 0) return;
|
|
182
|
-
execOrThrow(["commit", "-m", message, ...pathspec]);
|
|
203
|
+
await execOrThrow(["commit", "-m", message, ...pathspec]);
|
|
183
204
|
},
|
|
184
205
|
async hasUpstream() {
|
|
185
|
-
return
|
|
206
|
+
return (
|
|
207
|
+
(await exec(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])).status === 0
|
|
208
|
+
);
|
|
186
209
|
},
|
|
187
210
|
async fetch() {
|
|
188
|
-
|
|
211
|
+
const res = await git.fetch();
|
|
212
|
+
if (res.status !== 0) throw new Error(`git fetch failed: ${remoteFailureDetail(res)}`);
|
|
189
213
|
},
|
|
190
214
|
async rebaseOntoUpstream() {
|
|
191
|
-
const res = exec(["rebase", "--autostash", "@{u}"]);
|
|
192
|
-
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"]);
|
|
193
217
|
if (res.status === 0 && conflicts.out.length === 0) {
|
|
194
218
|
return { ok: true, conflictedPaths: [] };
|
|
195
219
|
}
|
|
@@ -199,14 +223,14 @@ export function defaultGitOps(
|
|
|
199
223
|
};
|
|
200
224
|
},
|
|
201
225
|
async tagExists(name) {
|
|
202
|
-
return exec(["tag", "--list", name]).out.length > 0;
|
|
226
|
+
return (await exec(["tag", "--list", name])).out.length > 0;
|
|
203
227
|
},
|
|
204
228
|
async tag(name, message) {
|
|
205
|
-
execOrThrow(["tag", "-a", name, "-m", message]);
|
|
229
|
+
await execOrThrow(["tag", "-a", name, "-m", message]);
|
|
206
230
|
},
|
|
207
231
|
async push() {
|
|
208
|
-
const res =
|
|
209
|
-
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) };
|
|
210
234
|
},
|
|
211
235
|
};
|
|
212
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, {
|