@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.
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 +8 -6
  9. package/src/cli/commands/artifact/finish/engine.ts +1 -1
  10. package/src/cli/commands/artifact/finish/git.ts +74 -59
  11. package/src/cli/commands/companion/hub.ts +1 -1
  12. package/src/cli/commands/companion/link.ts +13 -43
  13. package/src/cli/commands/doctor.ts +35 -4
  14. package/src/cli/commands/install.ts +2 -18
  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 +51 -1
  19. package/src/cli/commands/studio/terminal.ts +13 -2
  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 +22 -104
  33. package/src/lib/orchestrator/companion-hub.ts +70 -119
  34. package/src/lib/orchestrator/repo-local-registry.ts +0 -21
  35. package/src/lib/orchestrator/types.ts +6 -0
  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 +279 -102
  41. package/src/runtime/companion-sync.ts +34 -44
  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 +3 -24
  54. package/src/tools/setup/dynamic-plugins/loader.ts +0 -16
  55. package/src/tools/setup/dynamic-plugins/verify.ts +18 -22
  56. package/src/tools/setup/providers/opencode-format.ts +4 -0
  57. package/src/tools/setup/providers/opencode.ts +9 -65
  58. package/src/tools/setup/runtime-documents.ts +8 -9
  59. package/src/cli/commands/plugin/verify.ts +0 -25
  60. package/src/tools/setup/dynamic-plugins/policy.ts +0 -55
  61. package/src/tools/setup/dynamic-plugins/staging.ts +0 -53
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.1-canary.1",
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 { runPreferringSsh, type GitResult } from "../../../../runtime/companion-git";
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
- function gitRun(
50
- companionPath: string,
51
- args: readonly string[],
52
- ): { status: number; out: string; err: string } {
53
- const {
54
- GIT_DIR: _gitDir,
55
- GIT_WORK_TREE: _gitWorkTree,
56
- GIT_COMMON_DIR: _gitCommonDir,
57
- GIT_INDEX_FILE: _gitIndexFile,
58
- ...gitEnv
59
- } = process.env;
60
- const result = spawnSync("git", args, {
61
- cwd: companionPath,
62
- encoding: "utf8",
63
- stdio: ["ignore", "pipe", "pipe"],
64
- env: gitEnv,
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 exec = (args: readonly string[]) => gitRun(companionPath, args);
118
- const toResult = (res: ReturnType<typeof exec>): GitResult => ({
119
- status: res.status,
120
- stdout: res.out,
121
- stderr: res.err,
122
- });
123
- const execNetwork = (args: string[]) => runPreferringSsh(exec, args, toResult);
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(["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
+ ]);
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 = paths.filter(
165
- (filePath) =>
166
- exec(["ls-files", "--cached", "--others", "--exclude-standard", "--", filePath]).out
167
- .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,
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 = (paths ?? []).filter(
184
- (filePath) =>
185
- exec(["ls-files", "--cached", "--", filePath]).out.length > 0 ||
186
- 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,
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 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
+ );
194
209
  },
195
210
  async fetch() {
196
- const res = execNetwork(["fetch"]);
197
- if (res.status !== 0) throw new Error(`git fetch failed: ${res.err || res.out}`);
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 = execNetwork(["push", "--follow-tags"]);
218
- 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) };
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, {