@uniqbit/mate-core 0.15.5-canary.0 → 0.15.5-canary.1
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/package.json +1 -1
- package/src/cli/commands/artifact/finish/openspec.ts +180 -12
- package/src/cli/commands/companion/link.ts +9 -13
- package/src/cli/commands/working/cleanup.ts +125 -0
- package/src/cli/commands/working/working.ts +15 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/materialize.ts +46 -0
- package/src/cli/commands/workspace/workspace.ts +22 -0
- package/src/cli/main.ts +20 -1
- package/src/cli/usage.ts +8 -0
- package/src/cli/write-json-stdout.ts +14 -0
- package/src/lib/orchestrator/companion-registry-reader.ts +37 -0
- package/src/lib/orchestrator/{working-repo-store.ts → companion-registry-store.ts} +14 -7
- package/src/lib/orchestrator/companion-resolver.ts +50 -2
- package/src/lib/orchestrator/companion-store.ts +39 -10
- package/src/lib/orchestrator/editor.ts +36 -14
- package/src/lib/orchestrator/framework-context.ts +33 -3
- package/src/lib/orchestrator/global-config-store.ts +25 -0
- package/src/lib/orchestrator/repo-local-registry.ts +2 -22
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +149 -0
- package/src/lib/orchestrator/workspace-materialize.ts +80 -0
- package/src/lib/orchestrator/yaml-file-store.ts +10 -1
- package/src/templates/root/TEMPLATE_AGENTS.md +0 -8
- package/src/templates/root/TEMPLATE_CLAUDE.md +0 -10
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +44 -380
- package/src/tools/setup/plugins/gitignore.ts +3 -0
- package/src/tools/setup/providers/claude.ts +68 -56
- package/src/tools/setup/working-repo-cleanup.ts +40 -0
- package/src/tools/setup/working-repo-local-state.ts +103 -0
package/package.json
CHANGED
|
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
import { parse } from "yaml";
|
|
6
|
+
|
|
5
7
|
import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
|
|
6
8
|
import { runIndexCapCommand } from "../../cap/index-cmd";
|
|
7
9
|
import type { ArtifactFinisher, FinishContext } from "./finisher";
|
|
@@ -57,6 +59,171 @@ async function commitPathsForArchive(
|
|
|
57
59
|
];
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
const BOM = "";
|
|
63
|
+
|
|
64
|
+
interface ScopePair {
|
|
65
|
+
repository: string;
|
|
66
|
+
area: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type ProjectionResult =
|
|
70
|
+
| { ok: true; repository: string; areas: string[] }
|
|
71
|
+
| { ok: false; reason: string };
|
|
72
|
+
|
|
73
|
+
function parseDeltaScopes(source: string): ScopePair[] {
|
|
74
|
+
const text = source.startsWith(BOM) ? source.slice(BOM.length) : source;
|
|
75
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
76
|
+
if (!match) return [];
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const parsed = parse(match[1]) as unknown;
|
|
80
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
81
|
+
const scopes = (parsed as Record<string, unknown>).scopes;
|
|
82
|
+
if (!Array.isArray(scopes)) return [];
|
|
83
|
+
return scopes.flatMap((entry) => {
|
|
84
|
+
if (!entry || typeof entry !== "object") return [];
|
|
85
|
+
const pair = entry as Record<string, unknown>;
|
|
86
|
+
return typeof pair.repository === "string" && typeof pair.area === "string"
|
|
87
|
+
? [{ repository: pair.repository, area: pair.area }]
|
|
88
|
+
: [];
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function projectDeltaScopes(scopes: ScopePair[]): ProjectionResult {
|
|
96
|
+
if (scopes.length === 0) return { ok: false, reason: "delta declares no scopes entries" };
|
|
97
|
+
const repositories = [...new Set(scopes.map((entry) => entry.repository))];
|
|
98
|
+
if (repositories.length > 1) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: `delta names ${repositories.length} repositories (${repositories.join(", ")}); a spec names exactly one`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
repository: repositories[0],
|
|
107
|
+
areas: [...new Set(scopes.map((entry) => entry.area))],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function archivedChangeUsesMateV1(
|
|
112
|
+
companionPath: string,
|
|
113
|
+
anchorName: string,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
const metadataPath = path.join(
|
|
117
|
+
companionPath,
|
|
118
|
+
"openspec",
|
|
119
|
+
"changes",
|
|
120
|
+
"archive",
|
|
121
|
+
anchorName,
|
|
122
|
+
".openspec.yaml",
|
|
123
|
+
);
|
|
124
|
+
const parsed = parse(await fs.readFile(metadataPath, "utf8")) as unknown;
|
|
125
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
126
|
+
const schema = (parsed as Record<string, unknown>).schema;
|
|
127
|
+
return typeof schema === "string" && schema.trim() === "mate-v1";
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Scope keys are omitted when the delta had none — a partial block beats no frontmatter. */
|
|
134
|
+
function renderCanonicalFrontmatter(
|
|
135
|
+
capability: string,
|
|
136
|
+
scope: { repository: string; areas: string[] } | null,
|
|
137
|
+
): string {
|
|
138
|
+
return [
|
|
139
|
+
"---",
|
|
140
|
+
"type: spec",
|
|
141
|
+
`capability: ${capability}`,
|
|
142
|
+
...(scope ? [`repository: ${scope.repository}`, `areas: [${scope.areas.join(", ")}]`] : []),
|
|
143
|
+
"tags: [openspec/spec]",
|
|
144
|
+
"---",
|
|
145
|
+
"",
|
|
146
|
+
"",
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Prepends canonical frontmatter to main specs born during this archive.
|
|
152
|
+
*
|
|
153
|
+
* `openspec archive` rebuilds a brand-new main spec from a skeleton with no frontmatter slot,
|
|
154
|
+
* so scope metadata dies exactly once, at spec birth; existing specs keep theirs because only
|
|
155
|
+
* requirement blocks are spliced. Best-effort by design — the archive already succeeded, so a
|
|
156
|
+
* failure here warns rather than stranding the change archived-but-unfinished.
|
|
157
|
+
*/
|
|
158
|
+
async function reconcileMainSpecFrontmatter(
|
|
159
|
+
companionPath: string,
|
|
160
|
+
anchorName: string,
|
|
161
|
+
): Promise<string[]> {
|
|
162
|
+
if (!(await archivedChangeUsesMateV1(companionPath, anchorName))) return [];
|
|
163
|
+
|
|
164
|
+
const deltaSpecsDir = path.join(
|
|
165
|
+
companionPath,
|
|
166
|
+
"openspec",
|
|
167
|
+
"changes",
|
|
168
|
+
"archive",
|
|
169
|
+
anchorName,
|
|
170
|
+
"specs",
|
|
171
|
+
);
|
|
172
|
+
const reconciled: string[] = [];
|
|
173
|
+
|
|
174
|
+
const walk = async (directory: string, relative = ""): Promise<void> => {
|
|
175
|
+
let entries;
|
|
176
|
+
try {
|
|
177
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
const entryRelative = path.posix.join(relative, entry.name);
|
|
183
|
+
if (entry.isDirectory()) {
|
|
184
|
+
await walk(path.join(directory, entry.name), entryRelative);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!entry.isFile() || entry.name !== "spec.md") continue;
|
|
188
|
+
|
|
189
|
+
const capability = path.posix.dirname(entryRelative);
|
|
190
|
+
if (capability === ".") continue;
|
|
191
|
+
const canonicalRelative = path.posix.join("openspec", "specs", entryRelative);
|
|
192
|
+
const canonicalPath = path.join(companionPath, canonicalRelative);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const canonical = await fs.readFile(canonicalPath, "utf8");
|
|
196
|
+
if (canonical.replace(BOM, "").startsWith("---")) continue;
|
|
197
|
+
|
|
198
|
+
const deltaSource = await fs.readFile(path.join(directory, entry.name), "utf8");
|
|
199
|
+
const scopes = parseDeltaScopes(deltaSource);
|
|
200
|
+
const projected = projectDeltaScopes(scopes);
|
|
201
|
+
|
|
202
|
+
/** Multi-repository is invalid input, so skip; absent scopes still earn a partial block. */
|
|
203
|
+
if (!projected.ok && scopes.length > 0) {
|
|
204
|
+
process.stderr.write(
|
|
205
|
+
`mate: skipped frontmatter for ${canonicalRelative}: ${projected.reason}\n`,
|
|
206
|
+
);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const head = renderCanonicalFrontmatter(
|
|
210
|
+
capability,
|
|
211
|
+
projected.ok ? { repository: projected.repository, areas: projected.areas } : null,
|
|
212
|
+
);
|
|
213
|
+
await fs.writeFile(canonicalPath, head + canonical.replace(BOM, ""), "utf8");
|
|
214
|
+
reconciled.push(canonicalRelative);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
process.stderr.write(
|
|
217
|
+
`mate: could not reconcile frontmatter for ${canonicalRelative}: ${String(error)}\n`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await walk(deltaSpecsDir);
|
|
224
|
+
return reconciled;
|
|
225
|
+
}
|
|
226
|
+
|
|
60
227
|
async function capSync(context: FinishContext): Promise<boolean> {
|
|
61
228
|
const previous = process.exitCode;
|
|
62
229
|
process.exitCode = 0;
|
|
@@ -92,7 +259,10 @@ async function capSync(context: FinishContext): Promise<boolean> {
|
|
|
92
259
|
* to `openspec/`. Resumable detection reads the dated `openspec/changes/archive/` folder
|
|
93
260
|
* openspec actually created so the tag is never a computed date.
|
|
94
261
|
*/
|
|
95
|
-
export function openspecFinisher(
|
|
262
|
+
export function openspecFinisher(
|
|
263
|
+
contextOrPath: FinishContext | string,
|
|
264
|
+
runCommand: typeof run = run,
|
|
265
|
+
): ArtifactFinisher {
|
|
96
266
|
const context: FinishContext =
|
|
97
267
|
typeof contextOrPath === "string"
|
|
98
268
|
? { companionPath: contextOrPath, repositoryId: "" }
|
|
@@ -106,7 +276,7 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
|
|
|
106
276
|
return hasOpenspecCapability(capabilities);
|
|
107
277
|
},
|
|
108
278
|
async validate(name) {
|
|
109
|
-
const res =
|
|
279
|
+
const res = runCommand(companionPath, ["openspec", "validate", name, "--json"]);
|
|
110
280
|
try {
|
|
111
281
|
const parsed = JSON.parse(res.stdout) as {
|
|
112
282
|
items?: Array<{ id: string; valid: boolean; issues?: Array<{ message: string }> }>;
|
|
@@ -124,7 +294,7 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
|
|
|
124
294
|
}
|
|
125
295
|
},
|
|
126
296
|
async isComplete(name) {
|
|
127
|
-
const res =
|
|
297
|
+
const res = runCommand(companionPath, ["openspec", "list", "--json"]);
|
|
128
298
|
try {
|
|
129
299
|
const parsed = JSON.parse(res.stdout) as {
|
|
130
300
|
changes?: Array<{ name: string; completedTasks: number; totalTasks: number }>;
|
|
@@ -157,13 +327,12 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
|
|
|
157
327
|
}
|
|
158
328
|
// Latest dated folder wins if the same change name was ever archived twice.
|
|
159
329
|
const anchorName = matches[matches.length - 1];
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
};
|
|
330
|
+
const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
|
|
331
|
+
await reconcileMainSpecFrontmatter(companionPath, anchorName);
|
|
332
|
+
return { anchorName, commitPaths };
|
|
164
333
|
},
|
|
165
334
|
async produce(name) {
|
|
166
|
-
const res =
|
|
335
|
+
const res = runCommand(companionPath, ["openspec", "archive", name, "--yes"]);
|
|
167
336
|
if (res.status !== 0) {
|
|
168
337
|
return {
|
|
169
338
|
ok: false,
|
|
@@ -176,12 +345,11 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
|
|
|
176
345
|
if (!match) {
|
|
177
346
|
return { ok: false, produced: null, message: "could not detect archived folder name" };
|
|
178
347
|
}
|
|
348
|
+
const commitPaths = await commitPathsForArchive(companionPath, name, match[1]);
|
|
349
|
+
await reconcileMainSpecFrontmatter(companionPath, match[1]);
|
|
179
350
|
return {
|
|
180
351
|
ok: true,
|
|
181
|
-
produced: {
|
|
182
|
-
anchorName: match[1],
|
|
183
|
-
commitPaths: await commitPathsForArchive(companionPath, name, match[1]),
|
|
184
|
-
},
|
|
352
|
+
produced: { anchorName: match[1], commitPaths },
|
|
185
353
|
message: res.stdout.trim(),
|
|
186
354
|
};
|
|
187
355
|
},
|
|
@@ -12,11 +12,9 @@ import {
|
|
|
12
12
|
import { extractRepoName } from "../artifact/extract-repo-name";
|
|
13
13
|
import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
|
|
14
14
|
import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
|
|
15
|
+
import { companionRootedStore } from "../../../lib/orchestrator/companion-store";
|
|
15
16
|
import { inspectSetupPreflight } from "../../../lib/orchestrator/setup-preflight";
|
|
16
|
-
import {
|
|
17
|
-
findDescendantRepoLocalRegistries,
|
|
18
|
-
writeRepoLocalRegistryEntry,
|
|
19
|
-
} from "../../../lib/orchestrator/repo-local-registry";
|
|
17
|
+
import { findDescendantRepoLocalRegistries } from "../../../lib/orchestrator/repo-local-registry";
|
|
20
18
|
import { runSetupFlowAtPath } from "../setup";
|
|
21
19
|
import { runInstallCommand } from "../install";
|
|
22
20
|
import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
|
|
@@ -240,17 +238,15 @@ export async function runCompanionLinkCommandWithDeps(
|
|
|
240
238
|
path: cwd,
|
|
241
239
|
};
|
|
242
240
|
|
|
241
|
+
// Writes both the companion-side registry entry (companionRootedStore, the
|
|
242
|
+
// source of truth for `mate workspace list`) and the repo-local pointer
|
|
243
|
+
// (CompanionStore's internal dual write) in one call.
|
|
243
244
|
const registerRepository =
|
|
244
245
|
deps.registerRepository ??
|
|
245
|
-
(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
nextRepository,
|
|
250
|
-
options.companionSource,
|
|
251
|
-
);
|
|
252
|
-
return nextRepository;
|
|
253
|
-
});
|
|
246
|
+
((nextRepository, options) =>
|
|
247
|
+
companionRootedStore(options.companionPath).registerRepository(nextRepository, {
|
|
248
|
+
companionSource: options.companionSource,
|
|
249
|
+
}));
|
|
254
250
|
|
|
255
251
|
// Managed companions are git-backed; explicitly pasted paths retain local provenance.
|
|
256
252
|
const persistedSource: CompanionSource = companionSource === "local" ? "local" : "git";
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
|
|
8
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
9
|
+
import type { RootKind } from "../../../lib/orchestrator/root-context";
|
|
10
|
+
import {
|
|
11
|
+
collectWorkspaceInventory,
|
|
12
|
+
type WorkspaceInventoryV1,
|
|
13
|
+
} from "../../../lib/orchestrator/workspace-inventory";
|
|
14
|
+
import {
|
|
15
|
+
cleanupWorkingRepository,
|
|
16
|
+
type WorkingRepoCleanupResult,
|
|
17
|
+
} from "../../../tools/setup/working-repo-cleanup";
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
async function canonicalPath(candidatePath: string): Promise<string> {
|
|
22
|
+
try {
|
|
23
|
+
return await fs.realpath(candidatePath);
|
|
24
|
+
} catch {
|
|
25
|
+
return path.resolve(candidatePath);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorkingCleanupCommandDeps {
|
|
30
|
+
cwd: string;
|
|
31
|
+
resolveGitRoot: (cwd: string) => Promise<string | null>;
|
|
32
|
+
localRootKind: (repoPath: string) => Promise<RootKind | null>;
|
|
33
|
+
collectInventory: () => Promise<WorkspaceInventoryV1>;
|
|
34
|
+
cleanup: (
|
|
35
|
+
repoPath: string,
|
|
36
|
+
registeredCompanionPaths: string[],
|
|
37
|
+
) => Promise<WorkingRepoCleanupResult>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function resolveGitRoot(cwd: string): Promise<string | null> {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
43
|
+
return canonicalPath(stdout.trim());
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function localRootKind(repoPath: string): Promise<RootKind | null> {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(
|
|
52
|
+
path.join(repoPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
|
|
53
|
+
"utf8",
|
|
54
|
+
);
|
|
55
|
+
const config = parse(raw) as { type?: string } | null;
|
|
56
|
+
if (config?.type === "working" || config?.type === "hub") return config.type;
|
|
57
|
+
return "companion";
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
60
|
+
return "companion";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultDeps: WorkingCleanupCommandDeps = {
|
|
65
|
+
cwd: process.cwd(),
|
|
66
|
+
resolveGitRoot,
|
|
67
|
+
localRootKind,
|
|
68
|
+
collectInventory: collectWorkspaceInventory,
|
|
69
|
+
cleanup: cleanupWorkingRepository,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export async function runWorkingCleanupCommand(
|
|
73
|
+
argv: string[],
|
|
74
|
+
deps: WorkingCleanupCommandDeps = defaultDeps,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (argv.length > 0) {
|
|
77
|
+
process.stderr.write(`${FRAMEWORK_NAME}: usage: ${FRAMEWORK_NAME} working cleanup\n`);
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const repoPath = await deps.resolveGitRoot(deps.cwd);
|
|
82
|
+
if (!repoPath) {
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
`${FRAMEWORK_NAME}: working cleanup must run from a linked Git working repository\n`,
|
|
85
|
+
);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const kind = await deps.localRootKind(repoPath);
|
|
90
|
+
if (kind === "companion" || kind === "hub") {
|
|
91
|
+
process.stderr.write(
|
|
92
|
+
`${FRAMEWORK_NAME}: working cleanup is unavailable in a ${kind} repository\n`,
|
|
93
|
+
);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const inventory = await deps.collectInventory();
|
|
98
|
+
const resolvedPairings = await Promise.all(
|
|
99
|
+
inventory.pairings.map(async (pairing) => ({
|
|
100
|
+
pairing,
|
|
101
|
+
repositoryPath: await canonicalPath(pairing.repository.path),
|
|
102
|
+
})),
|
|
103
|
+
);
|
|
104
|
+
const matches = resolvedPairings
|
|
105
|
+
.filter(({ repositoryPath }) => repositoryPath === repoPath)
|
|
106
|
+
.map(({ pairing }) => pairing);
|
|
107
|
+
if (matches.length === 0) {
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
`${FRAMEWORK_NAME}: working cleanup must run from a linked Git working repository\n`,
|
|
110
|
+
);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const companionPaths = [...new Set(matches.map((pairing) => pairing.companionPath))];
|
|
115
|
+
const result = await deps.cleanup(repoPath, companionPaths);
|
|
116
|
+
if (!result.changed) {
|
|
117
|
+
console.log(`${FRAMEWORK_NAME}: working repository already clean`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const details = [
|
|
121
|
+
...(result.removed.length > 0 ? [`removed: ${result.removed.join(", ")}`] : []),
|
|
122
|
+
...(result.updated.length > 0 ? [`updated: ${result.updated.join(", ")}`] : []),
|
|
123
|
+
];
|
|
124
|
+
console.log(`${FRAMEWORK_NAME}: cleaned working repository (${details.join("; ")})`);
|
|
125
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { usage } from "../../usage";
|
|
2
|
+
import { runWorkingCleanupCommand } from "./cleanup";
|
|
3
|
+
|
|
4
|
+
export async function runWorkingCommand(
|
|
5
|
+
subcommand: string | undefined,
|
|
6
|
+
argv: string[],
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
if (subcommand === "cleanup") {
|
|
9
|
+
await runWorkingCleanupCommand(argv);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.error(`Unknown working command: ${subcommand ?? ""}`);
|
|
13
|
+
console.error(usage());
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { collectWorkspaceInventory } from "../../../lib/orchestrator/workspace-inventory";
|
|
2
|
+
import { writeJsonStdout } from "../../write-json-stdout";
|
|
3
|
+
|
|
4
|
+
export const workspaceListCommandDeps = {
|
|
5
|
+
collectWorkspaceInventory: () => collectWorkspaceInventory(),
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @command mate workspace list
|
|
10
|
+
* @description Prints the aggregate inventory of registered companions and
|
|
11
|
+
* their linked working-repository pairings as one JSON document. Runs
|
|
12
|
+
* without a linked working repository or any active companion context.
|
|
13
|
+
* @flags
|
|
14
|
+
* - `--json` — required; this command only supports JSON output.
|
|
15
|
+
*/
|
|
16
|
+
export async function runWorkspaceListCommand(argv: string[] = []): Promise<void> {
|
|
17
|
+
if (!argv.includes("--json")) {
|
|
18
|
+
process.stderr.write("mate: `workspace list` requires --json.\n");
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const inventory = await workspaceListCommandDeps.collectWorkspaceInventory();
|
|
24
|
+
await writeJsonStdout(inventory);
|
|
25
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { materializeWorkspace } from "../../../lib/orchestrator/workspace-materialize";
|
|
2
|
+
import { writeJsonStdout } from "../../write-json-stdout";
|
|
3
|
+
|
|
4
|
+
export const workspaceMaterializeCommandDeps = {
|
|
5
|
+
materializeWorkspace: (repositoryId: string, companionPath: string) =>
|
|
6
|
+
materializeWorkspace({ repositoryId, companionPath }),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function readFlag(argv: string[], name: string): string | undefined {
|
|
10
|
+
const index = argv.indexOf(name);
|
|
11
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @command mate workspace materialize
|
|
16
|
+
* @description Validates an explicit repository/companion pairing, writes
|
|
17
|
+
* the generated `.mate/workspace.code-workspace` file, and prints its path
|
|
18
|
+
* and folder order as one JSON document. Never spawns or focuses an editor.
|
|
19
|
+
* @flags
|
|
20
|
+
* - `--repository <id>` — required; the linked repository id to pair.
|
|
21
|
+
* - `--companion <path>` — required; the companion path that must link it.
|
|
22
|
+
* - `--json` — required; this command only supports JSON output.
|
|
23
|
+
*/
|
|
24
|
+
export async function runWorkspaceMaterializeCommand(argv: string[] = []): Promise<void> {
|
|
25
|
+
const repositoryId = readFlag(argv, "--repository");
|
|
26
|
+
const companionPath = readFlag(argv, "--companion");
|
|
27
|
+
|
|
28
|
+
if (!repositoryId || !companionPath || !argv.includes("--json")) {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
"mate: `workspace materialize` requires --repository <id>, --companion <path>, and --json.\n",
|
|
31
|
+
);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const result = await workspaceMaterializeCommandDeps.materializeWorkspace(
|
|
38
|
+
repositoryId,
|
|
39
|
+
companionPath,
|
|
40
|
+
);
|
|
41
|
+
await writeJsonStdout(result);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { usage } from "../../usage";
|
|
2
|
+
import { runWorkspaceListCommand } from "./list";
|
|
3
|
+
import { runWorkspaceMaterializeCommand } from "./materialize";
|
|
4
|
+
|
|
5
|
+
/** Dispatches `mate workspace <subcommand>`. Distinct from `mate companion open`. */
|
|
6
|
+
export async function runWorkspaceCommand(
|
|
7
|
+
subcommand: string | undefined,
|
|
8
|
+
argv: string[],
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
switch (subcommand) {
|
|
11
|
+
case "list":
|
|
12
|
+
await runWorkspaceListCommand(argv);
|
|
13
|
+
return;
|
|
14
|
+
case "materialize":
|
|
15
|
+
await runWorkspaceMaterializeCommand(argv);
|
|
16
|
+
return;
|
|
17
|
+
default:
|
|
18
|
+
console.error(`Unknown workspace command: ${subcommand ?? ""}`);
|
|
19
|
+
console.error(usage());
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { runLaunchOpenCodeCommand } from "./commands/launch/opencode";
|
|
|
17
17
|
import { runPluginCommand } from "./commands/plugin/plugin";
|
|
18
18
|
import { runReportCommand } from "./commands/report";
|
|
19
19
|
import { runUpdateCommand } from "./commands/update";
|
|
20
|
+
import { runWorkspaceCommand } from "./commands/workspace/workspace";
|
|
21
|
+
import { runWorkingCommand } from "./commands/working/working";
|
|
20
22
|
import { runInstallCommand } from "./commands/install";
|
|
21
23
|
import { inspectInstallPreflight } from "../lib/install";
|
|
22
24
|
import { resolveRootContext } from "../lib/orchestrator/root-context";
|
|
@@ -78,12 +80,19 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
78
80
|
const isPluginCommand =
|
|
79
81
|
command === "cap" && findPluginCliCommand(subcommand, rest[0]) !== undefined;
|
|
80
82
|
|
|
83
|
+
// `workspace list`/`workspace materialize` are machine-JSON contracts an
|
|
84
|
+
// editor extension may poll frequently; their stdout must be pure JSON,
|
|
85
|
+
// and the un-awaited scheduleBackgroundCheck() has been observed to race
|
|
86
|
+
// a large final stdout write against process exit, truncating it. Both
|
|
87
|
+
// reasons put them in the same stdout-owning bucket as plugin commands.
|
|
88
|
+
const ownsStdout = isPluginCommand || command === "workspace";
|
|
89
|
+
|
|
81
90
|
// Every dispatch case opens with a gate() call declaring what the command
|
|
82
91
|
// needs before it may run. Declared gates run in fixed order — update
|
|
83
92
|
// enforcement, companion selection, install preflight — and a blocked gate
|
|
84
93
|
// sets the exit code, so callers only need `if (!(await gate(...))) return`.
|
|
85
94
|
const gate = async (needs: GateNeeds): Promise<boolean> => {
|
|
86
|
-
if (!
|
|
95
|
+
if (!ownsStdout) {
|
|
87
96
|
const updateStore = new UpdateStateStore();
|
|
88
97
|
scheduleBackgroundCheck(updateStore);
|
|
89
98
|
if (needs.updateGuard && (await enforceUpdateIfRequired(updateStore))) {
|
|
@@ -206,6 +215,16 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
206
215
|
if (!(await gate({}))) return;
|
|
207
216
|
await runUpdateCommand(argv.slice(3));
|
|
208
217
|
return;
|
|
218
|
+
case "workspace":
|
|
219
|
+
// list/materialize are context-independent: no companion, install, or
|
|
220
|
+
// update gating — an editor extension may call these frequently.
|
|
221
|
+
if (!(await gate({}))) return;
|
|
222
|
+
await runWorkspaceCommand(subcommand, rest);
|
|
223
|
+
return;
|
|
224
|
+
case "working":
|
|
225
|
+
if (!(await gate({}))) return;
|
|
226
|
+
await runWorkingCommand(subcommand, rest);
|
|
227
|
+
return;
|
|
209
228
|
default:
|
|
210
229
|
// Unknown commands fail fast: no case matched, so no gate ever ran.
|
|
211
230
|
console.error(`Unknown command: ${command}`);
|
package/src/cli/usage.ts
CHANGED
|
@@ -16,6 +16,9 @@ export function usage(): string {
|
|
|
16
16
|
` ${n} companion list`,
|
|
17
17
|
` ${n} companion open`,
|
|
18
18
|
` ${n} companion tui`,
|
|
19
|
+
` ${n} workspace list --json`,
|
|
20
|
+
` ${n} workspace materialize --repository ID --companion PATH --json`,
|
|
21
|
+
` ${n} working cleanup`,
|
|
19
22
|
` ${n} hub init [folder]`,
|
|
20
23
|
` ${n} hub add [source] [--id ID] [--path PATH]`,
|
|
21
24
|
` ${n} hub sync [--json] (companions + hub plugins)`,
|
|
@@ -33,6 +36,11 @@ export function usage(): string {
|
|
|
33
36
|
` ${n} update`,
|
|
34
37
|
` ${n} update --check`,
|
|
35
38
|
"",
|
|
39
|
+
"Working repository state:",
|
|
40
|
+
" linked repositories locally exclude root .claude/, .opencode/, and .agents/ directories",
|
|
41
|
+
` ${n} working cleanup removes Mate-owned local integration without resetting product work`,
|
|
42
|
+
" tracked files and capability data remain untouched; link or launch recreates required state",
|
|
43
|
+
"",
|
|
36
44
|
"Doctor states:",
|
|
37
45
|
" linked-working-repository — cwd is inside a registered working repository",
|
|
38
46
|
" companion-repository — cwd is the companion repository itself",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes one JSON document to stdout and resolves only once the underlying
|
|
3
|
+
* write completes. `console.log`/bare `process.stdout.write` on a large
|
|
4
|
+
* payload can be truncated when stdout is a pipe and the process exits
|
|
5
|
+
* before the write flushes (observed at exactly the 64KB pipe boundary) —
|
|
6
|
+
* awaiting the write callback is what actually guarantees delivery.
|
|
7
|
+
*/
|
|
8
|
+
export function writeJsonStdout(data: unknown): Promise<void> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`, (error) =>
|
|
11
|
+
error ? reject(error) : resolve(),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
|
|
6
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
7
|
+
import type { LinkedRepository } from "./types";
|
|
8
|
+
|
|
9
|
+
export function companionRegistryPath(companionPath: string): string {
|
|
10
|
+
return path.join(path.resolve(companionPath), `.${FRAMEWORK_NAME}`, "config", "registry.yaml");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CompanionRegistryReadResult {
|
|
14
|
+
repos: LinkedRepository[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses a companion's `registry.yaml` without the writes {@link
|
|
19
|
+
* CompanionRegistryStore.load} performs (legacy-field migration, stale-pointer
|
|
20
|
+
* cleanup). Callers that must not mutate registry state on a read (e.g.
|
|
21
|
+
* inventory, materialization) use this instead. Throws ENOENT when the file
|
|
22
|
+
* doesn't exist; throws on unparsable YAML.
|
|
23
|
+
*/
|
|
24
|
+
export async function readCompanionRegistry(
|
|
25
|
+
companionPath: string,
|
|
26
|
+
): Promise<CompanionRegistryReadResult> {
|
|
27
|
+
const raw = await fs.readFile(companionRegistryPath(companionPath), "utf8");
|
|
28
|
+
const parsed = parse(raw) as { repos?: unknown } | null;
|
|
29
|
+
const rawRepos = Array.isArray(parsed?.repos) ? parsed.repos : [];
|
|
30
|
+
const repos = rawRepos
|
|
31
|
+
.filter(
|
|
32
|
+
(entry): entry is LinkedRepository =>
|
|
33
|
+
Boolean(entry) && typeof entry.id === "string" && typeof entry.path === "string",
|
|
34
|
+
)
|
|
35
|
+
.map(({ id, path: repoPath }) => ({ id, path: repoPath }));
|
|
36
|
+
return { repos };
|
|
37
|
+
}
|