pi-monofold 0.0.2 → 0.1.0
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/README.md +33 -7
- package/index.ts +456 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ pi -e .
|
|
|
84
84
|
Place config in the control repository:
|
|
85
85
|
|
|
86
86
|
```text
|
|
87
|
-
<control-repo>/.pi/monofold.
|
|
87
|
+
<control-repo>/.pi/monofold.yaml
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
Example:
|
|
@@ -111,6 +111,14 @@ workspaces:
|
|
|
111
111
|
filenameTemplate: "prd-{{slug}}.md"
|
|
112
112
|
metadata:
|
|
113
113
|
type: prd
|
|
114
|
+
projects:
|
|
115
|
+
- name: "Launch plan"
|
|
116
|
+
path: "Projects/Launch"
|
|
117
|
+
tags: [project, launch]
|
|
118
|
+
contextFiles: [CONTEXT.md]
|
|
119
|
+
routes:
|
|
120
|
+
default: "."
|
|
121
|
+
progress: "Progress"
|
|
114
122
|
|
|
115
123
|
- name: "Application"
|
|
116
124
|
path: "../app"
|
|
@@ -130,23 +138,41 @@ workspaces:
|
|
|
130
138
|
## Commands
|
|
131
139
|
|
|
132
140
|
- `/monofold:list` or `/monofold_list`: show manifest and git status summary.
|
|
133
|
-
- `/monofold:add <path> --name "Name" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit`: add a workspace to `.pi/monofold.
|
|
134
|
-
- `/monofold:
|
|
135
|
-
- `/monofold:
|
|
136
|
-
- `/monofold:
|
|
141
|
+
- `/monofold:add <path> --name "Name" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit`: add a workspace to `.pi/monofold.yaml`.
|
|
142
|
+
- `/monofold:project-add <path> --parent "Name" --tags project,slug`: add a Project Workspace under a parent workspace.
|
|
143
|
+
- `/monofold:update [natural language request]`: migrate legacy config to `.pi/monofold.yaml`, normalize YAML, validate the manifest, and optionally hand a config-change request to the agent.
|
|
144
|
+
- `/monofold:read file <path> --target #0.1`: read a file from a workspace or project target.
|
|
145
|
+
- `/monofold:tree [path] --target #0.1 --depth 2`: show a target tree.
|
|
146
|
+
- `/monofold:search <query> --target #0.1`: search a target.
|
|
137
147
|
- `/monofold:write --route progress --title "Title" --body "Markdown body"`: write routed Markdown.
|
|
138
|
-
- `/monofold:git status|commit|push --
|
|
148
|
+
- `/monofold:git status|commit|push --target #0.1`: run guarded target git.
|
|
139
149
|
|
|
140
150
|
Examples:
|
|
141
151
|
|
|
142
152
|
```text
|
|
143
153
|
/monofold:add C:/Projects/app --name "Application" --tags development,app --capabilities read,editCode,runCommands,gitCommit --context README.md,AGENTS.md
|
|
144
154
|
/monofold:add ../business --name "Product Docs" --tags business,docs --capabilities read,writeDocs,gitCommit --routes default=Notes,progress=Progress,research=Research
|
|
155
|
+
/monofold:project-add Projects/Launch --parent "Product Docs" --tags project,launch --routes default=.,progress=Progress
|
|
156
|
+
/monofold:update rename the Product Docs workspace to Business Notes and add tag docs
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Project Workspaces are listed under `workspaces[].projects`. Their `path` is relative to the parent workspace, `tags` are combined with parent tags, `capabilities` inherit unless explicitly replaced, and missing routes default to `default: "."` when the effective target has `writeDocs`.
|
|
160
|
+
|
|
161
|
+
## Updating configuration
|
|
162
|
+
|
|
163
|
+
`.pi/monofold.yaml` is the canonical config file. Legacy `.pi/monofold.yml` is still readable, but `/monofold:update` migrates it to `.pi/monofold.yaml`, writes a timestamped backup such as `.pi/monofold.yml.bak-20260524-153012`, and removes the legacy file after a successful write. If both `.yaml` and `.yml` exist, Pi Monofold stops with a conflict error so you can choose the correct file manually.
|
|
164
|
+
|
|
165
|
+
`/monofold:update` is a configuration migration command, not a Pi package updater. Use `pi update`, `pi update --extensions`, or `pi install ...@new-ref` for package updates.
|
|
166
|
+
|
|
167
|
+
After migration, you can provide a natural-language configuration change request. The command hands that request to the Pi agent, which edits `.pi/monofold.yaml` directly and validates the result through the manifest path:
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
/monofold:update add 4_Project/NewApp as a Project Workspace under Obsidian Vault with tags project,newapp and progress route Progress
|
|
145
171
|
```
|
|
146
172
|
|
|
147
173
|
## Guard
|
|
148
174
|
|
|
149
|
-
When `.pi/monofold.yml` exists, Pi Monofold guards standard `read/write/edit/grep/find/bash` calls against workspace capabilities.
|
|
175
|
+
When `.pi/monofold.yaml` or legacy `.pi/monofold.yml` exists, Pi Monofold guards standard `read/write/edit/grep/find/bash` calls against workspace capabilities.
|
|
150
176
|
|
|
151
177
|
- Unknown path: confirm in UI, block without UI.
|
|
152
178
|
- Docs write: requires `writeDocs`.
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
-
import { access, mkdir, readFile, readdir,
|
|
4
|
+
import { access, copyFile, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import YAML from "yaml";
|
|
7
7
|
|
|
@@ -21,6 +21,16 @@ type WorkspaceConfig = {
|
|
|
21
21
|
capabilities: CapabilityTag[];
|
|
22
22
|
contextFiles?: string[];
|
|
23
23
|
routes?: Partial<Record<RouteType, string | RouteConfig>>;
|
|
24
|
+
projects?: ProjectConfig[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ProjectConfig = {
|
|
28
|
+
name?: string;
|
|
29
|
+
path: string;
|
|
30
|
+
tags: string[];
|
|
31
|
+
capabilities?: CapabilityTag[];
|
|
32
|
+
contextFiles?: string[];
|
|
33
|
+
routes?: Partial<Record<RouteType, string | RouteConfig>>;
|
|
24
34
|
};
|
|
25
35
|
|
|
26
36
|
type MultiWorkspaceConfig = {
|
|
@@ -34,10 +44,17 @@ type MultiWorkspaceConfig = {
|
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
type ResolvedWorkspace = WorkspaceConfig & {
|
|
47
|
+
kind: "workspace" | "project";
|
|
48
|
+
targetId: string;
|
|
37
49
|
index: number;
|
|
50
|
+
projectIndex?: number;
|
|
51
|
+
parent?: ResolvedWorkspace;
|
|
52
|
+
displayPath: string;
|
|
38
53
|
resolvedPath: string;
|
|
54
|
+
realPath: string;
|
|
39
55
|
normalizedRoutes: Partial<Record<RouteType, RouteConfig>>;
|
|
40
56
|
effectiveContextFiles: string[];
|
|
57
|
+
commitScope?: string;
|
|
41
58
|
};
|
|
42
59
|
|
|
43
60
|
type LoadedConfig = {
|
|
@@ -49,6 +66,8 @@ type LoadedConfig = {
|
|
|
49
66
|
|
|
50
67
|
type TargetInput = {
|
|
51
68
|
targetTags?: string[];
|
|
69
|
+
targetName?: string;
|
|
70
|
+
targetId?: string;
|
|
52
71
|
workspaceName?: string;
|
|
53
72
|
workspaceIndex?: number;
|
|
54
73
|
requireCapabilities?: CapabilityTag[];
|
|
@@ -65,7 +84,22 @@ type CommandResult = {
|
|
|
65
84
|
exitCode: number | string | null | undefined;
|
|
66
85
|
};
|
|
67
86
|
|
|
68
|
-
|
|
87
|
+
type ConfigMigrationPlan = {
|
|
88
|
+
changed: boolean;
|
|
89
|
+
sourcePath: string;
|
|
90
|
+
sourceRelativePath: string;
|
|
91
|
+
sourceKind: "canonical" | "legacy";
|
|
92
|
+
targetPath: string;
|
|
93
|
+
targetRelativePath: string;
|
|
94
|
+
backupPath?: string;
|
|
95
|
+
backupRelativePath?: string;
|
|
96
|
+
normalizedText: string;
|
|
97
|
+
loaded: LoadedConfig;
|
|
98
|
+
actions: string[];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yaml");
|
|
102
|
+
const LEGACY_CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yml");
|
|
69
103
|
const ROUTE_TYPES: RouteType[] = ["default", "prd", "design", "progress", "issue", "research", "decision"];
|
|
70
104
|
const CAPABILITIES: CapabilityTag[] = ["read", "writeDocs", "editCode", "runCommands", "gitCommit", "gitPush"];
|
|
71
105
|
const DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]);
|
|
@@ -95,6 +129,11 @@ const CODE_EXTENSIONS = new Set([
|
|
|
95
129
|
".scss",
|
|
96
130
|
".html",
|
|
97
131
|
]);
|
|
132
|
+
const ROOT_KEYS = new Set(["version", "defaults", "workspaces"]);
|
|
133
|
+
const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
|
|
134
|
+
const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
|
|
135
|
+
const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
|
|
136
|
+
const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
|
|
98
137
|
|
|
99
138
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
100
139
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -104,6 +143,12 @@ function normalizeSlashes(value: string): string {
|
|
|
104
143
|
return value.replace(/\\/g, "/");
|
|
105
144
|
}
|
|
106
145
|
|
|
146
|
+
function assertKnownKeys(label: string, value: Record<string, unknown>, allowed: Set<string>): void {
|
|
147
|
+
for (const key of Object.keys(value)) {
|
|
148
|
+
if (!allowed.has(key)) throw new Error(`${label} has unknown key: ${key}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
107
152
|
function isInside(parent: string, child: string): boolean {
|
|
108
153
|
const relative = path.relative(parent, child);
|
|
109
154
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
@@ -115,6 +160,18 @@ function assertWorkspaceInternalRelative(label: string, value: string): void {
|
|
|
115
160
|
}
|
|
116
161
|
}
|
|
117
162
|
|
|
163
|
+
function assertProjectPath(label: string, value: string): void {
|
|
164
|
+
assertWorkspaceInternalRelative(label, value);
|
|
165
|
+
const normalized = normalizeSlashes(value).replace(/^\.\//, "").replace(/\/$/, "");
|
|
166
|
+
if (!normalized || normalized === ".") {
|
|
167
|
+
throw new Error(`${label} must point below the parent workspace, not the parent root`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function uniqueStrings(items: string[]): string[] {
|
|
172
|
+
return [...new Set(items.filter(Boolean))];
|
|
173
|
+
}
|
|
174
|
+
|
|
118
175
|
function asStringArray(label: string, value: unknown, required = true): string[] {
|
|
119
176
|
if (value === undefined && !required) return [];
|
|
120
177
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
@@ -133,6 +190,19 @@ function asCapabilityArray(value: unknown): CapabilityTag[] {
|
|
|
133
190
|
return items as CapabilityTag[];
|
|
134
191
|
}
|
|
135
192
|
|
|
193
|
+
function asOptionalCapabilityArray(value: unknown): CapabilityTag[] | undefined {
|
|
194
|
+
if (value === undefined) return undefined;
|
|
195
|
+
return asCapabilityArray(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function existingRealPath(label: string, targetPath: string): Promise<string> {
|
|
199
|
+
try {
|
|
200
|
+
return await realpath(targetPath);
|
|
201
|
+
} catch {
|
|
202
|
+
throw new Error(`${label} does not exist: ${targetPath}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
136
206
|
function normalizeRoute(routeType: string, value: unknown): RouteConfig {
|
|
137
207
|
if (!ROUTE_TYPES.includes(routeType as RouteType)) {
|
|
138
208
|
throw new Error(`Unknown route type: ${routeType}`);
|
|
@@ -144,6 +214,7 @@ function normalizeRoute(routeType: string, value: unknown): RouteConfig {
|
|
|
144
214
|
if (!isRecord(value) || typeof value.path !== "string") {
|
|
145
215
|
throw new Error(`routes.${routeType} must be a string path or object with path`);
|
|
146
216
|
}
|
|
217
|
+
assertKnownKeys(`routes.${routeType}`, value, ROUTE_KEYS);
|
|
147
218
|
assertWorkspaceInternalRelative(`routes.${routeType}.path`, value.path);
|
|
148
219
|
if (value.filenameTemplate !== undefined && typeof value.filenameTemplate !== "string") {
|
|
149
220
|
throw new Error(`routes.${routeType}.filenameTemplate must be a string`);
|
|
@@ -167,6 +238,19 @@ async function pathExists(targetPath: string): Promise<boolean> {
|
|
|
167
238
|
}
|
|
168
239
|
}
|
|
169
240
|
|
|
241
|
+
async function resolveConfigFile(cwd: string, allowMissing = false): Promise<{ configPath: string; relativePath: string; kind: "canonical" | "legacy" | "missing" }> {
|
|
242
|
+
const canonicalPath = path.join(cwd, CONFIG_RELATIVE_PATH);
|
|
243
|
+
const legacyPath = path.join(cwd, LEGACY_CONFIG_RELATIVE_PATH);
|
|
244
|
+
const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
|
|
245
|
+
if (hasCanonical && hasLegacy) {
|
|
246
|
+
throw new Error(`Configuration file conflict: both ${CONFIG_RELATIVE_PATH} and ${LEGACY_CONFIG_RELATIVE_PATH} exist. Remove one before continuing.`);
|
|
247
|
+
}
|
|
248
|
+
if (hasCanonical) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
|
|
249
|
+
if (hasLegacy) return { configPath: legacyPath, relativePath: LEGACY_CONFIG_RELATIVE_PATH, kind: "legacy" };
|
|
250
|
+
if (allowMissing) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "missing" };
|
|
251
|
+
throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
|
|
252
|
+
}
|
|
253
|
+
|
|
170
254
|
function runCommand(
|
|
171
255
|
command: string,
|
|
172
256
|
args: string[],
|
|
@@ -198,22 +282,31 @@ function runCommand(
|
|
|
198
282
|
}
|
|
199
283
|
|
|
200
284
|
async function loadConfig(cwd: string): Promise<LoadedConfig> {
|
|
201
|
-
const configPath =
|
|
285
|
+
const { configPath } = await resolveConfigFile(cwd);
|
|
202
286
|
const text = await readFile(configPath, "utf8");
|
|
203
287
|
const parsed = YAML.parse(text, { uniqueKeys: true }) as unknown;
|
|
288
|
+
return validateConfigObject(cwd, configPath, parsed);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function validateConfigObject(cwd: string, configPath: string, parsed: unknown): Promise<LoadedConfig> {
|
|
204
292
|
if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
|
|
293
|
+
assertKnownKeys("monofold config", parsed, ROOT_KEYS);
|
|
205
294
|
if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
|
|
206
295
|
if (!Array.isArray(parsed.workspaces) || parsed.workspaces.length === 0) {
|
|
207
296
|
throw new Error("monofold config requires non-empty workspaces array");
|
|
208
297
|
}
|
|
209
298
|
|
|
210
299
|
const defaults = isRecord(parsed.defaults) ? parsed.defaults : undefined;
|
|
300
|
+
if (defaults) assertKnownKeys("defaults", defaults, DEFAULT_KEYS);
|
|
211
301
|
const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
|
|
212
302
|
const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
|
|
213
303
|
const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
|
|
214
304
|
|
|
215
|
-
const workspaces
|
|
305
|
+
const workspaces: ResolvedWorkspace[] = [];
|
|
306
|
+
for (let index = 0; index < parsed.workspaces.length; index += 1) {
|
|
307
|
+
const item = parsed.workspaces[index];
|
|
216
308
|
if (!isRecord(item)) throw new Error(`workspaces[${index}] must be an object`);
|
|
309
|
+
assertKnownKeys(`workspaces[${index}]`, item, WORKSPACE_KEYS);
|
|
217
310
|
if (item.name !== undefined && typeof item.name !== "string") throw new Error(`workspaces[${index}].name must be string`);
|
|
218
311
|
if (typeof item.path !== "string") throw new Error(`workspaces[${index}].path is required`);
|
|
219
312
|
const tags = asStringArray(`workspaces[${index}].tags`, item.tags);
|
|
@@ -226,6 +319,9 @@ async function loadConfig(cwd: string): Promise<LoadedConfig> {
|
|
|
226
319
|
const routes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(item.routes)
|
|
227
320
|
? (item.routes as Partial<Record<RouteType, string | RouteConfig>>)
|
|
228
321
|
: undefined;
|
|
322
|
+
if (!capabilities.includes("writeDocs") && routes) {
|
|
323
|
+
throw new Error(`workspaces[${index}] has routes but lacks writeDocs capability`);
|
|
324
|
+
}
|
|
229
325
|
if (capabilities.includes("writeDocs") && !routes) {
|
|
230
326
|
throw new Error(`workspaces[${index}] has writeDocs but no routes`);
|
|
231
327
|
}
|
|
@@ -238,19 +334,87 @@ async function loadConfig(cwd: string): Promise<LoadedConfig> {
|
|
|
238
334
|
}
|
|
239
335
|
|
|
240
336
|
const resolvedPath = path.resolve(cwd, item.path);
|
|
241
|
-
|
|
337
|
+
const realPath = await existingRealPath(`workspaces[${index}].path`, resolvedPath);
|
|
338
|
+
const workspace: ResolvedWorkspace = {
|
|
339
|
+
kind: "workspace",
|
|
340
|
+
targetId: `#${index}`,
|
|
242
341
|
name: item.name as string | undefined,
|
|
243
342
|
path: item.path,
|
|
244
|
-
|
|
343
|
+
displayPath: item.path,
|
|
344
|
+
tags: uniqueStrings(tags),
|
|
245
345
|
capabilities,
|
|
246
346
|
contextFiles,
|
|
247
347
|
routes,
|
|
248
348
|
index,
|
|
249
349
|
resolvedPath,
|
|
350
|
+
realPath,
|
|
250
351
|
normalizedRoutes,
|
|
251
352
|
effectiveContextFiles: [...defaultContextFiles, ...contextFiles],
|
|
252
353
|
};
|
|
253
|
-
|
|
354
|
+
workspaces.push(workspace);
|
|
355
|
+
|
|
356
|
+
const projects = Array.isArray(item.projects) ? item.projects : [];
|
|
357
|
+
if (item.projects !== undefined && !Array.isArray(item.projects)) {
|
|
358
|
+
throw new Error(`workspaces[${index}].projects must be an array`);
|
|
359
|
+
}
|
|
360
|
+
const seenProjectRealPaths = new Set<string>();
|
|
361
|
+
for (let projectOffset = 0; projectOffset < projects.length; projectOffset += 1) {
|
|
362
|
+
const project = projects[projectOffset];
|
|
363
|
+
const projectIndex = projectOffset + 1;
|
|
364
|
+
if (!isRecord(project)) throw new Error(`workspaces[${index}].projects[${projectOffset}] must be an object`);
|
|
365
|
+
assertKnownKeys(`workspaces[${index}].projects[${projectOffset}]`, project, PROJECT_KEYS);
|
|
366
|
+
if (project.name !== undefined && typeof project.name !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].name must be string`);
|
|
367
|
+
if (typeof project.path !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].path is required`);
|
|
368
|
+
assertProjectPath(`workspaces[${index}].projects[${projectOffset}].path`, project.path);
|
|
369
|
+
const projectTags = asStringArray(`workspaces[${index}].projects[${projectOffset}].tags`, project.tags);
|
|
370
|
+
if (projectTags.length === 0) throw new Error(`workspaces[${index}].projects[${projectOffset}].tags is required`);
|
|
371
|
+
const projectCapabilities = asOptionalCapabilityArray(project.capabilities) ?? capabilities;
|
|
372
|
+
const projectContextFiles = asStringArray(`workspaces[${index}].projects[${projectOffset}].contextFiles`, project.contextFiles, false);
|
|
373
|
+
for (const contextFile of projectContextFiles) {
|
|
374
|
+
assertWorkspaceInternalRelative(`workspaces[${index}].projects[${projectOffset}].contextFiles`, contextFile);
|
|
375
|
+
}
|
|
376
|
+
const projectRoutes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(project.routes)
|
|
377
|
+
? (project.routes as Partial<Record<RouteType, string | RouteConfig>>)
|
|
378
|
+
: undefined;
|
|
379
|
+
if (!projectCapabilities.includes("writeDocs") && projectRoutes) {
|
|
380
|
+
throw new Error(`workspaces[${index}].projects[${projectOffset}] has routes but lacks writeDocs capability`);
|
|
381
|
+
}
|
|
382
|
+
const projectNormalizedRoutes: Partial<Record<RouteType, RouteConfig>> = {};
|
|
383
|
+
if (projectRoutes) {
|
|
384
|
+
for (const [routeType, routeValue] of Object.entries(projectRoutes)) {
|
|
385
|
+
projectNormalizedRoutes[routeType as RouteType] = normalizeRoute(routeType, routeValue);
|
|
386
|
+
}
|
|
387
|
+
} else if (projectCapabilities.includes("writeDocs")) {
|
|
388
|
+
projectNormalizedRoutes.default = { path: "." };
|
|
389
|
+
}
|
|
390
|
+
const projectResolvedPath = path.resolve(resolvedPath, project.path);
|
|
391
|
+
const projectRealPath = await existingRealPath(`workspaces[${index}].projects[${projectOffset}].path`, projectResolvedPath);
|
|
392
|
+
if (!isInside(realPath, projectRealPath) || projectRealPath === realPath) {
|
|
393
|
+
throw new Error(`workspaces[${index}].projects[${projectOffset}].path must stay below parent workspace: ${project.path}`);
|
|
394
|
+
}
|
|
395
|
+
if (seenProjectRealPaths.has(projectRealPath)) throw new Error(`Duplicate project path in workspaces[${index}]: ${project.path}`);
|
|
396
|
+
seenProjectRealPaths.add(projectRealPath);
|
|
397
|
+
workspaces.push({
|
|
398
|
+
kind: "project",
|
|
399
|
+
targetId: `#${index}.${projectIndex}`,
|
|
400
|
+
name: project.name as string | undefined,
|
|
401
|
+
path: project.path,
|
|
402
|
+
displayPath: normalizeSlashes(path.join(item.path, project.path)),
|
|
403
|
+
tags: uniqueStrings([...tags, ...projectTags]),
|
|
404
|
+
capabilities: projectCapabilities,
|
|
405
|
+
contextFiles: projectContextFiles,
|
|
406
|
+
routes: project.routes as ProjectConfig["routes"],
|
|
407
|
+
index,
|
|
408
|
+
projectIndex,
|
|
409
|
+
parent: workspace,
|
|
410
|
+
resolvedPath: projectResolvedPath,
|
|
411
|
+
realPath: projectRealPath,
|
|
412
|
+
normalizedRoutes: projectNormalizedRoutes,
|
|
413
|
+
effectiveContextFiles: [...defaultContextFiles, ...contextFiles, ...projectContextFiles],
|
|
414
|
+
commitScope: normalizeSlashes(path.relative(resolvedPath, projectResolvedPath)),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
254
418
|
|
|
255
419
|
return {
|
|
256
420
|
configPath,
|
|
@@ -262,15 +426,95 @@ async function loadConfig(cwd: string): Promise<LoadedConfig> {
|
|
|
262
426
|
filenameTemplate: defaultFilenameTemplate,
|
|
263
427
|
metadata: defaultMetadata,
|
|
264
428
|
},
|
|
265
|
-
workspaces,
|
|
429
|
+
workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
|
|
266
430
|
},
|
|
267
431
|
workspaces,
|
|
268
432
|
};
|
|
269
433
|
}
|
|
270
434
|
|
|
435
|
+
function timestampSuffix(now = new Date()): string {
|
|
436
|
+
return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-").replace(/Z$/, "");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
|
|
440
|
+
if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
|
|
441
|
+
assertKnownKeys("monofold config", parsed, ROOT_KEYS);
|
|
442
|
+
const version = parsed.version ?? 1;
|
|
443
|
+
if (version !== 1) throw new Error("monofold config requires version: 1");
|
|
444
|
+
const normalized: Record<string, unknown> = { version: 1 };
|
|
445
|
+
if (parsed.defaults !== undefined) normalized.defaults = parsed.defaults;
|
|
446
|
+
normalized.workspaces = parsed.workspaces;
|
|
447
|
+
return normalized;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPlan> {
|
|
451
|
+
const source = await resolveConfigFile(cwd);
|
|
452
|
+
if (source.kind === "missing") throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
|
|
453
|
+
|
|
454
|
+
const originalText = await readFile(source.configPath, "utf8");
|
|
455
|
+
const parsed = YAML.parse(originalText, { uniqueKeys: true }) as unknown;
|
|
456
|
+
const normalized = normalizeConfigForMigration(parsed);
|
|
457
|
+
const targetPath = path.join(cwd, CONFIG_RELATIVE_PATH);
|
|
458
|
+
const normalizedText = YAML.stringify(normalized).trimEnd() + "\n";
|
|
459
|
+
const loaded = await validateConfigObject(cwd, targetPath, normalized);
|
|
460
|
+
const changed = source.kind !== "canonical" || originalText !== normalizedText;
|
|
461
|
+
const actions: string[] = [];
|
|
462
|
+
if (source.kind === "legacy") actions.push(`Move legacy config ${LEGACY_CONFIG_RELATIVE_PATH} to canonical ${CONFIG_RELATIVE_PATH}`);
|
|
463
|
+
if (originalText !== normalizedText) actions.push("Normalize YAML and ensure version: 1 is explicit");
|
|
464
|
+
if (source.kind === "legacy") actions.push(`Remove legacy config ${LEGACY_CONFIG_RELATIVE_PATH} after writing ${CONFIG_RELATIVE_PATH}`);
|
|
465
|
+
if (!changed) actions.push(`Already current: ${CONFIG_RELATIVE_PATH} (version 1)`);
|
|
466
|
+
const backupPath = changed ? `${source.configPath}.bak-${timestampSuffix()}` : undefined;
|
|
467
|
+
return {
|
|
468
|
+
changed,
|
|
469
|
+
sourcePath: source.configPath,
|
|
470
|
+
sourceRelativePath: source.relativePath,
|
|
471
|
+
sourceKind: source.kind,
|
|
472
|
+
targetPath,
|
|
473
|
+
targetRelativePath: CONFIG_RELATIVE_PATH,
|
|
474
|
+
backupPath,
|
|
475
|
+
backupRelativePath: backupPath ? normalizeSlashes(path.relative(cwd, backupPath)) : undefined,
|
|
476
|
+
normalizedText,
|
|
477
|
+
loaded,
|
|
478
|
+
actions,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function applyConfigMigrationPlan(plan: ConfigMigrationPlan): Promise<void> {
|
|
483
|
+
if (!plan.changed) return;
|
|
484
|
+
if (!plan.backupPath) throw new Error("Migration backup path is required for changed configuration");
|
|
485
|
+
await mkdir(path.dirname(plan.targetPath), { recursive: true });
|
|
486
|
+
await copyFile(plan.sourcePath, plan.backupPath);
|
|
487
|
+
await writeFile(plan.targetPath, plan.normalizedText, "utf8");
|
|
488
|
+
if (plan.sourceKind === "legacy") await unlink(plan.sourcePath);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function formatMigrationPlan(plan: ConfigMigrationPlan): string {
|
|
492
|
+
if (!plan.changed) return `Already current: ${plan.targetRelativePath} (version ${plan.loaded.raw.version})`;
|
|
493
|
+
return [
|
|
494
|
+
`Source: ${plan.sourceRelativePath}`,
|
|
495
|
+
`Target: ${plan.targetRelativePath}`,
|
|
496
|
+
`Backup: ${plan.backupRelativePath}`,
|
|
497
|
+
"",
|
|
498
|
+
"Actions:",
|
|
499
|
+
...plan.actions.map((action) => `- ${action}`),
|
|
500
|
+
].join("\n");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function buildConfigurationHandoffPrompt(request: string): string {
|
|
504
|
+
return [
|
|
505
|
+
"/monofold:update completed. Apply this requested Pi Monofold configuration change to `.pi/monofold.yaml`.",
|
|
506
|
+
"Edit the canonical config directly when needed, preserve valid YAML, then run `/monofold:list` as manifest validation.",
|
|
507
|
+
"",
|
|
508
|
+
"User request:",
|
|
509
|
+
request.trim(),
|
|
510
|
+
].join("\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
271
513
|
function matchesTarget(workspace: ResolvedWorkspace, target: TargetInput): boolean {
|
|
514
|
+
if (target.targetId && workspace.targetId !== (target.targetId.startsWith("#") ? target.targetId : `#${target.targetId}`)) return false;
|
|
272
515
|
if (target.workspaceIndex !== undefined && workspace.index !== target.workspaceIndex) return false;
|
|
273
|
-
|
|
516
|
+
const targetName = target.targetName ?? target.workspaceName;
|
|
517
|
+
if (targetName && workspace.name !== targetName) return false;
|
|
274
518
|
if (target.targetTags?.length && !target.targetTags.every((tag) => workspace.tags.includes(tag))) return false;
|
|
275
519
|
if (target.requireCapabilities?.length) {
|
|
276
520
|
if (!target.requireCapabilities.every((cap) => workspace.capabilities.includes(cap))) return false;
|
|
@@ -325,14 +569,14 @@ function stringFlag(flags: Record<string, string | boolean>, ...names: string[])
|
|
|
325
569
|
}
|
|
326
570
|
|
|
327
571
|
function commandTarget(flags: Record<string, string | boolean>, requireCapabilities?: CapabilityTag[]): TargetInput {
|
|
328
|
-
const workspace = stringFlag(flags, "workspace", "w");
|
|
572
|
+
const workspace = stringFlag(flags, "target", "workspace", "w");
|
|
329
573
|
const tags = stringFlag(flags, "tags", "tag")
|
|
330
574
|
?.split(",")
|
|
331
575
|
.map((item) => item.trim())
|
|
332
576
|
.filter(Boolean);
|
|
333
|
-
const workspaceIndex = workspace?.startsWith("#") ? Number.parseInt(workspace.slice(1), 10) : undefined;
|
|
577
|
+
const workspaceIndex = workspace?.startsWith("#") && !workspace.includes(".") ? Number.parseInt(workspace.slice(1), 10) : undefined;
|
|
334
578
|
return {
|
|
335
|
-
...(workspace
|
|
579
|
+
...(workspace?.startsWith("#") ? { targetId: workspace } : workspace ? { targetName: workspace } : {}),
|
|
336
580
|
...(workspaceIndex !== undefined && Number.isFinite(workspaceIndex) ? { workspaceIndex } : {}),
|
|
337
581
|
...(tags?.length ? { targetTags: tags } : {}),
|
|
338
582
|
requireCapabilities,
|
|
@@ -420,6 +664,49 @@ async function addWorkspaceToConfig(configPath: string, workspaceBlock: Workspac
|
|
|
420
664
|
await writeFile(configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
|
|
421
665
|
}
|
|
422
666
|
|
|
667
|
+
function buildProjectFromAddArgs(args: string): { project: ProjectConfig; parent: TargetInput } {
|
|
668
|
+
const parsed = parseCommandArgs(args);
|
|
669
|
+
const projectPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional[0];
|
|
670
|
+
if (!projectPath) throw new Error("project path is required");
|
|
671
|
+
const tags = commaListFlag(parsed.flags, "tags", "tag");
|
|
672
|
+
if (tags.length === 0) throw new Error("--tags is required");
|
|
673
|
+
const capabilities = commaListFlag(parsed.flags, "capabilities", "caps", "cap");
|
|
674
|
+
const parentName = stringFlag(parsed.flags, "parent");
|
|
675
|
+
const parentTags = commaListFlag(parsed.flags, "parent-tags", "parentTags");
|
|
676
|
+
if (!parentName && parentTags.length === 0) throw new Error("--parent or --parent-tags is required");
|
|
677
|
+
const project: ProjectConfig = {
|
|
678
|
+
...(stringFlag(parsed.flags, "name", "n") ? { name: stringFlag(parsed.flags, "name", "n") } : {}),
|
|
679
|
+
path: projectPath,
|
|
680
|
+
tags,
|
|
681
|
+
...(capabilities.length ? { capabilities: asCapabilityArray(capabilities) } : {}),
|
|
682
|
+
contextFiles: commaListFlag(parsed.flags, "context", "contexts", "contextFiles"),
|
|
683
|
+
...(routesFlag(parsed.flags) ? { routes: routesFlag(parsed.flags) } : {}),
|
|
684
|
+
};
|
|
685
|
+
if (project.contextFiles?.length === 0) delete project.contextFiles;
|
|
686
|
+
return {
|
|
687
|
+
project,
|
|
688
|
+
parent: {
|
|
689
|
+
...(parentName?.startsWith("#") ? { targetId: parentName } : parentName ? { targetName: parentName } : {}),
|
|
690
|
+
...(parentTags.length ? { targetTags: parentTags } : {}),
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function addProjectToConfig(ctx: ExtensionCommandContext, loaded: LoadedConfig, project: ProjectConfig, parentInput: TargetInput): Promise<void> {
|
|
696
|
+
const parent = await resolveWorkspace(ctx, { ...loaded, workspaces: loaded.workspaces.filter((workspace) => workspace.kind === "workspace") }, parentInput);
|
|
697
|
+
assertProjectPath("project.path", project.path);
|
|
698
|
+
const projectRealPath = await existingRealPath("project.path", path.resolve(parent.resolvedPath, project.path));
|
|
699
|
+
if (!isInside(parent.realPath, projectRealPath) || projectRealPath === parent.realPath) throw new Error(`project path must stay below parent workspace: ${project.path}`);
|
|
700
|
+
if (loaded.workspaces.some((workspace) => workspace.parent === parent && workspace.realPath === projectRealPath)) throw new Error(`project path already exists under parent: ${project.path}`);
|
|
701
|
+
const parsed = YAML.parse(await readFile(loaded.configPath, "utf8"), { uniqueKeys: true }) as Record<string, unknown>;
|
|
702
|
+
const workspaces = parsed.workspaces as Record<string, unknown>[];
|
|
703
|
+
const rawParent = workspaces[parent.index];
|
|
704
|
+
const projects = Array.isArray(rawParent.projects) ? rawParent.projects : [];
|
|
705
|
+
rawParent.projects = projects;
|
|
706
|
+
projects.push(project);
|
|
707
|
+
await writeFile(loaded.configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
|
|
708
|
+
}
|
|
709
|
+
|
|
423
710
|
function sendCommandOutput(pi: ExtensionAPI, title: string, text: string, details?: Record<string, unknown>) {
|
|
424
711
|
pi.sendMessage({
|
|
425
712
|
customType: "monofold-output",
|
|
@@ -434,7 +721,7 @@ function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usa
|
|
|
434
721
|
sendCommandOutput(pi, command, `Error: ${message}\n\nUsage:\n${usage}`, { error: message });
|
|
435
722
|
}
|
|
436
723
|
|
|
437
|
-
async function resolveWorkspace(ctx: ExtensionContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
|
|
724
|
+
async function resolveWorkspace(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
|
|
438
725
|
const matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
|
|
439
726
|
if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
|
|
440
727
|
if (matches.length === 1) return matches[0];
|
|
@@ -449,7 +736,8 @@ async function resolveWorkspace(ctx: ExtensionContext, loaded: LoadedConfig, tar
|
|
|
449
736
|
|
|
450
737
|
function formatWorkspaceLabel(workspace: ResolvedWorkspace): string {
|
|
451
738
|
const displayName = workspace.name ? `${workspace.name} ` : "";
|
|
452
|
-
|
|
739
|
+
const parent = workspace.kind === "project" && workspace.parent ? ` parent=${workspace.parent.name ?? workspace.parent.targetId}` : "";
|
|
740
|
+
return `${workspace.targetId} ${displayName}[${workspace.tags.join(", ")}] ${workspace.displayPath}${parent}`;
|
|
453
741
|
}
|
|
454
742
|
|
|
455
743
|
function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
|
|
@@ -458,11 +746,18 @@ function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
|
|
|
458
746
|
}
|
|
459
747
|
|
|
460
748
|
async function gitSummary(workspace: ResolvedWorkspace): Promise<{ isGit: boolean; status?: string }> {
|
|
461
|
-
|
|
462
|
-
|
|
749
|
+
const root = await gitRoot(workspace);
|
|
750
|
+
if (!root) return { isGit: false };
|
|
751
|
+
const result = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 5000 });
|
|
463
752
|
return { isGit: true, status: result.stdout.trim() || "clean" };
|
|
464
753
|
}
|
|
465
754
|
|
|
755
|
+
async function gitRoot(workspace: ResolvedWorkspace): Promise<string | undefined> {
|
|
756
|
+
if (await pathExists(path.join(workspace.resolvedPath, ".git"))) return workspace.resolvedPath;
|
|
757
|
+
if (workspace.parent && (await pathExists(path.join(workspace.parent.resolvedPath, ".git")))) return workspace.parent.resolvedPath;
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
|
|
466
761
|
async function buildManifest(loaded: LoadedConfig): Promise<string> {
|
|
467
762
|
const lines = ["Pi Monofold Manifest:"];
|
|
468
763
|
for (const workspace of loaded.workspaces) {
|
|
@@ -505,7 +800,7 @@ function slugify(title: string): string {
|
|
|
505
800
|
}
|
|
506
801
|
|
|
507
802
|
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
508
|
-
return template.replace(/\{\{(date|datetime|title|slug|routeType|workspaceName|workspaceTags)\}\}/g, (_, key: string) => vars[key] ?? "");
|
|
803
|
+
return template.replace(/\{\{(date|datetime|title|slug|routeType|workspaceName|workspaceTags|targetName|targetTags|parentWorkspaceName|projectName)\}\}/g, (_, key: string) => vars[key] ?? "");
|
|
509
804
|
}
|
|
510
805
|
|
|
511
806
|
function renderMetadata(value: unknown, vars: Record<string, string>): unknown {
|
|
@@ -531,7 +826,7 @@ function classifyPath(targetPath: string): "docs" | "code" | "unknown" {
|
|
|
531
826
|
|
|
532
827
|
function findWorkspaceForPath(loaded: LoadedConfig, targetPath: string): ResolvedWorkspace | undefined {
|
|
533
828
|
const absolute = path.resolve(loaded.root, targetPath);
|
|
534
|
-
return loaded.workspaces.find((workspace) => isInside(workspace.resolvedPath, absolute));
|
|
829
|
+
return [...loaded.workspaces].sort((a, b) => b.resolvedPath.length - a.resolvedPath.length).find((workspace) => isInside(workspace.resolvedPath, absolute));
|
|
535
830
|
}
|
|
536
831
|
|
|
537
832
|
async function confirm(ctx: ExtensionContext, title: string, body: string): Promise<boolean> {
|
|
@@ -630,6 +925,8 @@ ${manifest}
|
|
|
630
925
|
query: Type.Optional(Type.String({ description: "Search query for mode=search" })),
|
|
631
926
|
depth: Type.Optional(Type.Number({ description: "Tree depth, default 1" })),
|
|
632
927
|
targetTags: Type.Optional(Type.Array(Type.String())),
|
|
928
|
+
targetName: Type.Optional(Type.String()),
|
|
929
|
+
targetId: Type.Optional(Type.String()),
|
|
633
930
|
workspaceName: Type.Optional(Type.String()),
|
|
634
931
|
requireCapabilities: Type.Optional(Type.Array(Type.String())),
|
|
635
932
|
}),
|
|
@@ -637,6 +934,8 @@ ${manifest}
|
|
|
637
934
|
const loaded = await loadConfig(ctx.cwd);
|
|
638
935
|
const workspace = await resolveWorkspace(ctx, loaded, {
|
|
639
936
|
targetTags: params.targetTags,
|
|
937
|
+
targetName: params.targetName,
|
|
938
|
+
targetId: params.targetId,
|
|
640
939
|
workspaceName: params.workspaceName,
|
|
641
940
|
requireCapabilities: ["read"],
|
|
642
941
|
});
|
|
@@ -678,6 +977,8 @@ ${manifest}
|
|
|
678
977
|
filename: Type.Optional(Type.String()),
|
|
679
978
|
metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
680
979
|
targetTags: Type.Optional(Type.Array(Type.String())),
|
|
980
|
+
targetName: Type.Optional(Type.String()),
|
|
981
|
+
targetId: Type.Optional(Type.String()),
|
|
681
982
|
workspaceName: Type.Optional(Type.String()),
|
|
682
983
|
}),
|
|
683
984
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -686,6 +987,8 @@ ${manifest}
|
|
|
686
987
|
const loaded = await loadConfig(ctx.cwd);
|
|
687
988
|
const workspace = await resolveWorkspace(ctx, loaded, {
|
|
688
989
|
targetTags: params.targetTags,
|
|
990
|
+
targetName: params.targetName,
|
|
991
|
+
targetId: params.targetId,
|
|
689
992
|
workspaceName: params.workspaceName,
|
|
690
993
|
requireCapabilities: ["writeDocs"],
|
|
691
994
|
});
|
|
@@ -701,6 +1004,10 @@ ${manifest}
|
|
|
701
1004
|
routeType,
|
|
702
1005
|
workspaceName: workspace.name ?? "",
|
|
703
1006
|
workspaceTags: workspace.tags.join(","),
|
|
1007
|
+
targetName: workspace.name ?? "",
|
|
1008
|
+
targetTags: workspace.tags.join(","),
|
|
1009
|
+
parentWorkspaceName: workspace.parent?.name ?? "",
|
|
1010
|
+
projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
|
|
704
1011
|
};
|
|
705
1012
|
const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
|
|
706
1013
|
const filename = params.filename ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
|
|
@@ -726,6 +1033,8 @@ ${manifest}
|
|
|
726
1033
|
action: Type.String({ description: "status, commit, or push" }),
|
|
727
1034
|
message: Type.Optional(Type.String()),
|
|
728
1035
|
targetTags: Type.Optional(Type.Array(Type.String())),
|
|
1036
|
+
targetName: Type.Optional(Type.String()),
|
|
1037
|
+
targetId: Type.Optional(Type.String()),
|
|
729
1038
|
workspaceName: Type.Optional(Type.String()),
|
|
730
1039
|
}),
|
|
731
1040
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
@@ -733,35 +1042,39 @@ ${manifest}
|
|
|
733
1042
|
const loaded = await loadConfig(ctx.cwd);
|
|
734
1043
|
const workspace = await resolveWorkspace(ctx, loaded, {
|
|
735
1044
|
targetTags: params.targetTags,
|
|
1045
|
+
targetName: params.targetName,
|
|
1046
|
+
targetId: params.targetId,
|
|
736
1047
|
workspaceName: params.workspaceName,
|
|
737
1048
|
requireCapabilities: required,
|
|
738
1049
|
});
|
|
739
|
-
|
|
1050
|
+
const root = await gitRoot(workspace);
|
|
1051
|
+
if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
|
|
740
1052
|
if (params.action === "status") {
|
|
741
|
-
const result = await runCommand("git", ["-C",
|
|
1053
|
+
const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { signal, timeout: 10000 });
|
|
742
1054
|
return { content: [{ type: "text", text: result.stdout || "clean" }], details: { workspace } };
|
|
743
1055
|
}
|
|
744
1056
|
if (params.action === "commit") {
|
|
745
1057
|
const message = params.message ?? `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
|
|
746
|
-
const status = await runCommand("git", ["-C",
|
|
747
|
-
const diffstat = await runCommand("git", ["-C",
|
|
748
|
-
const
|
|
1058
|
+
const status = await runCommand("git", ["-C", root, "status", "--short"], { signal, timeout: 10000 });
|
|
1059
|
+
const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { signal, timeout: 10000 });
|
|
1060
|
+
const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
|
|
1061
|
+
const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus (repo full):\n${status.stdout || "clean"}\n\nDiffstat (repo full):\n${diffstat.stdout || "none"}\n\nCommit scope:\n${scope}\n\nCommit message:\n${message}\n\nStage scoped changes and commit?`);
|
|
749
1062
|
if (!ok) return { content: [{ type: "text", text: "Commit cancelled" }], details: { cancelled: true } };
|
|
750
|
-
await runCommand("git", ["-C",
|
|
751
|
-
const commit = await runCommand("git", ["-C",
|
|
1063
|
+
await runCommand("git", ["-C", root, "add", "-A", "--", scope], { signal, timeout: 10000 });
|
|
1064
|
+
const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { signal, timeout: 30000 });
|
|
752
1065
|
return { content: [{ type: "text", text: commit.stdout || commit.stderr }], details: { workspace, message } };
|
|
753
1066
|
}
|
|
754
1067
|
if (params.action === "push") {
|
|
755
|
-
const branch = await runCommand("git", ["-C",
|
|
756
|
-
const remote = await runCommand("git", ["-C",
|
|
757
|
-
const log = await runCommand("git", ["-C",
|
|
1068
|
+
const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { signal, timeout: 10000 });
|
|
1069
|
+
const remote = await runCommand("git", ["-C", root, "remote", "-v"], { signal, timeout: 10000 });
|
|
1070
|
+
const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
|
|
758
1071
|
signal,
|
|
759
1072
|
timeout: 10000,
|
|
760
1073
|
allowExitCodes: [0, 128],
|
|
761
1074
|
});
|
|
762
|
-
const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
|
|
1075
|
+
const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nPush is repository/branch scoped.\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
|
|
763
1076
|
if (!ok) return { content: [{ type: "text", text: "Push cancelled" }], details: { cancelled: true } };
|
|
764
|
-
const push = await runCommand("git", ["-C",
|
|
1077
|
+
const push = await runCommand("git", ["-C", root, "push"], { signal, timeout: 60000 });
|
|
765
1078
|
return { content: [{ type: "text", text: push.stdout || push.stderr }], details: { workspace } };
|
|
766
1079
|
}
|
|
767
1080
|
throw new Error(`Unknown monofold_git action: ${params.action}`);
|
|
@@ -863,6 +1176,10 @@ ${manifest}
|
|
|
863
1176
|
routeType,
|
|
864
1177
|
workspaceName: workspace.name ?? "",
|
|
865
1178
|
workspaceTags: workspace.tags.join(","),
|
|
1179
|
+
targetName: workspace.name ?? "",
|
|
1180
|
+
targetTags: workspace.tags.join(","),
|
|
1181
|
+
parentWorkspaceName: workspace.parent?.name ?? "",
|
|
1182
|
+
projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
|
|
866
1183
|
};
|
|
867
1184
|
const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
|
|
868
1185
|
const filename = stringFlag(parsed.flags, "filename", "file", "f") ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
|
|
@@ -892,10 +1209,11 @@ ${manifest}
|
|
|
892
1209
|
const required: CapabilityTag[] = action === "push" ? ["gitPush"] : action === "commit" ? ["gitCommit"] : [];
|
|
893
1210
|
const loaded = await loadConfig(ctx.cwd);
|
|
894
1211
|
const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, required));
|
|
895
|
-
|
|
1212
|
+
const root = await gitRoot(workspace);
|
|
1213
|
+
if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
|
|
896
1214
|
|
|
897
1215
|
if (action === "status") {
|
|
898
|
-
const result = await runCommand("git", ["-C",
|
|
1216
|
+
const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { timeout: 10000 });
|
|
899
1217
|
sendCommandOutput(pi, `monofold:git status ${formatWorkspaceLabel(workspace)}`, result.stdout || "clean", { workspace });
|
|
900
1218
|
return;
|
|
901
1219
|
}
|
|
@@ -903,32 +1221,33 @@ ${manifest}
|
|
|
903
1221
|
if (action === "commit") {
|
|
904
1222
|
const parsedMessage = stringFlag(parsed.flags, "message", "m") ?? parsed.positional.slice(1).join(" ");
|
|
905
1223
|
const message = parsedMessage || `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
|
|
906
|
-
const status = await runCommand("git", ["-C",
|
|
907
|
-
const diffstat = await runCommand("git", ["-C",
|
|
908
|
-
const
|
|
1224
|
+
const status = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 10000 });
|
|
1225
|
+
const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { timeout: 10000 });
|
|
1226
|
+
const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
|
|
1227
|
+
const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus (repo full):\n${status.stdout || "clean"}\n\nDiffstat (repo full):\n${diffstat.stdout || "none"}\n\nCommit scope:\n${scope}\n\nCommit message:\n${message}\n\nStage scoped changes and commit?`);
|
|
909
1228
|
if (!ok) {
|
|
910
1229
|
sendCommandOutput(pi, "monofold:git commit", "Commit cancelled", { cancelled: true });
|
|
911
1230
|
return;
|
|
912
1231
|
}
|
|
913
|
-
await runCommand("git", ["-C",
|
|
914
|
-
const commit = await runCommand("git", ["-C",
|
|
1232
|
+
await runCommand("git", ["-C", root, "add", "-A", "--", scope], { timeout: 10000 });
|
|
1233
|
+
const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { timeout: 30000 });
|
|
915
1234
|
sendCommandOutput(pi, `monofold:git commit ${formatWorkspaceLabel(workspace)}`, commit.stdout || commit.stderr, { workspace, message });
|
|
916
1235
|
return;
|
|
917
1236
|
}
|
|
918
1237
|
|
|
919
1238
|
if (action === "push") {
|
|
920
|
-
const branch = await runCommand("git", ["-C",
|
|
921
|
-
const remote = await runCommand("git", ["-C",
|
|
922
|
-
const log = await runCommand("git", ["-C",
|
|
1239
|
+
const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { timeout: 10000 });
|
|
1240
|
+
const remote = await runCommand("git", ["-C", root, "remote", "-v"], { timeout: 10000 });
|
|
1241
|
+
const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
|
|
923
1242
|
timeout: 10000,
|
|
924
1243
|
allowExitCodes: [0, 128],
|
|
925
1244
|
});
|
|
926
|
-
const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
|
|
1245
|
+
const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nPush is repository/branch scoped.\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
|
|
927
1246
|
if (!ok) {
|
|
928
1247
|
sendCommandOutput(pi, "monofold:git push", "Push cancelled", { cancelled: true });
|
|
929
1248
|
return;
|
|
930
1249
|
}
|
|
931
|
-
const push = await runCommand("git", ["-C",
|
|
1250
|
+
const push = await runCommand("git", ["-C", root, "push"], { timeout: 60000 });
|
|
932
1251
|
sendCommandOutput(pi, `monofold:git push ${formatWorkspaceLabel(workspace)}`, push.stdout || push.stderr, { workspace });
|
|
933
1252
|
return;
|
|
934
1253
|
}
|
|
@@ -950,8 +1269,8 @@ ${manifest}
|
|
|
950
1269
|
const addCommand = async (args: string, ctx: ExtensionCommandContext) => {
|
|
951
1270
|
try {
|
|
952
1271
|
const workspaceBlock = buildWorkspaceFromAddArgs(args);
|
|
953
|
-
const
|
|
954
|
-
await addWorkspaceToConfig(configPath, workspaceBlock);
|
|
1272
|
+
const configFile = await resolveConfigFile(ctx.cwd, true);
|
|
1273
|
+
await addWorkspaceToConfig(configFile.configPath, workspaceBlock);
|
|
955
1274
|
const loaded = await loadConfig(ctx.cwd);
|
|
956
1275
|
sendCommandOutput(pi, "monofold:add", `Added workspace:\n${YAML.stringify(workspaceBlock).trim()}\n\n${await buildManifest(loaded)}`, {
|
|
957
1276
|
workspace: workspaceBlock,
|
|
@@ -961,6 +1280,62 @@ ${manifest}
|
|
|
961
1280
|
}
|
|
962
1281
|
};
|
|
963
1282
|
|
|
1283
|
+
const projectAddUsage = [
|
|
1284
|
+
"/monofold:project-add <path> --parent \"Workspace Name\" --tags project,slug",
|
|
1285
|
+
"Parent by tags: --parent-tags vault,docs",
|
|
1286
|
+
"Optional: --name \"Name\" --capabilities read,writeDocs --context CONTEXT.md --routes default=.,progress=Progress",
|
|
1287
|
+
"Alias: /monofold_project_add ...",
|
|
1288
|
+
].join("\n");
|
|
1289
|
+
|
|
1290
|
+
const projectAddCommand = async (args: string, ctx: ExtensionCommandContext) => {
|
|
1291
|
+
try {
|
|
1292
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
1293
|
+
const { project, parent } = buildProjectFromAddArgs(args);
|
|
1294
|
+
await addProjectToConfig(ctx, loaded, project, parent);
|
|
1295
|
+
const next = await loadConfig(ctx.cwd);
|
|
1296
|
+
sendCommandOutput(pi, "monofold:project-add", `Added project:\n${YAML.stringify(project).trim()}\n\n${await buildManifest(next)}`, { project });
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
sendCommandError(pi, "monofold:project-add", error, projectAddUsage);
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
const updateUsage = [
|
|
1303
|
+
"/monofold:update [natural language configuration change request]",
|
|
1304
|
+
`Migrates ${LEGACY_CONFIG_RELATIVE_PATH} to ${CONFIG_RELATIVE_PATH}, normalizes YAML, and validates the manifest.`,
|
|
1305
|
+
"If a request is provided, it is handed off to the Pi agent after successful migration.",
|
|
1306
|
+
].join("\n");
|
|
1307
|
+
|
|
1308
|
+
const updateCommand = async (args: string, ctx: ExtensionCommandContext) => {
|
|
1309
|
+
try {
|
|
1310
|
+
const plan = await buildConfigMigrationPlan(ctx.cwd);
|
|
1311
|
+
if (plan.changed && ctx.hasUI) {
|
|
1312
|
+
const ok = await ctx.ui.confirm("Monofold Update", `${formatMigrationPlan(plan)}\n\nApply migration?`);
|
|
1313
|
+
if (!ok) {
|
|
1314
|
+
sendCommandOutput(pi, "monofold:update", "Update cancelled", { cancelled: true });
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
await applyConfigMigrationPlan(plan);
|
|
1320
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
1321
|
+
sendCommandOutput(pi, "monofold:update", `${formatMigrationPlan(plan)}\n\nManifest validation: OK (${loaded.workspaces.length} targets)`, {
|
|
1322
|
+
changed: plan.changed,
|
|
1323
|
+
configPath: CONFIG_RELATIVE_PATH,
|
|
1324
|
+
backupPath: plan.backupRelativePath,
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
let request = args.trim();
|
|
1328
|
+
if (!request && ctx.hasUI) {
|
|
1329
|
+
request = (await ctx.ui.input("Optional: describe workspace/project configuration changes to apply now", ""))?.trim() ?? "";
|
|
1330
|
+
}
|
|
1331
|
+
if (request) {
|
|
1332
|
+
pi.sendUserMessage(buildConfigurationHandoffPrompt(request), { deliverAs: "followUp" });
|
|
1333
|
+
}
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
sendCommandError(pi, "monofold:update", error, updateUsage);
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
964
1339
|
pi.registerCommand("monofold:list", { description: "List configured Pi Monofold workspaces", handler: listCommand });
|
|
965
1340
|
pi.registerCommand("monofold_list", { description: "Alias for /monofold:list", handler: listCommand });
|
|
966
1341
|
pi.registerCommand("monofold:tree", { description: "Show a tree for a configured workspace", handler: readCommand });
|
|
@@ -971,18 +1346,48 @@ ${manifest}
|
|
|
971
1346
|
pi.registerCommand("monofold_write", { description: "Alias for /monofold:write", handler: writeCommand });
|
|
972
1347
|
pi.registerCommand("monofold:git", { description: "Run guarded workspace git status, commit, or push", handler: gitCommand });
|
|
973
1348
|
pi.registerCommand("monofold_git", { description: "Alias for /monofold:git", handler: gitCommand });
|
|
974
|
-
pi.registerCommand("monofold:add", { description:
|
|
1349
|
+
pi.registerCommand("monofold:add", { description: `Add a workspace to ${CONFIG_RELATIVE_PATH}`, handler: addCommand });
|
|
975
1350
|
pi.registerCommand("monofold_add", { description: "Alias for /monofold:add", handler: addCommand });
|
|
1351
|
+
pi.registerCommand("monofold:project-add", { description: "Add a project workspace under a parent workspace", handler: projectAddCommand });
|
|
1352
|
+
pi.registerCommand("monofold_project_add", { description: "Alias for /monofold:project-add", handler: projectAddCommand });
|
|
1353
|
+
pi.registerCommand("monofold:update", { description: `Migrate and validate ${CONFIG_RELATIVE_PATH}`, handler: updateCommand });
|
|
976
1354
|
|
|
977
1355
|
const initCommand = async (_args: string, ctx: ExtensionCommandContext) => {
|
|
978
1356
|
if (!ctx.hasUI) {
|
|
979
1357
|
ctx.ui.notify("monofold:init requires interactive UI", "error");
|
|
980
1358
|
return;
|
|
981
1359
|
}
|
|
982
|
-
const
|
|
983
|
-
const
|
|
1360
|
+
const configFile = await resolveConfigFile(ctx.cwd, true);
|
|
1361
|
+
const configPath = configFile.configPath;
|
|
1362
|
+
const exists = configFile.kind !== "missing";
|
|
1363
|
+
const addKind = exists ? await ctx.ui.select("What do you want to add?", ["Workspace", "Project Workspace"]) : "Workspace";
|
|
1364
|
+
if (!addKind) return;
|
|
1365
|
+
if (addKind === "Project Workspace") {
|
|
1366
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
1367
|
+
const parentLabels = loaded.workspaces.filter((workspace) => workspace.kind === "workspace").map(formatWorkspaceLabel);
|
|
1368
|
+
const parentLabel = await ctx.ui.select("Parent workspace", parentLabels);
|
|
1369
|
+
if (!parentLabel) return;
|
|
1370
|
+
const parent = loaded.workspaces.filter((workspace) => workspace.kind === "workspace")[parentLabels.indexOf(parentLabel)];
|
|
1371
|
+
const projectPath = await ctx.ui.input("Project path relative to parent workspace", "4_Project/Example");
|
|
1372
|
+
if (!projectPath) return;
|
|
1373
|
+
const name = await ctx.ui.input("Optional project name", "");
|
|
1374
|
+
const tagsInput = await ctx.ui.input("Project tags comma-separated", "project,example");
|
|
1375
|
+
if (!tagsInput) return;
|
|
1376
|
+
const capsInput = await ctx.ui.input("Optional capabilities override comma-separated", "");
|
|
1377
|
+
const routesInput = await ctx.ui.input("Optional routes (default path or key=path list)", "");
|
|
1378
|
+
const project: ProjectConfig = {
|
|
1379
|
+
...(name?.trim() ? { name: name.trim() } : {}),
|
|
1380
|
+
path: projectPath.trim(),
|
|
1381
|
+
tags: tagsInput.split(",").map((s) => s.trim()).filter(Boolean),
|
|
1382
|
+
...(capsInput?.trim() ? { capabilities: asCapabilityArray(capsInput.split(",").map((s) => s.trim()).filter(Boolean)) } : {}),
|
|
1383
|
+
...(routesInput?.trim() ? { routes: routesInput.includes("=") ? Object.fromEntries(routesInput.split(",").map((entry) => entry.split("=").map((part) => part.trim()))) as ProjectConfig["routes"] : { default: routesInput.trim() } } : {}),
|
|
1384
|
+
};
|
|
1385
|
+
await addProjectToConfig(ctx, loaded, project, { targetId: parent.targetId });
|
|
1386
|
+
ctx.ui.notify(`Updated ${normalizeSlashes(path.relative(ctx.cwd, loaded.configPath))}`, "info");
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
984
1389
|
if (exists) {
|
|
985
|
-
const ok = await ctx.ui.confirm("Existing config", `${
|
|
1390
|
+
const ok = await ctx.ui.confirm("Existing config", `${configFile.relativePath} exists. Append a new workspace?`);
|
|
986
1391
|
if (!ok) return;
|
|
987
1392
|
}
|
|
988
1393
|
const workspacePath = await ctx.ui.input("Workspace path", "../business");
|
|
@@ -1010,11 +1415,11 @@ ${manifest}
|
|
|
1010
1415
|
const next = exists ? `${current.trimEnd()}\n${addition}\n` : `version: 1\n\nworkspaces:\n${addition}\n`;
|
|
1011
1416
|
await mkdir(path.dirname(configPath), { recursive: true });
|
|
1012
1417
|
await writeFile(configPath, next, "utf8");
|
|
1013
|
-
ctx.ui.notify(`Updated ${
|
|
1418
|
+
ctx.ui.notify(`Updated ${configFile.relativePath}`, "info");
|
|
1014
1419
|
};
|
|
1015
1420
|
|
|
1016
1421
|
pi.registerCommand("monofold:init", {
|
|
1017
|
-
description:
|
|
1422
|
+
description: `Create or update ${CONFIG_RELATIVE_PATH} with an interactive wizard`,
|
|
1018
1423
|
handler: initCommand,
|
|
1019
1424
|
});
|
|
1020
1425
|
pi.registerCommand("monofold_init", {
|
|
@@ -1025,7 +1430,7 @@ ${manifest}
|
|
|
1025
1430
|
pi.registerTool({
|
|
1026
1431
|
name: "monofold_init",
|
|
1027
1432
|
label: "Workspace Init",
|
|
1028
|
-
description:
|
|
1433
|
+
description: `Queue the interactive /monofold:init command to create or update ${CONFIG_RELATIVE_PATH}.`,
|
|
1029
1434
|
parameters: Type.Object({}),
|
|
1030
1435
|
async execute() {
|
|
1031
1436
|
pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
|
package/package.json
CHANGED