@uniqbit/mate-core 0.18.1-canary.1 → 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 +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 +30 -4
- 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/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/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/staging.ts +3 -1
- 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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import fs from "node:fs/promises";
|
|
5
4
|
|
|
6
5
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
@@ -19,17 +18,16 @@ import { runSetupFlowAtPath } from "../setup";
|
|
|
19
18
|
import { runInstallCommand } from "../install";
|
|
20
19
|
import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
|
|
21
20
|
import { invalidateInstallState } from "../../../lib/install";
|
|
22
|
-
import {
|
|
21
|
+
import { companionGit, describeGitFailure, type GitResult } from "../../../runtime/companion-git";
|
|
23
22
|
|
|
24
23
|
interface CompanionLinkCommandDeps {
|
|
25
24
|
selectCompanionLinkInputs?: (
|
|
26
25
|
options: CompanionLinkWizardOptions,
|
|
27
26
|
) => Promise<CompanionLinkInputs | null>;
|
|
28
27
|
extractRepoName?: (url: string) => string | null;
|
|
29
|
-
|
|
28
|
+
cloneCompanion?: (url: string, destination: string) => Promise<GitResult>;
|
|
30
29
|
mkdir?: typeof fs.mkdir;
|
|
31
30
|
stat?: typeof fs.stat;
|
|
32
|
-
rm?: typeof fs.rm;
|
|
33
31
|
listCompanions?: () => Promise<string[]>;
|
|
34
32
|
registerCompanion?: (companionPath: string) => Promise<void>;
|
|
35
33
|
resolveCompanion?: (cwd: string) => ReturnType<CompanionResolver["resolve"]>;
|
|
@@ -80,10 +78,12 @@ export async function runCompanionLinkCommandWithDeps(
|
|
|
80
78
|
|
|
81
79
|
const selectInputs = deps.selectCompanionLinkInputs ?? selectCompanionLinkInputs;
|
|
82
80
|
const extractName = deps.extractRepoName ?? extractRepoName;
|
|
83
|
-
const
|
|
81
|
+
const cloneCompanion =
|
|
82
|
+
deps.cloneCompanion ??
|
|
83
|
+
((url: string, destination: string) =>
|
|
84
|
+
companionGit(path.dirname(destination), { prompt: "if-terminal" }).clone(url, destination));
|
|
84
85
|
const mkdir = deps.mkdir ?? fs.mkdir;
|
|
85
86
|
const stat = deps.stat ?? fs.stat;
|
|
86
|
-
const rm = deps.rm ?? fs.rm;
|
|
87
87
|
const listCompanions =
|
|
88
88
|
deps.listCompanions ??
|
|
89
89
|
(() => {
|
|
@@ -149,44 +149,14 @@ export async function runCompanionLinkCommandWithDeps(
|
|
|
149
149
|
const existingTarget = await stat(companionPath).catch(() => null);
|
|
150
150
|
if (!existingTarget?.isDirectory()) {
|
|
151
151
|
await mkdir(companionsDir, { recursive: true });
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
| undefined;
|
|
161
|
-
const cloneErrors: string[] = [];
|
|
162
|
-
|
|
163
|
-
for (const cloneUrl of cloneUrls) {
|
|
164
|
-
cloneResult = spawn("git", ["clone", cloneUrl, companionPath], {
|
|
165
|
-
encoding: "utf8",
|
|
166
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
167
|
-
});
|
|
168
|
-
if (cloneResult.status === 0) {
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
cloneErrors.push(
|
|
173
|
-
String(cloneResult.stderr || cloneResult.stdout || `Clone failed for ${cloneUrl}`),
|
|
152
|
+
const clone = await cloneCompanion(gitUrl, companionPath);
|
|
153
|
+
if (clone.status !== 0) {
|
|
154
|
+
const detail = describeGitFailure(clone);
|
|
155
|
+
exitWithError(
|
|
156
|
+
`mate: failed to clone companion repository:\n${
|
|
157
|
+
detail === "unknown Git error" ? "review the Git or SSH diagnostics above" : detail
|
|
158
|
+
}`,
|
|
174
159
|
);
|
|
175
|
-
try {
|
|
176
|
-
await rm(companionPath, { recursive: true, force: true });
|
|
177
|
-
} catch {
|
|
178
|
-
// Ignore cleanup errors after a failed clone.
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (!cloneResult || cloneResult.status !== 0) {
|
|
183
|
-
try {
|
|
184
|
-
await rm(companionPath, { recursive: true, force: true });
|
|
185
|
-
} catch {
|
|
186
|
-
// Ignore cleanup errors after a failed clone.
|
|
187
|
-
}
|
|
188
|
-
const output = cloneErrors.join("\n\n");
|
|
189
|
-
exitWithError(`mate: failed to clone companion repository:\n${output}`);
|
|
190
160
|
}
|
|
191
161
|
}
|
|
192
162
|
companionPaths = [companionPath];
|
|
@@ -2,12 +2,17 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
|
+
import { gitEnvironment } from "../../runtime/companion-git";
|
|
5
6
|
import { getActiveDistribution } from "../../distribution";
|
|
6
7
|
import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
|
|
7
8
|
import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
|
|
8
9
|
import { pathIsDirectory } from "../../lib/orchestrator/repo-local-registry";
|
|
9
10
|
import { resolveRootContext, type RootContext } from "../../lib/orchestrator/root-context";
|
|
10
11
|
import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
|
|
12
|
+
import {
|
|
13
|
+
verifyDeclaredPlugins,
|
|
14
|
+
type PluginVerificationFailure,
|
|
15
|
+
} from "../../tools/setup/dynamic-plugins/verify";
|
|
11
16
|
import { getRequiredPluginDrift } from "../../tools/setup/policy";
|
|
12
17
|
import { resolveCommandOnPath } from "../../tools/setup/utils";
|
|
13
18
|
import { writeJsonStdout } from "../write-json-stdout";
|
|
@@ -19,6 +24,7 @@ interface DoctorDeps {
|
|
|
19
24
|
pathValue?: string;
|
|
20
25
|
/** Returns a member checkout's HEAD commit, or null when unreadable. */
|
|
21
26
|
gitHead?: (memberPath: string) => string | null;
|
|
27
|
+
verifyPlugins?: (companionPath: string) => Promise<PluginVerificationFailure[]>;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export type DoctorKind = "core" | "working" | "companion" | "hub";
|
|
@@ -51,6 +57,7 @@ export interface DoctorReport {
|
|
|
51
57
|
capabilities?: string[];
|
|
52
58
|
toolInstallations?: DoctorToolInstallation[];
|
|
53
59
|
requiredPluginDrift?: Array<{ pluginId: string; kind: string; reason: string }>;
|
|
60
|
+
pluginFailures?: PluginVerificationFailure[];
|
|
54
61
|
engineRequirement?: { range: string; ok: boolean; detail: string };
|
|
55
62
|
hub?: { members: DoctorHubMember[] };
|
|
56
63
|
resolutionFailures: Array<{ companionPath: string; message: string }>;
|
|
@@ -74,6 +81,7 @@ function defaultGitHead(memberPath: string): string | null {
|
|
|
74
81
|
try {
|
|
75
82
|
return execFileSync("git", ["-C", memberPath, "rev-parse", "HEAD"], {
|
|
76
83
|
stdio: ["ignore", "pipe", "ignore"],
|
|
84
|
+
env: gitEnvironment(),
|
|
77
85
|
})
|
|
78
86
|
.toString()
|
|
79
87
|
.trim();
|
|
@@ -237,6 +245,10 @@ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<Doctor
|
|
|
237
245
|
report.engineRequirement = collectEngineRequirement(root.config);
|
|
238
246
|
}
|
|
239
247
|
|
|
248
|
+
if (root.rootPath && root.config) {
|
|
249
|
+
report.pluginFailures = await (deps.verifyPlugins ?? verifyDeclaredPlugins)(root.rootPath);
|
|
250
|
+
}
|
|
251
|
+
|
|
240
252
|
return report;
|
|
241
253
|
}
|
|
242
254
|
|
|
@@ -329,6 +341,15 @@ function renderHumanReport(report: DoctorReport): void {
|
|
|
329
341
|
),
|
|
330
342
|
);
|
|
331
343
|
}
|
|
344
|
+
if (report.pluginFailures && report.pluginFailures.length > 0) {
|
|
345
|
+
printSection(
|
|
346
|
+
"Declared Plugins",
|
|
347
|
+
renderTable(
|
|
348
|
+
["Plugin", "Issue"],
|
|
349
|
+
report.pluginFailures.map((failure) => [failure.package, failure.reason]),
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
332
353
|
if (report.engineRequirement) {
|
|
333
354
|
printSection("Version Requirement", report.engineRequirement.detail);
|
|
334
355
|
}
|
|
@@ -350,13 +371,18 @@ function renderHumanReport(report: DoctorReport): void {
|
|
|
350
371
|
* working repository), `companion`, `hub`, or `core` (not linked) — with
|
|
351
372
|
* kind-specific diagnostics: policy and capabilities for working repos,
|
|
352
373
|
* capabilities and tool checks for companions, member health for hubs.
|
|
374
|
+
* Declared plugins are verified strictly (allowed by `MATE_ALLOWED_PLUGINS`,
|
|
375
|
+
* installed, loadable); any failure is named on stderr and exits non-zero.
|
|
353
376
|
* `--json` emits the same report as one JSON document.
|
|
354
377
|
*/
|
|
355
378
|
export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
|
|
356
379
|
const report = await collectDoctorReport(deps);
|
|
357
|
-
if (argv.includes("--json"))
|
|
358
|
-
|
|
359
|
-
|
|
380
|
+
if (argv.includes("--json")) await writeJsonStdout(report);
|
|
381
|
+
else renderHumanReport(report);
|
|
382
|
+
|
|
383
|
+
const failures = report.pluginFailures ?? [];
|
|
384
|
+
for (const failure of failures) {
|
|
385
|
+
process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
|
|
360
386
|
}
|
|
361
|
-
|
|
387
|
+
if (failures.length > 0) process.exitCode = 1;
|
|
362
388
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { usage } from "../../usage";
|
|
2
2
|
import { runPluginInstallCommand } from "./install";
|
|
3
|
-
import { runPluginVerifyCommand } from "./verify";
|
|
4
3
|
|
|
5
4
|
export async function runPluginCommand(
|
|
6
5
|
subcommand: string | undefined,
|
|
@@ -10,9 +9,6 @@ export async function runPluginCommand(
|
|
|
10
9
|
case "install":
|
|
11
10
|
await runPluginInstallCommand(argv);
|
|
12
11
|
return;
|
|
13
|
-
case "verify":
|
|
14
|
-
await runPluginVerifyCommand();
|
|
15
|
-
return;
|
|
16
12
|
default:
|
|
17
13
|
console.error(`Unknown plugin command: ${subcommand ?? ""}`);
|
|
18
14
|
console.error(usage());
|
|
@@ -30,6 +30,9 @@ export const STUDIO_PARAMS: readonly string[] = [
|
|
|
30
30
|
/** Filled into a prompt when no change is named, so a prompt is never half-written. */
|
|
31
31
|
export const CHANGE_PLACEHOLDER = "<change-name>";
|
|
32
32
|
|
|
33
|
+
/** Filled into pre-explore prompts, which run before any change exists. */
|
|
34
|
+
export const IDEA_PLACEHOLDER = "<idea>";
|
|
35
|
+
|
|
33
36
|
const DIGEST_LENGTH = 10;
|
|
34
37
|
|
|
35
38
|
interface SelectionBase {
|
|
@@ -4,6 +4,8 @@ import * as fsp from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
|
|
7
|
+
import { gitEnvironment } from "../../../runtime/companion-git";
|
|
8
|
+
|
|
7
9
|
export interface VaultTreeNode {
|
|
8
10
|
name: string;
|
|
9
11
|
path: string;
|
|
@@ -108,7 +110,7 @@ function inside(root: string, candidate: string): boolean {
|
|
|
108
110
|
);
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
async function realpathContaining(
|
|
113
|
+
async function realpathContaining(candidate: string): Promise<string> {
|
|
112
114
|
let current = candidate;
|
|
113
115
|
while (true) {
|
|
114
116
|
try {
|
|
@@ -146,7 +148,7 @@ export async function resolveVaultPath(
|
|
|
146
148
|
absolute = await fsp.realpath(lexical);
|
|
147
149
|
} catch (error) {
|
|
148
150
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
149
|
-
const containing = await realpathContaining(
|
|
151
|
+
const containing = await realpathContaining(path.dirname(lexical));
|
|
150
152
|
if (!inside(root, containing))
|
|
151
153
|
throw new VaultPathError("the requested path leaves the companion root");
|
|
152
154
|
absolute = lexical;
|
|
@@ -167,7 +169,7 @@ export async function resolveVaultPath(
|
|
|
167
169
|
|
|
168
170
|
function runGit(args: string[], input?: string): Promise<VaultGitResult> {
|
|
169
171
|
return new Promise((resolve, reject) => {
|
|
170
|
-
const child = spawn("git", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
172
|
+
const child = spawn("git", args, { stdio: ["pipe", "pipe", "pipe"], env: gitEnvironment() });
|
|
171
173
|
const stdout: Buffer[] = [];
|
|
172
174
|
const stderr: Buffer[] = [];
|
|
173
175
|
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CHANGE_PLACEHOLDER } from "../../selection";
|
|
1
|
+
import { CHANGE_PLACEHOLDER, IDEA_PLACEHOLDER } from "../../selection";
|
|
2
2
|
|
|
3
3
|
export type WorkflowStepKind = "skill" | "review" | "completion" | "optional";
|
|
4
4
|
export type WorkflowStepBadge = "optional" | "skill" | "openspec" | "human-in-loop";
|
|
@@ -56,11 +56,12 @@ function stepPrompt(
|
|
|
56
56
|
includeChange = true,
|
|
57
57
|
): string {
|
|
58
58
|
const changeNote = includeChange ? ` for ${CHANGE_PLACEHOLDER}` : " for";
|
|
59
|
+
const ideaNote = includeChange ? ` about ${IDEA_PLACEHOLDER}` : " about";
|
|
59
60
|
const schemaNote = profile && profile !== "mate-v1" ? ` (use schema: ${profile})` : "";
|
|
60
61
|
|
|
61
62
|
switch (step.id) {
|
|
62
63
|
case "pre-explore":
|
|
63
|
-
return `/mate-interview-me or /mate-grill-me${
|
|
64
|
+
return `/mate-interview-me or /mate-grill-me${ideaNote}`;
|
|
64
65
|
case "explore":
|
|
65
66
|
return `/openspec-explore${schemaNote}${changeNote}`;
|
|
66
67
|
case "proposal":
|
|
@@ -172,8 +173,8 @@ export function workflowPlan(
|
|
|
172
173
|
name === "mate-interview-me"
|
|
173
174
|
? "Difference: guided, one-question-at-a-time clarification. Best when: the request is ambiguous and intent, constraints, or scope still need confirmation."
|
|
174
175
|
: "Difference: adversarial, round-based review of the full design-tree frontier. Best when: the direction is mostly known but hidden assumptions, risks, and downstream impacts need surfacing.",
|
|
175
|
-
prompt: `/${name}
|
|
176
|
-
copyPrompt: `/${name}
|
|
176
|
+
prompt: `/${name} about ${IDEA_PLACEHOLDER}`,
|
|
177
|
+
copyPrompt: `/${name} about`,
|
|
177
178
|
});
|
|
178
179
|
}
|
|
179
180
|
const start = optionalStep(
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
|
-
import {
|
|
5
|
-
OPENCODE_PLUGIN_PACKAGE_NAME,
|
|
6
|
-
warmOpenCodePluginCache,
|
|
7
|
-
} from "../../lib/opencode-plugin-package";
|
|
8
4
|
import { installPublicPackageSync, isPackageInstalledAtNpmGlobalRoot } from "../../lib/public-npm";
|
|
9
5
|
import {
|
|
10
6
|
fetchLatestVersion,
|
|
@@ -66,8 +62,6 @@ export const updateCommandDeps = {
|
|
|
66
62
|
},
|
|
67
63
|
spawnPostInstall: (executable: string, args: string[]): InstallResult =>
|
|
68
64
|
spawnSync(executable, args, { stdio: "inherit" }) as InstallResult,
|
|
69
|
-
warmOpenCodePluginCache: (latest: string) =>
|
|
70
|
-
warmOpenCodePluginCache(latest, process.env, getUpdateConfig().registry),
|
|
71
65
|
};
|
|
72
66
|
|
|
73
67
|
/**
|
|
@@ -157,20 +151,6 @@ export async function runUpdateCommand(argv: string[]): Promise<void> {
|
|
|
157
151
|
|
|
158
152
|
await updateCommandDeps.saveUpdateState(latest);
|
|
159
153
|
|
|
160
|
-
// The plugin package is released in lockstep with the CLI; pre-fetch the
|
|
161
|
-
// newly coordinated version into OpenCode's plugin environment so the next
|
|
162
|
-
// managed launch does not stall on a registry download. Best effort only.
|
|
163
|
-
const warmed = await updateCommandDeps.warmOpenCodePluginCache(latest);
|
|
164
|
-
if (!warmed.ok) {
|
|
165
|
-
process.stderr.write(
|
|
166
|
-
[
|
|
167
|
-
`${FRAMEWORK_NAME}: could not pre-fetch ${OPENCODE_PLUGIN_PACKAGE_NAME}@${latest} for OpenCode.`,
|
|
168
|
-
"The next managed OpenCode launch will download it (requires registry access).",
|
|
169
|
-
...(warmed.detail ? [`Details: ${warmed.detail}`] : []),
|
|
170
|
-
].join("\n") + "\n",
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
154
|
const postInstall = updateCommandDeps.runPostInstall(skipConfirm);
|
|
175
155
|
if (postInstall.status !== 0 || postInstall.error) {
|
|
176
156
|
process.stderr.write(
|
|
@@ -182,4 +162,8 @@ export async function runUpdateCommand(argv: string[]): Promise<void> {
|
|
|
182
162
|
|
|
183
163
|
console.log(`\nUpgraded to ${latest}.`);
|
|
184
164
|
console.log("Post-update installation complete.");
|
|
165
|
+
/** Managed launches run standalone; only a long-lived service keeps the replaced plugin loaded. */
|
|
166
|
+
console.log(
|
|
167
|
+
"Restart any running OpenCode service to load the new plugin: `opencode service restart`.",
|
|
168
|
+
);
|
|
185
169
|
}
|
|
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
|
|
8
8
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
9
|
+
import { gitEnvironment } from "../../../runtime/companion-git";
|
|
9
10
|
import type { RootKind } from "../../../lib/orchestrator/root-context";
|
|
10
11
|
import {
|
|
11
12
|
collectWorkspaceInventory,
|
|
@@ -39,7 +40,9 @@ export interface WorkingCleanupCommandDeps {
|
|
|
39
40
|
|
|
40
41
|
async function resolveGitRoot(cwd: string): Promise<string | null> {
|
|
41
42
|
try {
|
|
42
|
-
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"]
|
|
43
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
44
|
+
env: gitEnvironment(),
|
|
45
|
+
});
|
|
43
46
|
return canonicalPath(stdout.trim());
|
|
44
47
|
} catch {
|
|
45
48
|
return null;
|
|
@@ -1,320 +1,2 @@
|
|
|
1
|
-
import { Box, Text, render as inkRender } from "ink";
|
|
2
|
-
import React, { useEffect } from "react";
|
|
3
|
-
import { SelectMenu, type SelectMenuItem } from "../lib/components/select-menu";
|
|
4
|
-
import { WizardFooter } from "../lib/components/wizard-footer";
|
|
5
|
-
import { WizardHeader } from "../lib/components/wizard-header";
|
|
6
|
-
|
|
7
1
|
export const LAUNCH_TARGETS = ["claude", "opencode"] as const;
|
|
8
2
|
export type LaunchTarget = (typeof LAUNCH_TARGETS)[number];
|
|
9
|
-
|
|
10
|
-
export interface LaunchPreviewData {
|
|
11
|
-
repositoryId: string;
|
|
12
|
-
repositoryPath: string;
|
|
13
|
-
companionPath: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface LaunchWizardResult {
|
|
17
|
-
target: LaunchTarget;
|
|
18
|
-
preview: LaunchPreviewData;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
type WizardStep = "target" | "review";
|
|
22
|
-
type PreviewState =
|
|
23
|
-
| { status: "idle" }
|
|
24
|
-
| { status: "loading" }
|
|
25
|
-
| { status: "ready"; data: LaunchPreviewData }
|
|
26
|
-
| { status: "error"; message: string };
|
|
27
|
-
|
|
28
|
-
interface LaunchWizardAppProps {
|
|
29
|
-
activeIndex: number;
|
|
30
|
-
hasPreselectedTarget: boolean;
|
|
31
|
-
items: SelectMenuItem[];
|
|
32
|
-
previewState: PreviewState;
|
|
33
|
-
selectedTarget: LaunchTarget | null;
|
|
34
|
-
step: WizardStep;
|
|
35
|
-
totalSteps: number;
|
|
36
|
-
onMounted?: () => void;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface LaunchWizardOptions {
|
|
40
|
-
stdin?: NodeJS.ReadStream;
|
|
41
|
-
stdout?: NodeJS.WriteStream;
|
|
42
|
-
stderr?: NodeJS.WriteStream;
|
|
43
|
-
allowedTargets?: Set<string>;
|
|
44
|
-
preselectedTarget?: LaunchTarget;
|
|
45
|
-
resolvePreview(target: LaunchTarget): Promise<LaunchPreviewData>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const launchSelectorDeps = {
|
|
49
|
-
render: inkRender,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const TARGET_ITEMS: SelectMenuItem[] = [
|
|
53
|
-
{ key: "claude", label: "claude" },
|
|
54
|
-
{ key: "opencode", label: "opencode" },
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
function parseLaunchSelectorInput(
|
|
58
|
-
input: string,
|
|
59
|
-
): "submit" | "cancel" | "up" | "down" | "back" | null {
|
|
60
|
-
if (input === "\r" || input === "\n") return "submit";
|
|
61
|
-
if (input === "\x1b[A" || input === "[A") return "up";
|
|
62
|
-
if (input === "\x1b[B" || input === "[B") return "down";
|
|
63
|
-
if (input === "\x7f" || input === "\b") return "back";
|
|
64
|
-
if (input === "\x1b" || input.toLowerCase() === "q") return "cancel";
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function ReviewPanel({
|
|
69
|
-
previewState,
|
|
70
|
-
selectedTarget,
|
|
71
|
-
}: {
|
|
72
|
-
previewState: PreviewState;
|
|
73
|
-
selectedTarget: LaunchTarget | null;
|
|
74
|
-
}) {
|
|
75
|
-
if (previewState.status === "loading" || previewState.status === "idle") {
|
|
76
|
-
return <Text dimColor>Resolving launch preview…</Text>;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (previewState.status === "error") {
|
|
80
|
-
return <Text color="red">{previewState.message}</Text>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<Box flexDirection="column">
|
|
85
|
-
<Text>Target: {selectedTarget}</Text>
|
|
86
|
-
<Text>Repository: {previewState.data.repositoryId}</Text>
|
|
87
|
-
<Text dimColor>{previewState.data.repositoryPath}</Text>
|
|
88
|
-
<Text>Companion:</Text>
|
|
89
|
-
<Text dimColor>{previewState.data.companionPath}</Text>
|
|
90
|
-
</Box>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function LaunchWizardApp({
|
|
95
|
-
activeIndex,
|
|
96
|
-
hasPreselectedTarget,
|
|
97
|
-
items,
|
|
98
|
-
previewState,
|
|
99
|
-
selectedTarget,
|
|
100
|
-
step,
|
|
101
|
-
totalSteps,
|
|
102
|
-
onMounted,
|
|
103
|
-
}: LaunchWizardAppProps): React.JSX.Element {
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
onMounted?.();
|
|
106
|
-
}, [onMounted]);
|
|
107
|
-
|
|
108
|
-
const stepNumber = hasPreselectedTarget ? 0 : step === "target" ? 0 : totalSteps - 1;
|
|
109
|
-
const title = step === "target" ? "Select launch target" : "Review launch";
|
|
110
|
-
const hint =
|
|
111
|
-
step === "target"
|
|
112
|
-
? "↑↓ navigate · enter continue · q cancel"
|
|
113
|
-
: previewState.status === "error"
|
|
114
|
-
? "enter retry · backspace previous · q cancel"
|
|
115
|
-
: previewState.status === "ready"
|
|
116
|
-
? "enter launch · backspace previous · q cancel"
|
|
117
|
-
: "backspace previous · q cancel";
|
|
118
|
-
|
|
119
|
-
return (
|
|
120
|
-
<Box flexDirection="column" width="100%">
|
|
121
|
-
<WizardHeader title={title} step={stepNumber} totalSteps={totalSteps} />
|
|
122
|
-
<Box marginTop={1} flexDirection="column">
|
|
123
|
-
{step === "target" ? <SelectMenu items={items} activeIndex={activeIndex} /> : null}
|
|
124
|
-
{step === "review" ? (
|
|
125
|
-
<ReviewPanel previewState={previewState} selectedTarget={selectedTarget} />
|
|
126
|
-
) : null}
|
|
127
|
-
</Box>
|
|
128
|
-
<WizardFooter hint={hint} />
|
|
129
|
-
</Box>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export async function runLaunchWizard(
|
|
134
|
-
options: LaunchWizardOptions,
|
|
135
|
-
): Promise<LaunchWizardResult | null> {
|
|
136
|
-
const stdin = options.stdin ?? process.stdin;
|
|
137
|
-
const stdout = options.stdout ?? process.stdout;
|
|
138
|
-
const stderr = options.stderr ?? process.stderr;
|
|
139
|
-
if (!stdin.isTTY || !stdout.isTTY) {
|
|
140
|
-
throw new Error("Launch wizard requires a TTY.");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const allowedTargets = options.allowedTargets;
|
|
144
|
-
const preselectedTarget = options.preselectedTarget ?? null;
|
|
145
|
-
const items: SelectMenuItem[] = TARGET_ITEMS.map((item) => ({
|
|
146
|
-
...item,
|
|
147
|
-
dimColor: allowedTargets ? !allowedTargets.has(item.key) : false,
|
|
148
|
-
}));
|
|
149
|
-
const firstEnabledIndex = items.findIndex((item) => !item.dimColor);
|
|
150
|
-
let activeIndex = preselectedTarget ? 0 : firstEnabledIndex < 0 ? 0 : firstEnabledIndex;
|
|
151
|
-
let step: WizardStep = preselectedTarget ? "review" : "target";
|
|
152
|
-
let selectedTarget: LaunchTarget | null = preselectedTarget;
|
|
153
|
-
let previewState: PreviewState = { status: "idle" };
|
|
154
|
-
let previewRequestId = 0;
|
|
155
|
-
let finalResult: LaunchWizardResult | null = null;
|
|
156
|
-
let resolved = false;
|
|
157
|
-
let rendered: ReturnType<typeof launchSelectorDeps.render> | undefined;
|
|
158
|
-
|
|
159
|
-
const totalSteps = preselectedTarget ? 1 : 2;
|
|
160
|
-
|
|
161
|
-
stdin.resume();
|
|
162
|
-
(stdin as NodeJS.ReadStream & { ref?: () => void }).ref?.();
|
|
163
|
-
const keepAlive = setInterval(() => {}, 1 << 30);
|
|
164
|
-
|
|
165
|
-
const cleanup = () => {
|
|
166
|
-
clearInterval(keepAlive);
|
|
167
|
-
stdin.off("data", onData);
|
|
168
|
-
stdin.pause();
|
|
169
|
-
stdin.setRawMode?.(false);
|
|
170
|
-
(stdin as NodeJS.ReadStream & { unref?: () => void }).unref?.();
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const currentTarget = () => LAUNCH_TARGETS[activeIndex];
|
|
174
|
-
const isEnabled = (index: number) => !items[index]?.dimColor;
|
|
175
|
-
const nextEnabledIndex = (start: number, direction: 1 | -1): number => {
|
|
176
|
-
if (!items.some((item) => !item.dimColor)) return start;
|
|
177
|
-
let index = start;
|
|
178
|
-
for (let i = 0; i < items.length; i += 1) {
|
|
179
|
-
index = (index + direction + items.length) % items.length;
|
|
180
|
-
if (isEnabled(index)) return index;
|
|
181
|
-
}
|
|
182
|
-
return start;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const rerender = () => {
|
|
186
|
-
rendered?.rerender(
|
|
187
|
-
<LaunchWizardApp
|
|
188
|
-
activeIndex={activeIndex}
|
|
189
|
-
hasPreselectedTarget={!!preselectedTarget}
|
|
190
|
-
items={items}
|
|
191
|
-
previewState={previewState}
|
|
192
|
-
selectedTarget={selectedTarget}
|
|
193
|
-
step={step}
|
|
194
|
-
totalSteps={totalSteps}
|
|
195
|
-
onMounted={onMounted}
|
|
196
|
-
/>,
|
|
197
|
-
);
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
const settle = (
|
|
201
|
-
value: LaunchWizardResult | null,
|
|
202
|
-
resolve: (value: LaunchWizardResult | null) => void,
|
|
203
|
-
) => {
|
|
204
|
-
if (resolved) return;
|
|
205
|
-
resolved = true;
|
|
206
|
-
cleanup();
|
|
207
|
-
resolve(value);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const loadPreview = () => {
|
|
211
|
-
if (!selectedTarget) return;
|
|
212
|
-
const requestId = ++previewRequestId;
|
|
213
|
-
previewState = { status: "loading" };
|
|
214
|
-
rerender();
|
|
215
|
-
|
|
216
|
-
options
|
|
217
|
-
.resolvePreview(selectedTarget)
|
|
218
|
-
.then((preview) => {
|
|
219
|
-
if (requestId !== previewRequestId || resolved) return;
|
|
220
|
-
previewState = { status: "ready", data: preview };
|
|
221
|
-
rerender();
|
|
222
|
-
})
|
|
223
|
-
.catch((error: unknown) => {
|
|
224
|
-
if (requestId !== previewRequestId || resolved) return;
|
|
225
|
-
previewState = {
|
|
226
|
-
status: "error",
|
|
227
|
-
message: error instanceof Error ? error.message : String(error),
|
|
228
|
-
};
|
|
229
|
-
rerender();
|
|
230
|
-
});
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const enterReview = () => {
|
|
234
|
-
step = "review";
|
|
235
|
-
loadPreview();
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
const onMounted = () => {
|
|
239
|
-
const buffered = stdin.read();
|
|
240
|
-
if (buffered) {
|
|
241
|
-
// Discard buffered input before the wizard becomes interactive.
|
|
242
|
-
}
|
|
243
|
-
stdin.on("data", onData);
|
|
244
|
-
if (step === "review") {
|
|
245
|
-
loadPreview();
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const onData = (chunk: string) => {
|
|
250
|
-
const action = parseLaunchSelectorInput(chunk);
|
|
251
|
-
if (!action) return;
|
|
252
|
-
if (action === "cancel") {
|
|
253
|
-
finalResult = null;
|
|
254
|
-
rendered?.unmount();
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (action === "back") {
|
|
258
|
-
if (step === "review") {
|
|
259
|
-
if (preselectedTarget) {
|
|
260
|
-
finalResult = null;
|
|
261
|
-
rendered?.unmount();
|
|
262
|
-
} else {
|
|
263
|
-
step = "target";
|
|
264
|
-
previewState = { status: "idle" };
|
|
265
|
-
rerender();
|
|
266
|
-
}
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
if (step === "review") {
|
|
272
|
-
if (action !== "submit") return;
|
|
273
|
-
if (previewState.status === "error") {
|
|
274
|
-
loadPreview();
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
if (previewState.status !== "ready" || !selectedTarget) return;
|
|
278
|
-
finalResult = {
|
|
279
|
-
target: selectedTarget,
|
|
280
|
-
preview: previewState.data,
|
|
281
|
-
};
|
|
282
|
-
rendered?.unmount();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
if (action === "up" || action === "down") {
|
|
286
|
-
activeIndex = nextEnabledIndex(activeIndex, action === "up" ? -1 : 1);
|
|
287
|
-
rerender();
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
if (action !== "submit") return;
|
|
291
|
-
if (!isEnabled(activeIndex)) return;
|
|
292
|
-
selectedTarget = currentTarget();
|
|
293
|
-
enterReview();
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
stdin.setEncoding("utf8");
|
|
297
|
-
stdin.setRawMode?.(true);
|
|
298
|
-
|
|
299
|
-
return new Promise<LaunchWizardResult | null>((resolve) => {
|
|
300
|
-
rendered = launchSelectorDeps.render(
|
|
301
|
-
<LaunchWizardApp
|
|
302
|
-
activeIndex={activeIndex}
|
|
303
|
-
hasPreselectedTarget={!!preselectedTarget}
|
|
304
|
-
items={items}
|
|
305
|
-
previewState={previewState}
|
|
306
|
-
selectedTarget={selectedTarget}
|
|
307
|
-
step={step}
|
|
308
|
-
totalSteps={totalSteps}
|
|
309
|
-
onMounted={onMounted}
|
|
310
|
-
/>,
|
|
311
|
-
{ stdin, stdout, stderr },
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
const finish = (value: LaunchWizardResult | null) => settle(value, resolve);
|
|
315
|
-
|
|
316
|
-
rendered.waitUntilExit().then(() => {
|
|
317
|
-
finish(finalResult);
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
}
|