@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.
Files changed (61) hide show
  1. package/claude-plugin/hooks/session-banner.mjs +1 -1
  2. package/opencode-plugin/server.ts +5 -0
  3. package/opencode-plugin/src/add-dir.ts +118 -0
  4. package/opencode-plugin/src/companion.ts +180 -0
  5. package/opencode-plugin/src/server.ts +26 -0
  6. package/opencode-plugin/src/tui.tsx +1 -0
  7. package/opencode-plugin/tui.tsx +2 -0
  8. package/package.json +13 -6
  9. package/src/cli/commands/artifact/finish/engine.ts +1 -1
  10. package/src/cli/commands/artifact/finish/git.ts +75 -51
  11. package/src/cli/commands/companion/hub.ts +1 -1
  12. package/src/cli/commands/companion/link.ts +13 -59
  13. package/src/cli/commands/doctor.ts +30 -4
  14. package/src/cli/commands/install.ts +17 -1
  15. package/src/cli/commands/plugin/plugin.ts +0 -4
  16. package/src/cli/commands/report/formatter.ts +0 -4
  17. package/src/cli/commands/studio/selection.ts +3 -0
  18. package/src/cli/commands/studio/terminal-launch.ts +1 -51
  19. package/src/cli/commands/studio/terminal.ts +2 -13
  20. package/src/cli/commands/studio/vault.ts +5 -3
  21. package/src/cli/commands/studio/views/workflow/steps.ts +5 -4
  22. package/src/cli/commands/update.ts +4 -20
  23. package/src/cli/commands/working/cleanup.ts +4 -1
  24. package/src/cli/launch-selector.tsx +0 -318
  25. package/src/cli/main.ts +0 -29
  26. package/src/cli/setup-selector.tsx +1 -1
  27. package/src/cli/usage.ts +0 -1
  28. package/src/hooks/session-banner.ts +7 -5
  29. package/src/hooks/validate-artifact-path.ts +17 -6
  30. package/src/lib/opencode-plugin-package.ts +7 -28
  31. package/src/lib/orchestrator/adapters/opencode.ts +28 -67
  32. package/src/lib/orchestrator/companion-git-sync.ts +29 -93
  33. package/src/lib/orchestrator/companion-hub.ts +71 -110
  34. package/src/lib/orchestrator/repo-local-registry.ts +0 -21
  35. package/src/lib/orchestrator/types.ts +0 -6
  36. package/src/lib/orchestrator/working-repo-projection.ts +13 -9
  37. package/src/lib/package-paths.ts +32 -0
  38. package/src/lib/prebuilt-workspace.ts +0 -3
  39. package/src/opencode/companion-hooks.ts +15 -10
  40. package/src/runtime/companion-git.ts +327 -84
  41. package/src/runtime/companion-sync.ts +34 -43
  42. package/src/runtime/index.ts +7 -5
  43. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +2 -2
  44. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +25 -0
  45. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +9 -7
  46. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +34 -4
  47. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +2 -2
  48. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +25 -0
  49. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +9 -7
  50. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +34 -4
  51. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +68 -77
  52. package/src/tools/setup/capabilities/graphify.ts +0 -4
  53. package/src/tools/setup/dynamic-plugins/frozen.ts +24 -3
  54. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  55. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  56. package/src/tools/setup/dynamic-plugins/staging.ts +55 -0
  57. package/src/tools/setup/dynamic-plugins/verify.ts +22 -18
  58. package/src/tools/setup/providers/opencode-format.ts +4 -0
  59. package/src/tools/setup/providers/opencode.ts +9 -65
  60. package/src/tools/setup/runtime-documents.ts +8 -9
  61. package/src/cli/commands/plugin/verify.ts +0 -29
@@ -5,4 +5,4 @@
5
5
  import "./ts-loader.mjs";
6
6
 
7
7
  const { run } = await import("../../src/hooks/session-banner.ts");
8
- process.exitCode = run();
8
+ process.exitCode = await run();
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Root entry for a plugin bound by directory path: OpenCode resolves `<dir>/server`
3
+ * there and never reads `exports`, which serves only package-name references.
4
+ */
5
+ export { default } from "./src/server";
@@ -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";
@@ -0,0 +1,2 @@
1
+ /** Root entry for a plugin bound by directory path; see `server.ts`. */
2
+ export { default } from "./src/tui";
package/package.json CHANGED
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.1-canary.0",
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
- function gitRun(
48
- companionPath: string,
49
- args: string[],
50
- ): { status: number; out: string; err: string } {
51
- const {
52
- GIT_DIR: _gitDir,
53
- GIT_WORK_TREE: _gitWorkTree,
54
- GIT_COMMON_DIR: _gitCommonDir,
55
- GIT_INDEX_FILE: _gitIndexFile,
56
- ...gitEnv
57
- } = process.env;
58
- const result = spawnSync("git", args, {
59
- cwd: companionPath,
60
- encoding: "utf8",
61
- stdio: ["ignore", "pipe", "pipe"],
62
- env: gitEnv,
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 exec = (args: string[]) => gitRun(companionPath, args);
116
- const execOrThrow = (args: string[]) => {
117
- 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);
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(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
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 = paths.filter(
157
- (filePath) =>
158
- exec(["ls-files", "--cached", "--others", "--exclude-standard", "--", filePath]).out
159
- .length > 0,
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 = (paths ?? []).filter(
176
- (filePath) =>
177
- exec(["ls-files", "--cached", "--", filePath]).out.length > 0 ||
178
- exec(["ls-tree", "--name-only", "HEAD", "--", filePath]).out.length > 0,
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 exec(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).status === 0;
206
+ return (
207
+ (await exec(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])).status === 0
208
+ );
186
209
  },
187
210
  async fetch() {
188
- execOrThrow(["fetch"]);
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 = exec(["push", "--follow-tags"]);
209
- return { ok: res.status === 0, error: res.err || res.out };
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, {