pi-monofold 0.0.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/README.md +155 -0
- package/index.ts +1073 -0
- package/package.json +44 -0
- package/tsconfig.json +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Pi Monofold
|
|
2
|
+
|
|
3
|
+
Pi Monofold (`pi-monofold`) is a Pi Coding Agent extension that folds multiple local repositories and folders into a guarded **Virtual Monorepo** for AI agents.
|
|
4
|
+
|
|
5
|
+
It keeps repositories physically separate, while giving Pi a lightweight manifest, routed writes, workspace-aware reads, guarded commands, and explicit git flows.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
AI coding agents work best when documentation, rules, product context, and implementation code are visible as one connected system. Physical monorepos are not always practical. Pi Monofold gives Pi a logical monorepo boundary without forcing repository migration.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Pi Monofold is a Pi package. Install it with Pi's package installer from git or npm.
|
|
14
|
+
|
|
15
|
+
> Security: Pi packages run with full system access. Review packages before installing third-party code.
|
|
16
|
+
|
|
17
|
+
### From git
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
pi install git:github.com/eiei114/pi-monofold
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Install into the current project settings instead of user settings:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
pi install -l git:github.com/eiei114/pi-monofold
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Pin a version/ref:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
pi install git:github.com/eiei114/pi-monofold@v0.1.0
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Try without installing:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
pi -e git:github.com/eiei114/pi-monofold
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### From npm
|
|
42
|
+
|
|
43
|
+
After the package is published to npm:
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
pi install npm:pi-monofold
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Install into the current project settings instead of user settings:
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
pi install -l npm:pi-monofold
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Pin a version:
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
pi install npm:pi-monofold@0.1.0
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Try without installing:
|
|
62
|
+
|
|
63
|
+
```powershell
|
|
64
|
+
pi -e npm:pi-monofold
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Local development
|
|
68
|
+
|
|
69
|
+
```powershell
|
|
70
|
+
git clone https://github.com/eiei114/pi-monofold.git
|
|
71
|
+
cd pi-monofold
|
|
72
|
+
npm install
|
|
73
|
+
npm run typecheck
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Try the local checkout without installing:
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
pi -e .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Config
|
|
83
|
+
|
|
84
|
+
Place config in the control repository:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
<control-repo>/.pi/monofold.yml
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
version: 1
|
|
94
|
+
|
|
95
|
+
defaults:
|
|
96
|
+
filenameTemplate: "{{date}}-{{slug}}.md"
|
|
97
|
+
metadata:
|
|
98
|
+
created: "{{date}}"
|
|
99
|
+
source: "pi-monofold"
|
|
100
|
+
|
|
101
|
+
workspaces:
|
|
102
|
+
- name: "Product docs"
|
|
103
|
+
path: "../business"
|
|
104
|
+
tags: [business, markdown, planning]
|
|
105
|
+
capabilities: [read, writeDocs, gitCommit]
|
|
106
|
+
contextFiles: [README.md, CONTEXT.md]
|
|
107
|
+
routes:
|
|
108
|
+
default: "Notes"
|
|
109
|
+
prd:
|
|
110
|
+
path: "Docs/PRD"
|
|
111
|
+
filenameTemplate: "prd-{{slug}}.md"
|
|
112
|
+
metadata:
|
|
113
|
+
type: prd
|
|
114
|
+
|
|
115
|
+
- name: "Application"
|
|
116
|
+
path: "../app"
|
|
117
|
+
tags: [development, app]
|
|
118
|
+
capabilities: [read, editCode, runCommands, gitCommit, gitPush]
|
|
119
|
+
contextFiles: [README.md, AGENTS.md]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Tools
|
|
123
|
+
|
|
124
|
+
- `monofold_list`: show manifest and git status summary.
|
|
125
|
+
- `monofold_read`: read files, search text, or show a tree inside readable workspaces.
|
|
126
|
+
- `monofold_write`: create routed Markdown outputs by `routeType`, `title`, and `body`.
|
|
127
|
+
- `monofold_git`: run guarded workspace git `status`, `commit`, or `push`.
|
|
128
|
+
- `monofold_init`: queue `/monofold:init`.
|
|
129
|
+
|
|
130
|
+
## Commands
|
|
131
|
+
|
|
132
|
+
- `/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.yml`.
|
|
134
|
+
- `/monofold:read file <path> --workspace #0`: read a file from a workspace.
|
|
135
|
+
- `/monofold:tree [path] --workspace #0 --depth 2`: show a workspace tree.
|
|
136
|
+
- `/monofold:search <query> --workspace #0`: search a workspace.
|
|
137
|
+
- `/monofold:write --route progress --title "Title" --body "Markdown body"`: write routed Markdown.
|
|
138
|
+
- `/monofold:git status|commit|push --workspace #0`: run guarded workspace git.
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
/monofold:add C:/Projects/app --name "Application" --tags development,app --capabilities read,editCode,runCommands,gitCommit --context README.md,AGENTS.md
|
|
144
|
+
/monofold:add ../business --name "Product Docs" --tags business,docs --capabilities read,writeDocs,gitCommit --routes default=Notes,progress=Progress,research=Research
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Guard
|
|
148
|
+
|
|
149
|
+
When `.pi/monofold.yml` exists, Pi Monofold guards standard `read/write/edit/grep/find/bash` calls against workspace capabilities.
|
|
150
|
+
|
|
151
|
+
- Unknown path: confirm in UI, block without UI.
|
|
152
|
+
- Docs write: requires `writeDocs`.
|
|
153
|
+
- Code edit: requires `editCode`.
|
|
154
|
+
- Bash: requires workspace cwd and `runCommands`.
|
|
155
|
+
- Git commit/push via bash: blocked; use `monofold_git`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import YAML from "yaml";
|
|
7
|
+
|
|
8
|
+
type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "gitCommit" | "gitPush";
|
|
9
|
+
type RouteType = "default" | "prd" | "design" | "progress" | "issue" | "research" | "decision";
|
|
10
|
+
|
|
11
|
+
type RouteConfig = {
|
|
12
|
+
path: string;
|
|
13
|
+
filenameTemplate?: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type WorkspaceConfig = {
|
|
18
|
+
name?: string;
|
|
19
|
+
path: string;
|
|
20
|
+
tags: string[];
|
|
21
|
+
capabilities: CapabilityTag[];
|
|
22
|
+
contextFiles?: string[];
|
|
23
|
+
routes?: Partial<Record<RouteType, string | RouteConfig>>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type MultiWorkspaceConfig = {
|
|
27
|
+
version: 1;
|
|
28
|
+
defaults?: {
|
|
29
|
+
contextFiles?: string[];
|
|
30
|
+
filenameTemplate?: string;
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
};
|
|
33
|
+
workspaces: WorkspaceConfig[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ResolvedWorkspace = WorkspaceConfig & {
|
|
37
|
+
index: number;
|
|
38
|
+
resolvedPath: string;
|
|
39
|
+
normalizedRoutes: Partial<Record<RouteType, RouteConfig>>;
|
|
40
|
+
effectiveContextFiles: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type LoadedConfig = {
|
|
44
|
+
configPath: string;
|
|
45
|
+
root: string;
|
|
46
|
+
raw: MultiWorkspaceConfig;
|
|
47
|
+
workspaces: ResolvedWorkspace[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type TargetInput = {
|
|
51
|
+
targetTags?: string[];
|
|
52
|
+
workspaceName?: string;
|
|
53
|
+
workspaceIndex?: number;
|
|
54
|
+
requireCapabilities?: CapabilityTag[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type ParsedCommandArgs = {
|
|
58
|
+
positional: string[];
|
|
59
|
+
flags: Record<string, string | boolean>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type CommandResult = {
|
|
63
|
+
stdout: string;
|
|
64
|
+
stderr: string;
|
|
65
|
+
exitCode: number | string | null | undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yml");
|
|
69
|
+
const ROUTE_TYPES: RouteType[] = ["default", "prd", "design", "progress", "issue", "research", "decision"];
|
|
70
|
+
const CAPABILITIES: CapabilityTag[] = ["read", "writeDocs", "editCode", "runCommands", "gitCommit", "gitPush"];
|
|
71
|
+
const DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]);
|
|
72
|
+
const CODE_EXTENSIONS = new Set([
|
|
73
|
+
".ts",
|
|
74
|
+
".tsx",
|
|
75
|
+
".js",
|
|
76
|
+
".jsx",
|
|
77
|
+
".mjs",
|
|
78
|
+
".cjs",
|
|
79
|
+
".json",
|
|
80
|
+
".yml",
|
|
81
|
+
".yaml",
|
|
82
|
+
".toml",
|
|
83
|
+
".rs",
|
|
84
|
+
".go",
|
|
85
|
+
".py",
|
|
86
|
+
".rb",
|
|
87
|
+
".java",
|
|
88
|
+
".kt",
|
|
89
|
+
".swift",
|
|
90
|
+
".cs",
|
|
91
|
+
".cpp",
|
|
92
|
+
".c",
|
|
93
|
+
".h",
|
|
94
|
+
".css",
|
|
95
|
+
".scss",
|
|
96
|
+
".html",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeSlashes(value: string): string {
|
|
104
|
+
return value.replace(/\\/g, "/");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isInside(parent: string, child: string): boolean {
|
|
108
|
+
const relative = path.relative(parent, child);
|
|
109
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertWorkspaceInternalRelative(label: string, value: string): void {
|
|
113
|
+
if (path.isAbsolute(value) || normalizeSlashes(value).split("/").includes("..")) {
|
|
114
|
+
throw new Error(`${label} must be a workspace-internal relative path: ${value}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function asStringArray(label: string, value: unknown, required = true): string[] {
|
|
119
|
+
if (value === undefined && !required) return [];
|
|
120
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
121
|
+
throw new Error(`${label} must be an array of strings`);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function asCapabilityArray(value: unknown): CapabilityTag[] {
|
|
127
|
+
const items = asStringArray("capabilities", value);
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
if (!CAPABILITIES.includes(item as CapabilityTag)) {
|
|
130
|
+
throw new Error(`Unknown capability: ${item}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return items as CapabilityTag[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeRoute(routeType: string, value: unknown): RouteConfig {
|
|
137
|
+
if (!ROUTE_TYPES.includes(routeType as RouteType)) {
|
|
138
|
+
throw new Error(`Unknown route type: ${routeType}`);
|
|
139
|
+
}
|
|
140
|
+
if (typeof value === "string") {
|
|
141
|
+
assertWorkspaceInternalRelative(`routes.${routeType}`, value);
|
|
142
|
+
return { path: value };
|
|
143
|
+
}
|
|
144
|
+
if (!isRecord(value) || typeof value.path !== "string") {
|
|
145
|
+
throw new Error(`routes.${routeType} must be a string path or object with path`);
|
|
146
|
+
}
|
|
147
|
+
assertWorkspaceInternalRelative(`routes.${routeType}.path`, value.path);
|
|
148
|
+
if (value.filenameTemplate !== undefined && typeof value.filenameTemplate !== "string") {
|
|
149
|
+
throw new Error(`routes.${routeType}.filenameTemplate must be a string`);
|
|
150
|
+
}
|
|
151
|
+
if (value.metadata !== undefined && !isRecord(value.metadata)) {
|
|
152
|
+
throw new Error(`routes.${routeType}.metadata must be an object`);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
path: value.path,
|
|
156
|
+
filenameTemplate: value.filenameTemplate,
|
|
157
|
+
metadata: value.metadata as Record<string, unknown> | undefined,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function pathExists(targetPath: string): Promise<boolean> {
|
|
162
|
+
try {
|
|
163
|
+
await access(targetPath);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function runCommand(
|
|
171
|
+
command: string,
|
|
172
|
+
args: string[],
|
|
173
|
+
options: { cwd?: string; timeout?: number; signal?: AbortSignal; allowExitCodes?: Array<number | string> } = {},
|
|
174
|
+
): Promise<CommandResult> {
|
|
175
|
+
const allowExitCodes = new Set<number | string>(options.allowExitCodes ?? [0]);
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
execFile(
|
|
178
|
+
command,
|
|
179
|
+
args,
|
|
180
|
+
{
|
|
181
|
+
cwd: options.cwd,
|
|
182
|
+
timeout: options.timeout ?? 10000,
|
|
183
|
+
signal: options.signal,
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
186
|
+
},
|
|
187
|
+
(error, stdout, stderr) => {
|
|
188
|
+
const exitCode = typeof error === "object" && error && "code" in error ? (error as { code?: number | string }).code : 0;
|
|
189
|
+
const result = { stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), exitCode };
|
|
190
|
+
if (!error || allowExitCodes.has(exitCode ?? 0)) {
|
|
191
|
+
resolve(result);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
reject(new Error(`${command} ${args.join(" ")} failed (${String(exitCode)}): ${result.stderr || result.stdout}`));
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function loadConfig(cwd: string): Promise<LoadedConfig> {
|
|
201
|
+
const configPath = path.join(cwd, CONFIG_RELATIVE_PATH);
|
|
202
|
+
const text = await readFile(configPath, "utf8");
|
|
203
|
+
const parsed = YAML.parse(text, { uniqueKeys: true }) as unknown;
|
|
204
|
+
if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
|
|
205
|
+
if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
|
|
206
|
+
if (!Array.isArray(parsed.workspaces) || parsed.workspaces.length === 0) {
|
|
207
|
+
throw new Error("monofold config requires non-empty workspaces array");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const defaults = isRecord(parsed.defaults) ? parsed.defaults : undefined;
|
|
211
|
+
const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
|
|
212
|
+
const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
|
|
213
|
+
const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
|
|
214
|
+
|
|
215
|
+
const workspaces = parsed.workspaces.map((item, index): ResolvedWorkspace => {
|
|
216
|
+
if (!isRecord(item)) throw new Error(`workspaces[${index}] must be an object`);
|
|
217
|
+
if (item.name !== undefined && typeof item.name !== "string") throw new Error(`workspaces[${index}].name must be string`);
|
|
218
|
+
if (typeof item.path !== "string") throw new Error(`workspaces[${index}].path is required`);
|
|
219
|
+
const tags = asStringArray(`workspaces[${index}].tags`, item.tags);
|
|
220
|
+
const capabilities = asCapabilityArray(item.capabilities);
|
|
221
|
+
const contextFiles = asStringArray(`workspaces[${index}].contextFiles`, item.contextFiles, false);
|
|
222
|
+
for (const contextFile of [...defaultContextFiles, ...contextFiles]) {
|
|
223
|
+
assertWorkspaceInternalRelative(`workspaces[${index}].contextFiles`, contextFile);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const routes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(item.routes)
|
|
227
|
+
? (item.routes as Partial<Record<RouteType, string | RouteConfig>>)
|
|
228
|
+
: undefined;
|
|
229
|
+
if (capabilities.includes("writeDocs") && !routes) {
|
|
230
|
+
throw new Error(`workspaces[${index}] has writeDocs but no routes`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const normalizedRoutes: Partial<Record<RouteType, RouteConfig>> = {};
|
|
234
|
+
if (routes) {
|
|
235
|
+
for (const [routeType, routeValue] of Object.entries(routes)) {
|
|
236
|
+
normalizedRoutes[routeType as RouteType] = normalizeRoute(routeType, routeValue);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const resolvedPath = path.resolve(cwd, item.path);
|
|
241
|
+
return {
|
|
242
|
+
name: item.name as string | undefined,
|
|
243
|
+
path: item.path,
|
|
244
|
+
tags,
|
|
245
|
+
capabilities,
|
|
246
|
+
contextFiles,
|
|
247
|
+
routes,
|
|
248
|
+
index,
|
|
249
|
+
resolvedPath,
|
|
250
|
+
normalizedRoutes,
|
|
251
|
+
effectiveContextFiles: [...defaultContextFiles, ...contextFiles],
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
configPath,
|
|
257
|
+
root: cwd,
|
|
258
|
+
raw: {
|
|
259
|
+
version: 1,
|
|
260
|
+
defaults: {
|
|
261
|
+
contextFiles: defaultContextFiles,
|
|
262
|
+
filenameTemplate: defaultFilenameTemplate,
|
|
263
|
+
metadata: defaultMetadata,
|
|
264
|
+
},
|
|
265
|
+
workspaces,
|
|
266
|
+
},
|
|
267
|
+
workspaces,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function matchesTarget(workspace: ResolvedWorkspace, target: TargetInput): boolean {
|
|
272
|
+
if (target.workspaceIndex !== undefined && workspace.index !== target.workspaceIndex) return false;
|
|
273
|
+
if (target.workspaceName && workspace.name !== target.workspaceName) return false;
|
|
274
|
+
if (target.targetTags?.length && !target.targetTags.every((tag) => workspace.tags.includes(tag))) return false;
|
|
275
|
+
if (target.requireCapabilities?.length) {
|
|
276
|
+
if (!target.requireCapabilities.every((cap) => workspace.capabilities.includes(cap))) return false;
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function splitCommandArgs(input: string): string[] {
|
|
282
|
+
const tokens: string[] = [];
|
|
283
|
+
const pattern = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
|
|
284
|
+
let match: RegExpExecArray | null;
|
|
285
|
+
while ((match = pattern.exec(input))) {
|
|
286
|
+
const token = match[1] ?? match[2] ?? match[3] ?? "";
|
|
287
|
+
tokens.push(token.replace(/\\(["\\])/g, "$1"));
|
|
288
|
+
}
|
|
289
|
+
return tokens;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function parseCommandArgs(input: string): ParsedCommandArgs {
|
|
293
|
+
const positional: string[] = [];
|
|
294
|
+
const flags: Record<string, string | boolean> = {};
|
|
295
|
+
const tokens = splitCommandArgs(input);
|
|
296
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
297
|
+
const token = tokens[index];
|
|
298
|
+
if (!token.startsWith("--") || token === "--") {
|
|
299
|
+
positional.push(token);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const raw = token.slice(2);
|
|
303
|
+
const equalsIndex = raw.indexOf("=");
|
|
304
|
+
if (equalsIndex >= 0) {
|
|
305
|
+
flags[raw.slice(0, equalsIndex)] = raw.slice(equalsIndex + 1);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const next = tokens[index + 1];
|
|
309
|
+
if (next && !next.startsWith("--")) {
|
|
310
|
+
flags[raw] = next;
|
|
311
|
+
index += 1;
|
|
312
|
+
} else {
|
|
313
|
+
flags[raw] = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { positional, flags };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function stringFlag(flags: Record<string, string | boolean>, ...names: string[]): string | undefined {
|
|
320
|
+
for (const name of names) {
|
|
321
|
+
const value = flags[name];
|
|
322
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function commandTarget(flags: Record<string, string | boolean>, requireCapabilities?: CapabilityTag[]): TargetInput {
|
|
328
|
+
const workspace = stringFlag(flags, "workspace", "w");
|
|
329
|
+
const tags = stringFlag(flags, "tags", "tag")
|
|
330
|
+
?.split(",")
|
|
331
|
+
.map((item) => item.trim())
|
|
332
|
+
.filter(Boolean);
|
|
333
|
+
const workspaceIndex = workspace?.startsWith("#") ? Number.parseInt(workspace.slice(1), 10) : undefined;
|
|
334
|
+
return {
|
|
335
|
+
...(workspace && workspaceIndex === undefined ? { workspaceName: workspace } : {}),
|
|
336
|
+
...(workspaceIndex !== undefined && Number.isFinite(workspaceIndex) ? { workspaceIndex } : {}),
|
|
337
|
+
...(tags?.length ? { targetTags: tags } : {}),
|
|
338
|
+
requireCapabilities,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function metadataFlag(flags: Record<string, string | boolean>): Record<string, string> {
|
|
343
|
+
const raw = stringFlag(flags, "meta", "metadata");
|
|
344
|
+
if (!raw) return {};
|
|
345
|
+
return Object.fromEntries(
|
|
346
|
+
raw
|
|
347
|
+
.split(",")
|
|
348
|
+
.map((item) => item.trim())
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
.map((item) => {
|
|
351
|
+
const equalsIndex = item.indexOf("=");
|
|
352
|
+
if (equalsIndex < 0) return [item, ""];
|
|
353
|
+
return [item.slice(0, equalsIndex).trim(), item.slice(equalsIndex + 1).trim()];
|
|
354
|
+
})
|
|
355
|
+
.filter(([key]) => key),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function commaListFlag(flags: Record<string, string | boolean>, ...names: string[]): string[] {
|
|
360
|
+
const raw = stringFlag(flags, ...names);
|
|
361
|
+
if (!raw) return [];
|
|
362
|
+
return raw
|
|
363
|
+
.split(",")
|
|
364
|
+
.map((item) => item.trim())
|
|
365
|
+
.filter(Boolean);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function routesFlag(flags: Record<string, string | boolean>): WorkspaceConfig["routes"] | undefined {
|
|
369
|
+
const raw = stringFlag(flags, "routes", "route");
|
|
370
|
+
if (!raw) return undefined;
|
|
371
|
+
if (!raw.includes("=")) return { default: raw };
|
|
372
|
+
const routes: Partial<Record<RouteType, string>> = {};
|
|
373
|
+
for (const item of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
|
|
374
|
+
const equalsIndex = item.indexOf("=");
|
|
375
|
+
if (equalsIndex < 0) throw new Error(`Route entry must be routeType=path: ${item}`);
|
|
376
|
+
const routeType = item.slice(0, equalsIndex).trim() as RouteType;
|
|
377
|
+
const routePath = item.slice(equalsIndex + 1).trim();
|
|
378
|
+
if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown route type: ${routeType}`);
|
|
379
|
+
if (!routePath) throw new Error(`Route path is empty for ${routeType}`);
|
|
380
|
+
routes[routeType] = routePath;
|
|
381
|
+
}
|
|
382
|
+
return routes;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildWorkspaceFromAddArgs(args: string): WorkspaceConfig {
|
|
386
|
+
const parsed = parseCommandArgs(args);
|
|
387
|
+
const workspacePath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional[0];
|
|
388
|
+
if (!workspacePath) throw new Error("workspace path is required");
|
|
389
|
+
const capabilities = commaListFlag(parsed.flags, "capabilities", "caps", "cap");
|
|
390
|
+
if (capabilities.length === 0) throw new Error("--capabilities is required");
|
|
391
|
+
const workspaceBlock: WorkspaceConfig = {
|
|
392
|
+
...(stringFlag(parsed.flags, "name", "n") ? { name: stringFlag(parsed.flags, "name", "n") } : {}),
|
|
393
|
+
path: workspacePath,
|
|
394
|
+
tags: commaListFlag(parsed.flags, "tags", "tag"),
|
|
395
|
+
capabilities: asCapabilityArray(capabilities),
|
|
396
|
+
contextFiles: commaListFlag(parsed.flags, "context", "contexts", "contextFiles"),
|
|
397
|
+
...(routesFlag(parsed.flags) ? { routes: routesFlag(parsed.flags) } : {}),
|
|
398
|
+
};
|
|
399
|
+
if (workspaceBlock.tags.length === 0) throw new Error("--tags is required");
|
|
400
|
+
if (workspaceBlock.contextFiles?.length === 0) delete workspaceBlock.contextFiles;
|
|
401
|
+
if (workspaceBlock.capabilities.includes("writeDocs") && !workspaceBlock.routes) {
|
|
402
|
+
throw new Error("workspaces with writeDocs require --route or --routes");
|
|
403
|
+
}
|
|
404
|
+
return workspaceBlock;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function addWorkspaceToConfig(configPath: string, workspaceBlock: WorkspaceConfig): Promise<void> {
|
|
408
|
+
const exists = await pathExists(configPath);
|
|
409
|
+
const parsed = exists ? (YAML.parse(await readFile(configPath, "utf8"), { uniqueKeys: true }) as unknown) : { version: 1, workspaces: [] };
|
|
410
|
+
if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
|
|
411
|
+
if (parsed.version === undefined) parsed.version = 1;
|
|
412
|
+
if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
|
|
413
|
+
const workspaces = Array.isArray(parsed.workspaces) ? parsed.workspaces : [];
|
|
414
|
+
parsed.workspaces = workspaces;
|
|
415
|
+
if (workspaces.some((item: unknown) => isRecord(item) && item.path === workspaceBlock.path)) {
|
|
416
|
+
throw new Error(`workspace path already exists: ${workspaceBlock.path}`);
|
|
417
|
+
}
|
|
418
|
+
workspaces.push(workspaceBlock);
|
|
419
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
420
|
+
await writeFile(configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function sendCommandOutput(pi: ExtensionAPI, title: string, text: string, details?: Record<string, unknown>) {
|
|
424
|
+
pi.sendMessage({
|
|
425
|
+
customType: "monofold-output",
|
|
426
|
+
content: `## ${title}\n\n${text}`,
|
|
427
|
+
display: true,
|
|
428
|
+
details: details ?? {},
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usage: string) {
|
|
433
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
434
|
+
sendCommandOutput(pi, command, `Error: ${message}\n\nUsage:\n${usage}`, { error: message });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function resolveWorkspace(ctx: ExtensionContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
|
|
438
|
+
const matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
|
|
439
|
+
if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
|
|
440
|
+
if (matches.length === 1) return matches[0];
|
|
441
|
+
if (!ctx.hasUI) {
|
|
442
|
+
throw new Error(`Multiple workspaces match target in non-interactive mode: ${matches.map(formatWorkspaceLabel).join(", ")}`);
|
|
443
|
+
}
|
|
444
|
+
const labels = matches.map(formatWorkspaceLabel);
|
|
445
|
+
const choice = await ctx.ui.select("Select workspace", labels);
|
|
446
|
+
if (!choice) throw new Error("Workspace selection cancelled");
|
|
447
|
+
return matches[labels.indexOf(choice)];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function formatWorkspaceLabel(workspace: ResolvedWorkspace): string {
|
|
451
|
+
const displayName = workspace.name ? `${workspace.name} ` : "";
|
|
452
|
+
return `#${workspace.index} ${displayName}[${workspace.tags.join(", ")}] ${workspace.path}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
|
|
456
|
+
assertWorkspaceInternalRelative("path", inputPath);
|
|
457
|
+
return path.join(workspace.resolvedPath, inputPath);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function gitSummary(workspace: ResolvedWorkspace): Promise<{ isGit: boolean; status?: string }> {
|
|
461
|
+
if (!(await pathExists(path.join(workspace.resolvedPath, ".git")))) return { isGit: false };
|
|
462
|
+
const result = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short"], { timeout: 5000 });
|
|
463
|
+
return { isGit: true, status: result.stdout.trim() || "clean" };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function buildManifest(loaded: LoadedConfig): Promise<string> {
|
|
467
|
+
const lines = ["Pi Monofold Manifest:"];
|
|
468
|
+
for (const workspace of loaded.workspaces) {
|
|
469
|
+
const git = await gitSummary(workspace).catch((error) => ({ isGit: false, status: `git status error: ${String(error)}` }));
|
|
470
|
+
lines.push(
|
|
471
|
+
`- ${formatWorkspaceLabel(workspace)}\n` +
|
|
472
|
+
` capabilities: ${workspace.capabilities.join(", ")}\n` +
|
|
473
|
+
` routes: ${Object.keys(workspace.normalizedRoutes).join(", ") || "none"}\n` +
|
|
474
|
+
` contextFiles: ${workspace.effectiveContextFiles.join(", ") || "none"}\n` +
|
|
475
|
+
` git: ${git.isGit ? git.status : "not a git repository"}`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
lines.push("Use monofold_* tools for cross-workspace operations. Do not guess output paths when a route exists.");
|
|
479
|
+
return lines.join("\n");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function shallowTree(root: string, depth: number, prefix = ""): Promise<string[]> {
|
|
483
|
+
if (depth < 0) return [];
|
|
484
|
+
const entries = await readdir(path.join(root, prefix), { withFileTypes: true });
|
|
485
|
+
const lines: string[] = [];
|
|
486
|
+
for (const entry of entries.filter((e) => !e.name.startsWith(".git") && e.name !== "node_modules").slice(0, 200)) {
|
|
487
|
+
const rel = normalizeSlashes(path.join(prefix, entry.name));
|
|
488
|
+
lines.push(entry.isDirectory() ? `${rel}/` : rel);
|
|
489
|
+
if (entry.isDirectory() && depth > 0) {
|
|
490
|
+
lines.push(...(await shallowTree(root, depth - 1, rel)));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return lines;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function slugify(title: string): string {
|
|
497
|
+
const normalized = title
|
|
498
|
+
.trim()
|
|
499
|
+
.toLowerCase()
|
|
500
|
+
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, "")
|
|
501
|
+
.replace(/\s+/g, "-")
|
|
502
|
+
.replace(/-+/g, "-")
|
|
503
|
+
.replace(/^-|-$/g, "");
|
|
504
|
+
return normalized || "note";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
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] ?? "");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function renderMetadata(value: unknown, vars: Record<string, string>): unknown {
|
|
512
|
+
if (typeof value === "string") return renderTemplate(value, vars);
|
|
513
|
+
if (Array.isArray(value)) return value.map((item) => renderMetadata(item, vars));
|
|
514
|
+
if (isRecord(value)) {
|
|
515
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderMetadata(item, vars)]));
|
|
516
|
+
}
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function frontmatter(metadata: Record<string, unknown>): string {
|
|
521
|
+
if (Object.keys(metadata).length === 0) return "";
|
|
522
|
+
return `---\n${YAML.stringify(metadata).trim()}\n---\n\n`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function classifyPath(targetPath: string): "docs" | "code" | "unknown" {
|
|
526
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
527
|
+
if (DOC_EXTENSIONS.has(ext)) return "docs";
|
|
528
|
+
if (CODE_EXTENSIONS.has(ext)) return "code";
|
|
529
|
+
return "unknown";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function findWorkspaceForPath(loaded: LoadedConfig, targetPath: string): ResolvedWorkspace | undefined {
|
|
533
|
+
const absolute = path.resolve(loaded.root, targetPath);
|
|
534
|
+
return loaded.workspaces.find((workspace) => isInside(workspace.resolvedPath, absolute));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function confirm(ctx: ExtensionContext, title: string, body: string): Promise<boolean> {
|
|
538
|
+
if (!ctx.hasUI) return false;
|
|
539
|
+
return ctx.ui.confirm(title, body);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function maybeBlockUnknown(ctx: ExtensionContext, loaded: LoadedConfig, targetPath: string, action: string) {
|
|
543
|
+
const workspace = findWorkspaceForPath(loaded, targetPath);
|
|
544
|
+
if (workspace) return undefined;
|
|
545
|
+
const ok = await confirm(ctx, "Unknown Path", `${action} targets an unknown path:\n${targetPath}\nAllow this operation?`);
|
|
546
|
+
if (!ok) return { block: true, reason: `Unknown Path requires confirmation: ${targetPath}` };
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function guardPathOperation(ctx: ExtensionContext, loaded: LoadedConfig, targetPath: string, action: "read" | "write" | "edit") {
|
|
551
|
+
const workspace = findWorkspaceForPath(loaded, targetPath);
|
|
552
|
+
if (!workspace) return maybeBlockUnknown(ctx, loaded, targetPath, action);
|
|
553
|
+
if (action === "read") {
|
|
554
|
+
if (!workspace.capabilities.includes("read")) return { block: true, reason: `Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}` };
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
const kind = classifyPath(targetPath);
|
|
558
|
+
if (kind === "docs" && workspace.capabilities.includes("writeDocs")) return undefined;
|
|
559
|
+
if (kind === "code" && workspace.capabilities.includes("editCode")) return undefined;
|
|
560
|
+
if (kind === "unknown") {
|
|
561
|
+
const ok = await confirm(
|
|
562
|
+
ctx,
|
|
563
|
+
"Unclassified file write",
|
|
564
|
+
`${action} targets an unclassified file in ${formatWorkspaceLabel(workspace)}:\n${targetPath}\nAllow?`,
|
|
565
|
+
);
|
|
566
|
+
if (ok) return undefined;
|
|
567
|
+
}
|
|
568
|
+
return { block: true, reason: `Workspace lacks capability for ${kind} ${action}: ${formatWorkspaceLabel(workspace)}` };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function bashLooksDangerous(command: string): string | undefined {
|
|
572
|
+
const normalized = command.toLowerCase();
|
|
573
|
+
if (/rm\s+(-[^\n;]*r[^\n;]*f|-rf|-fr)/.test(normalized)) return "rm -rf";
|
|
574
|
+
if (/git\s+reset\s+--hard/.test(normalized)) return "git reset --hard";
|
|
575
|
+
if (/git\s+clean\b/.test(normalized)) return "git clean";
|
|
576
|
+
if (/chmod\s+-r/.test(normalized)) return "chmod -R";
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function bashContainsGitCommitOrPush(command: string): boolean {
|
|
581
|
+
return /(^|[;&|]\s*)git\s+(commit|push)\b/i.test(command);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function inferBashCwd(ctx: ExtensionContext, command: string): string {
|
|
585
|
+
const match = command.match(/(?:^|[;&|]\s*)cd\s+([^;&|\n]+)/);
|
|
586
|
+
if (!match) return ctx.cwd;
|
|
587
|
+
const raw = match[1].trim().replace(/^['"]|['"]$/g, "");
|
|
588
|
+
return path.resolve(ctx.cwd, raw);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export default function piMultiWorkspace(pi: ExtensionAPI) {
|
|
592
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
593
|
+
try {
|
|
594
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
595
|
+
const manifest = await buildManifest(loaded);
|
|
596
|
+
return {
|
|
597
|
+
systemPrompt:
|
|
598
|
+
_event.systemPrompt +
|
|
599
|
+
`
|
|
600
|
+
|
|
601
|
+
## Pi Monofold
|
|
602
|
+
|
|
603
|
+
${manifest}
|
|
604
|
+
`,
|
|
605
|
+
};
|
|
606
|
+
} catch {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
pi.registerTool({
|
|
612
|
+
name: "monofold_list",
|
|
613
|
+
label: "Workspace List",
|
|
614
|
+
description: "List configured Pi Monofold workspaces with tags, capabilities, routes, context files, and git status.",
|
|
615
|
+
parameters: Type.Object({}),
|
|
616
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
617
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
618
|
+
const manifest = await buildManifest(loaded);
|
|
619
|
+
return { content: [{ type: "text", text: manifest }], details: { workspaces: loaded.workspaces } };
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
pi.registerTool({
|
|
624
|
+
name: "monofold_read",
|
|
625
|
+
label: "Workspace Read",
|
|
626
|
+
description: "Read, search, or list files inside a configured Workspace. Requires read capability.",
|
|
627
|
+
parameters: Type.Object({
|
|
628
|
+
mode: Type.String({ description: "file, search, or tree" }),
|
|
629
|
+
path: Type.Optional(Type.String({ description: "Workspace-relative path for file/tree" })),
|
|
630
|
+
query: Type.Optional(Type.String({ description: "Search query for mode=search" })),
|
|
631
|
+
depth: Type.Optional(Type.Number({ description: "Tree depth, default 1" })),
|
|
632
|
+
targetTags: Type.Optional(Type.Array(Type.String())),
|
|
633
|
+
workspaceName: Type.Optional(Type.String()),
|
|
634
|
+
requireCapabilities: Type.Optional(Type.Array(Type.String())),
|
|
635
|
+
}),
|
|
636
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
637
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
638
|
+
const workspace = await resolveWorkspace(ctx, loaded, {
|
|
639
|
+
targetTags: params.targetTags,
|
|
640
|
+
workspaceName: params.workspaceName,
|
|
641
|
+
requireCapabilities: ["read"],
|
|
642
|
+
});
|
|
643
|
+
if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
|
|
644
|
+
if (params.mode === "file") {
|
|
645
|
+
if (!params.path) throw new Error("monofold_read mode=file requires path");
|
|
646
|
+
const filePath = relativePath(workspace, params.path);
|
|
647
|
+
const text = await readFile(filePath, "utf8");
|
|
648
|
+
return { content: [{ type: "text", text }], details: { workspace: formatWorkspaceLabel(workspace), path: params.path } };
|
|
649
|
+
}
|
|
650
|
+
if (params.mode === "tree") {
|
|
651
|
+
const root = params.path ? relativePath(workspace, params.path) : workspace.resolvedPath;
|
|
652
|
+
const lines = await shallowTree(root, Math.max(0, Math.min(5, params.depth ?? 1)));
|
|
653
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { workspace: formatWorkspaceLabel(workspace), path: params.path ?? "." } };
|
|
654
|
+
}
|
|
655
|
+
if (params.mode === "search") {
|
|
656
|
+
if (!params.query) throw new Error("monofold_read mode=search requires query");
|
|
657
|
+
const result = await runCommand("rg", ["--line-number", "--hidden", "--glob", "!.git/**", params.query, params.path ?? "."], {
|
|
658
|
+
cwd: workspace.resolvedPath,
|
|
659
|
+
signal,
|
|
660
|
+
timeout: 10000,
|
|
661
|
+
allowExitCodes: [0, 1],
|
|
662
|
+
});
|
|
663
|
+
const output = result.stdout.trim() || result.stderr.trim() || "No matches";
|
|
664
|
+
return { content: [{ type: "text", text: output }], details: { workspace: formatWorkspaceLabel(workspace), query: params.query } };
|
|
665
|
+
}
|
|
666
|
+
throw new Error(`Unknown monofold_read mode: ${params.mode}`);
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.registerTool({
|
|
671
|
+
name: "monofold_write",
|
|
672
|
+
label: "Workspace Write",
|
|
673
|
+
description: "Write a Markdown document to a routed Workspace destination using routeType, title, body, filename, and metadata.",
|
|
674
|
+
parameters: Type.Object({
|
|
675
|
+
routeType: Type.String({ description: "default, prd, design, progress, issue, research, or decision" }),
|
|
676
|
+
title: Type.String(),
|
|
677
|
+
body: Type.String(),
|
|
678
|
+
filename: Type.Optional(Type.String()),
|
|
679
|
+
metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
680
|
+
targetTags: Type.Optional(Type.Array(Type.String())),
|
|
681
|
+
workspaceName: Type.Optional(Type.String()),
|
|
682
|
+
}),
|
|
683
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
684
|
+
const routeType = params.routeType as RouteType;
|
|
685
|
+
if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${params.routeType}`);
|
|
686
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
687
|
+
const workspace = await resolveWorkspace(ctx, loaded, {
|
|
688
|
+
targetTags: params.targetTags,
|
|
689
|
+
workspaceName: params.workspaceName,
|
|
690
|
+
requireCapabilities: ["writeDocs"],
|
|
691
|
+
});
|
|
692
|
+
const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
|
|
693
|
+
if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
|
|
694
|
+
const now = new Date();
|
|
695
|
+
const date = now.toISOString().slice(0, 10);
|
|
696
|
+
const vars = {
|
|
697
|
+
date,
|
|
698
|
+
datetime: now.toISOString(),
|
|
699
|
+
title: params.title,
|
|
700
|
+
slug: slugify(params.title),
|
|
701
|
+
routeType,
|
|
702
|
+
workspaceName: workspace.name ?? "",
|
|
703
|
+
workspaceTags: workspace.tags.join(","),
|
|
704
|
+
};
|
|
705
|
+
const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
|
|
706
|
+
const filename = params.filename ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
|
|
707
|
+
assertWorkspaceInternalRelative("filename", filename);
|
|
708
|
+
const dir = relativePath(workspace, route.path);
|
|
709
|
+
const outputPath = path.join(dir, filename);
|
|
710
|
+
const defaultMetadata = loaded.raw.defaults?.metadata ?? {};
|
|
711
|
+
const routeMetadata = route.metadata ?? {};
|
|
712
|
+
const metadata = renderMetadata({ ...defaultMetadata, ...routeMetadata, ...(params.metadata ?? {}) }, vars) as Record<string, unknown>;
|
|
713
|
+
const text = `${frontmatter(metadata)}# ${params.title}\n\n${params.body.trim()}\n`;
|
|
714
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
715
|
+
await writeFile(outputPath, text, "utf8");
|
|
716
|
+
const rel = normalizeSlashes(path.relative(workspace.resolvedPath, outputPath));
|
|
717
|
+
return { content: [{ type: "text", text: `Wrote ${formatWorkspaceLabel(workspace)}:${rel}` }], details: { workspace, path: rel } };
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
pi.registerTool({
|
|
722
|
+
name: "monofold_git",
|
|
723
|
+
label: "Workspace Git",
|
|
724
|
+
description: "Run guarded git status, commit, or push for one configured Git Workspace.",
|
|
725
|
+
parameters: Type.Object({
|
|
726
|
+
action: Type.String({ description: "status, commit, or push" }),
|
|
727
|
+
message: Type.Optional(Type.String()),
|
|
728
|
+
targetTags: Type.Optional(Type.Array(Type.String())),
|
|
729
|
+
workspaceName: Type.Optional(Type.String()),
|
|
730
|
+
}),
|
|
731
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
732
|
+
const required: CapabilityTag[] = params.action === "push" ? ["gitPush"] : params.action === "commit" ? ["gitCommit"] : [];
|
|
733
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
734
|
+
const workspace = await resolveWorkspace(ctx, loaded, {
|
|
735
|
+
targetTags: params.targetTags,
|
|
736
|
+
workspaceName: params.workspaceName,
|
|
737
|
+
requireCapabilities: required,
|
|
738
|
+
});
|
|
739
|
+
if (!(await pathExists(path.join(workspace.resolvedPath, ".git")))) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
|
|
740
|
+
if (params.action === "status") {
|
|
741
|
+
const result = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short", "--branch"], { signal, timeout: 10000 });
|
|
742
|
+
return { content: [{ type: "text", text: result.stdout || "clean" }], details: { workspace } };
|
|
743
|
+
}
|
|
744
|
+
if (params.action === "commit") {
|
|
745
|
+
const message = params.message ?? `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
|
|
746
|
+
const status = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short"], { signal, timeout: 10000 });
|
|
747
|
+
const diffstat = await runCommand("git", ["-C", workspace.resolvedPath, "diff", "--stat"], { signal, timeout: 10000 });
|
|
748
|
+
const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus:\n${status.stdout || "clean"}\n\nDiffstat:\n${diffstat.stdout || "none"}\n\nCommit message:\n${message}\n\nStage all and commit?`);
|
|
749
|
+
if (!ok) return { content: [{ type: "text", text: "Commit cancelled" }], details: { cancelled: true } };
|
|
750
|
+
await runCommand("git", ["-C", workspace.resolvedPath, "add", "-A"], { signal, timeout: 10000 });
|
|
751
|
+
const commit = await runCommand("git", ["-C", workspace.resolvedPath, "commit", "-m", message], { signal, timeout: 30000 });
|
|
752
|
+
return { content: [{ type: "text", text: commit.stdout || commit.stderr }], details: { workspace, message } };
|
|
753
|
+
}
|
|
754
|
+
if (params.action === "push") {
|
|
755
|
+
const branch = await runCommand("git", ["-C", workspace.resolvedPath, "branch", "--show-current"], { signal, timeout: 10000 });
|
|
756
|
+
const remote = await runCommand("git", ["-C", workspace.resolvedPath, "remote", "-v"], { signal, timeout: 10000 });
|
|
757
|
+
const log = await runCommand("git", ["-C", workspace.resolvedPath, "log", "--oneline", "@{u}..HEAD"], {
|
|
758
|
+
signal,
|
|
759
|
+
timeout: 10000,
|
|
760
|
+
allowExitCodes: [0, 128],
|
|
761
|
+
});
|
|
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?`);
|
|
763
|
+
if (!ok) return { content: [{ type: "text", text: "Push cancelled" }], details: { cancelled: true } };
|
|
764
|
+
const push = await runCommand("git", ["-C", workspace.resolvedPath, "push"], { signal, timeout: 60000 });
|
|
765
|
+
return { content: [{ type: "text", text: push.stdout || push.stderr }], details: { workspace } };
|
|
766
|
+
}
|
|
767
|
+
throw new Error(`Unknown monofold_git action: ${params.action}`);
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const listCommand = async (_args: string, ctx: ExtensionCommandContext) => {
|
|
772
|
+
try {
|
|
773
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
774
|
+
const manifest = await buildManifest(loaded);
|
|
775
|
+
sendCommandOutput(pi, "monofold:list", manifest, { workspaces: loaded.workspaces });
|
|
776
|
+
} catch (error) {
|
|
777
|
+
sendCommandError(pi, "monofold:list", error, "/monofold:list");
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const readUsage = [
|
|
782
|
+
"/monofold:tree [path] [--workspace \"Name\"|--workspace #0] [--depth 2]",
|
|
783
|
+
"/monofold:read file <path> [--workspace \"Name\"|--workspace #0]",
|
|
784
|
+
"/monofold:search <query> [--workspace \"Name\"|--workspace #0]",
|
|
785
|
+
"Aliases: /monofold_read tree|file|search ...",
|
|
786
|
+
].join("\n");
|
|
787
|
+
|
|
788
|
+
const readCommand = async (args: string, ctx: ExtensionCommandContext) => {
|
|
789
|
+
try {
|
|
790
|
+
const parsed = parseCommandArgs(args);
|
|
791
|
+
const mode = parsed.positional[0] ?? "tree";
|
|
792
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
793
|
+
const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["read"]));
|
|
794
|
+
if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
|
|
795
|
+
|
|
796
|
+
if (mode === "file" || mode === "read") {
|
|
797
|
+
const inputPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional.slice(1).join(" ");
|
|
798
|
+
if (!inputPath) throw new Error("file mode requires path");
|
|
799
|
+
const filePath = relativePath(workspace, inputPath);
|
|
800
|
+
const text = await readFile(filePath, "utf8");
|
|
801
|
+
sendCommandOutput(pi, `monofold:read ${formatWorkspaceLabel(workspace)}:${inputPath}`, text, { workspace, path: inputPath });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (mode === "tree" || mode === "ls") {
|
|
806
|
+
const inputPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional.slice(1).join(" ");
|
|
807
|
+
const depth = Number.parseInt(stringFlag(parsed.flags, "depth", "d") ?? "1", 10);
|
|
808
|
+
const root = inputPath ? relativePath(workspace, inputPath) : workspace.resolvedPath;
|
|
809
|
+
const lines = await shallowTree(root, Math.max(0, Math.min(5, Number.isFinite(depth) ? depth : 1)));
|
|
810
|
+
sendCommandOutput(pi, `monofold:tree ${formatWorkspaceLabel(workspace)}:${inputPath || "."}`, lines.join("\n"), {
|
|
811
|
+
workspace,
|
|
812
|
+
path: inputPath || ".",
|
|
813
|
+
});
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (mode === "search" || mode === "grep") {
|
|
818
|
+
const query = stringFlag(parsed.flags, "query", "q") ?? parsed.positional.slice(1).join(" ");
|
|
819
|
+
if (!query) throw new Error("search mode requires query");
|
|
820
|
+
const result = await runCommand("rg", ["--line-number", "--hidden", "--glob", "!.git/**", query, "."], {
|
|
821
|
+
cwd: workspace.resolvedPath,
|
|
822
|
+
timeout: 10000,
|
|
823
|
+
allowExitCodes: [0, 1],
|
|
824
|
+
});
|
|
825
|
+
const output = result.stdout.trim() || result.stderr.trim() || "No matches";
|
|
826
|
+
sendCommandOutput(pi, `monofold:search ${formatWorkspaceLabel(workspace)}:${query}`, output, { workspace, query });
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
throw new Error(`Unknown read mode: ${mode}`);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
sendCommandError(pi, "monofold:read", error, readUsage);
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const writeUsage = [
|
|
837
|
+
"/monofold:write --route progress --title \"Title\" --body \"Markdown body\" [--workspace \"Name\"|--workspace #0]",
|
|
838
|
+
"Optional: --filename file.md --meta key=value,other=value",
|
|
839
|
+
"Alias: /monofold_write ...",
|
|
840
|
+
].join("\n");
|
|
841
|
+
|
|
842
|
+
const writeCommand = async (args: string, ctx: ExtensionCommandContext) => {
|
|
843
|
+
try {
|
|
844
|
+
const parsed = parseCommandArgs(args);
|
|
845
|
+
const routeType = (stringFlag(parsed.flags, "route", "r") ?? parsed.positional[0] ?? "default") as RouteType;
|
|
846
|
+
if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${routeType}`);
|
|
847
|
+
const title = stringFlag(parsed.flags, "title", "t");
|
|
848
|
+
const body = stringFlag(parsed.flags, "body", "b");
|
|
849
|
+
if (!title) throw new Error("--title is required");
|
|
850
|
+
if (!body) throw new Error("--body is required");
|
|
851
|
+
|
|
852
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
853
|
+
const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["writeDocs"]));
|
|
854
|
+
const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
|
|
855
|
+
if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
|
|
856
|
+
const now = new Date();
|
|
857
|
+
const date = now.toISOString().slice(0, 10);
|
|
858
|
+
const vars = {
|
|
859
|
+
date,
|
|
860
|
+
datetime: now.toISOString(),
|
|
861
|
+
title,
|
|
862
|
+
slug: slugify(title),
|
|
863
|
+
routeType,
|
|
864
|
+
workspaceName: workspace.name ?? "",
|
|
865
|
+
workspaceTags: workspace.tags.join(","),
|
|
866
|
+
};
|
|
867
|
+
const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
|
|
868
|
+
const filename = stringFlag(parsed.flags, "filename", "file", "f") ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
|
|
869
|
+
assertWorkspaceInternalRelative("filename", filename);
|
|
870
|
+
const dir = relativePath(workspace, route.path);
|
|
871
|
+
const outputPath = path.join(dir, filename);
|
|
872
|
+
const metadata = renderMetadata(
|
|
873
|
+
{ ...(loaded.raw.defaults?.metadata ?? {}), ...(route.metadata ?? {}), ...metadataFlag(parsed.flags) },
|
|
874
|
+
vars,
|
|
875
|
+
) as Record<string, unknown>;
|
|
876
|
+
const text = `${frontmatter(metadata)}# ${title}\n\n${body.trim()}\n`;
|
|
877
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
878
|
+
await writeFile(outputPath, text, "utf8");
|
|
879
|
+
const rel = normalizeSlashes(path.relative(workspace.resolvedPath, outputPath));
|
|
880
|
+
sendCommandOutput(pi, "monofold:write", `Wrote ${formatWorkspaceLabel(workspace)}:${rel}`, { workspace, path: rel });
|
|
881
|
+
} catch (error) {
|
|
882
|
+
sendCommandError(pi, "monofold:write", error, writeUsage);
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const gitUsage = "/monofold:git status|commit|push [--workspace \"Name\"|--workspace #0] [--message \"Commit message\"]\nAlias: /monofold_git ...";
|
|
887
|
+
|
|
888
|
+
const gitCommand = async (args: string, ctx: ExtensionCommandContext) => {
|
|
889
|
+
try {
|
|
890
|
+
const parsed = parseCommandArgs(args);
|
|
891
|
+
const action = parsed.positional[0] ?? "status";
|
|
892
|
+
const required: CapabilityTag[] = action === "push" ? ["gitPush"] : action === "commit" ? ["gitCommit"] : [];
|
|
893
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
894
|
+
const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, required));
|
|
895
|
+
if (!(await pathExists(path.join(workspace.resolvedPath, ".git")))) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
|
|
896
|
+
|
|
897
|
+
if (action === "status") {
|
|
898
|
+
const result = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short", "--branch"], { timeout: 10000 });
|
|
899
|
+
sendCommandOutput(pi, `monofold:git status ${formatWorkspaceLabel(workspace)}`, result.stdout || "clean", { workspace });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (action === "commit") {
|
|
904
|
+
const parsedMessage = stringFlag(parsed.flags, "message", "m") ?? parsed.positional.slice(1).join(" ");
|
|
905
|
+
const message = parsedMessage || `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
|
|
906
|
+
const status = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short"], { timeout: 10000 });
|
|
907
|
+
const diffstat = await runCommand("git", ["-C", workspace.resolvedPath, "diff", "--stat"], { timeout: 10000 });
|
|
908
|
+
const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus:\n${status.stdout || "clean"}\n\nDiffstat:\n${diffstat.stdout || "none"}\n\nCommit message:\n${message}\n\nStage all and commit?`);
|
|
909
|
+
if (!ok) {
|
|
910
|
+
sendCommandOutput(pi, "monofold:git commit", "Commit cancelled", { cancelled: true });
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
await runCommand("git", ["-C", workspace.resolvedPath, "add", "-A"], { timeout: 10000 });
|
|
914
|
+
const commit = await runCommand("git", ["-C", workspace.resolvedPath, "commit", "-m", message], { timeout: 30000 });
|
|
915
|
+
sendCommandOutput(pi, `monofold:git commit ${formatWorkspaceLabel(workspace)}`, commit.stdout || commit.stderr, { workspace, message });
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (action === "push") {
|
|
920
|
+
const branch = await runCommand("git", ["-C", workspace.resolvedPath, "branch", "--show-current"], { timeout: 10000 });
|
|
921
|
+
const remote = await runCommand("git", ["-C", workspace.resolvedPath, "remote", "-v"], { timeout: 10000 });
|
|
922
|
+
const log = await runCommand("git", ["-C", workspace.resolvedPath, "log", "--oneline", "@{u}..HEAD"], {
|
|
923
|
+
timeout: 10000,
|
|
924
|
+
allowExitCodes: [0, 128],
|
|
925
|
+
});
|
|
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?`);
|
|
927
|
+
if (!ok) {
|
|
928
|
+
sendCommandOutput(pi, "monofold:git push", "Push cancelled", { cancelled: true });
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const push = await runCommand("git", ["-C", workspace.resolvedPath, "push"], { timeout: 60000 });
|
|
932
|
+
sendCommandOutput(pi, `monofold:git push ${formatWorkspaceLabel(workspace)}`, push.stdout || push.stderr, { workspace });
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
throw new Error(`Unknown git action: ${action}`);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
sendCommandError(pi, "monofold:git", error, gitUsage);
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
const addUsage = [
|
|
943
|
+
"/monofold:add <path> --name \"Name\" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit",
|
|
944
|
+
"Optional: --context README.md,AGENTS.md",
|
|
945
|
+
"Docs workspace: --capabilities read,writeDocs,gitCommit --route Notes",
|
|
946
|
+
"Multi-route docs: --routes default=Notes,progress=Progress,research=Research",
|
|
947
|
+
"Alias: /monofold_add ...",
|
|
948
|
+
].join("\n");
|
|
949
|
+
|
|
950
|
+
const addCommand = async (args: string, ctx: ExtensionCommandContext) => {
|
|
951
|
+
try {
|
|
952
|
+
const workspaceBlock = buildWorkspaceFromAddArgs(args);
|
|
953
|
+
const configPath = path.join(ctx.cwd, CONFIG_RELATIVE_PATH);
|
|
954
|
+
await addWorkspaceToConfig(configPath, workspaceBlock);
|
|
955
|
+
const loaded = await loadConfig(ctx.cwd);
|
|
956
|
+
sendCommandOutput(pi, "monofold:add", `Added workspace:\n${YAML.stringify(workspaceBlock).trim()}\n\n${await buildManifest(loaded)}`, {
|
|
957
|
+
workspace: workspaceBlock,
|
|
958
|
+
});
|
|
959
|
+
} catch (error) {
|
|
960
|
+
sendCommandError(pi, "monofold:add", error, addUsage);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
pi.registerCommand("monofold:list", { description: "List configured Pi Monofold workspaces", handler: listCommand });
|
|
965
|
+
pi.registerCommand("monofold_list", { description: "Alias for /monofold:list", handler: listCommand });
|
|
966
|
+
pi.registerCommand("monofold:tree", { description: "Show a tree for a configured workspace", handler: readCommand });
|
|
967
|
+
pi.registerCommand("monofold:read", { description: "Read, tree, or search a configured workspace", handler: readCommand });
|
|
968
|
+
pi.registerCommand("monofold_read", { description: "Alias for /monofold:read", handler: readCommand });
|
|
969
|
+
pi.registerCommand("monofold:search", { description: "Search a configured workspace", handler: (args, ctx) => readCommand(`search ${args}`, ctx) });
|
|
970
|
+
pi.registerCommand("monofold:write", { description: "Write a routed Markdown document", handler: writeCommand });
|
|
971
|
+
pi.registerCommand("monofold_write", { description: "Alias for /monofold:write", handler: writeCommand });
|
|
972
|
+
pi.registerCommand("monofold:git", { description: "Run guarded workspace git status, commit, or push", handler: gitCommand });
|
|
973
|
+
pi.registerCommand("monofold_git", { description: "Alias for /monofold:git", handler: gitCommand });
|
|
974
|
+
pi.registerCommand("monofold:add", { description: "Add a workspace to .pi/monofold.yml", handler: addCommand });
|
|
975
|
+
pi.registerCommand("monofold_add", { description: "Alias for /monofold:add", handler: addCommand });
|
|
976
|
+
|
|
977
|
+
const initCommand = async (_args: string, ctx: ExtensionCommandContext) => {
|
|
978
|
+
if (!ctx.hasUI) {
|
|
979
|
+
ctx.ui.notify("monofold:init requires interactive UI", "error");
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const configPath = path.join(ctx.cwd, CONFIG_RELATIVE_PATH);
|
|
983
|
+
const exists = await pathExists(configPath);
|
|
984
|
+
if (exists) {
|
|
985
|
+
const ok = await ctx.ui.confirm("Existing config", `${CONFIG_RELATIVE_PATH} exists. Append a new workspace?`);
|
|
986
|
+
if (!ok) return;
|
|
987
|
+
}
|
|
988
|
+
const workspacePath = await ctx.ui.input("Workspace path", "../business");
|
|
989
|
+
if (!workspacePath) return;
|
|
990
|
+
const name = await ctx.ui.input("Optional workspace name", "");
|
|
991
|
+
const tagsInput = await ctx.ui.input("Tags comma-separated", "business,markdown");
|
|
992
|
+
if (!tagsInput) return;
|
|
993
|
+
const capsInput = await ctx.ui.input("Capabilities comma-separated", "read,writeDocs,gitCommit");
|
|
994
|
+
if (!capsInput) return;
|
|
995
|
+
const capabilities = capsInput.split(",").map((s) => s.trim()).filter(Boolean);
|
|
996
|
+
const routePath = capabilities.includes("writeDocs") ? await ctx.ui.input("Default document route", "Notes") : undefined;
|
|
997
|
+
const workspaceBlock: WorkspaceConfig = {
|
|
998
|
+
...(name?.trim() ? { name: name.trim() } : {}),
|
|
999
|
+
path: workspacePath.trim(),
|
|
1000
|
+
tags: tagsInput.split(",").map((s) => s.trim()).filter(Boolean),
|
|
1001
|
+
capabilities: capabilities as CapabilityTag[],
|
|
1002
|
+
...(routePath ? { routes: { default: routePath.trim() } } : {}),
|
|
1003
|
+
};
|
|
1004
|
+
const current = exists ? await readFile(configPath, "utf8") : "version: 1\n\nworkspaces:\n";
|
|
1005
|
+
const addition = YAML.stringify([workspaceBlock])
|
|
1006
|
+
.split("\n")
|
|
1007
|
+
.filter(Boolean)
|
|
1008
|
+
.map((line) => ` ${line}`)
|
|
1009
|
+
.join("\n");
|
|
1010
|
+
const next = exists ? `${current.trimEnd()}\n${addition}\n` : `version: 1\n\nworkspaces:\n${addition}\n`;
|
|
1011
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
1012
|
+
await writeFile(configPath, next, "utf8");
|
|
1013
|
+
ctx.ui.notify(`Updated ${CONFIG_RELATIVE_PATH}`, "info");
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
pi.registerCommand("monofold:init", {
|
|
1017
|
+
description: "Create or update .pi/monofold.yml with an interactive wizard",
|
|
1018
|
+
handler: initCommand,
|
|
1019
|
+
});
|
|
1020
|
+
pi.registerCommand("monofold_init", {
|
|
1021
|
+
description: "Alias for /monofold:init",
|
|
1022
|
+
handler: initCommand,
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
pi.registerTool({
|
|
1026
|
+
name: "monofold_init",
|
|
1027
|
+
label: "Workspace Init",
|
|
1028
|
+
description: "Queue the interactive /monofold:init command to create or update .pi/monofold.yml.",
|
|
1029
|
+
parameters: Type.Object({}),
|
|
1030
|
+
async execute() {
|
|
1031
|
+
pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
|
|
1032
|
+
return { content: [{ type: "text", text: "Queued /monofold:init" }], details: {} };
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
1037
|
+
let loaded: LoadedConfig;
|
|
1038
|
+
try {
|
|
1039
|
+
loaded = await loadConfig(ctx.cwd);
|
|
1040
|
+
} catch {
|
|
1041
|
+
return undefined;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if ((event.toolName === "read" || event.toolName === "write" || event.toolName === "edit") && typeof event.input.path === "string") {
|
|
1045
|
+
return guardPathOperation(ctx, loaded, event.input.path, event.toolName as "read" | "write" | "edit");
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if ((event.toolName === "grep" || event.toolName === "find") && typeof event.input.path === "string") {
|
|
1049
|
+
return guardPathOperation(ctx, loaded, event.input.path, "read");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (event.toolName === "bash" && typeof event.input.command === "string") {
|
|
1053
|
+
const command = event.input.command;
|
|
1054
|
+
if (bashContainsGitCommitOrPush(command)) {
|
|
1055
|
+
return { block: true, reason: "Use monofold_git for git commit/push so confirmation flow is enforced." };
|
|
1056
|
+
}
|
|
1057
|
+
const danger = bashLooksDangerous(command);
|
|
1058
|
+
if (danger) {
|
|
1059
|
+
const ok = await confirm(ctx, "Dangerous command", `Command contains ${danger}:\n${command}\nAllow?`);
|
|
1060
|
+
if (!ok) return { block: true, reason: `Dangerous command requires confirmation: ${danger}` };
|
|
1061
|
+
}
|
|
1062
|
+
const cwd = inferBashCwd(ctx, command);
|
|
1063
|
+
const workspace = findWorkspaceForPath(loaded, cwd);
|
|
1064
|
+
if (!workspace) return maybeBlockUnknown(ctx, loaded, cwd, "bash");
|
|
1065
|
+
if (!workspace.capabilities.includes("runCommands")) {
|
|
1066
|
+
return { block: true, reason: `Workspace lacks runCommands capability: ${formatWorkspaceLabel(workspace)}` };
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return undefined;
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"scripts": {
|
|
3
|
+
"typecheck": "tsc --noEmit"
|
|
4
|
+
},
|
|
5
|
+
"peerDependencies": {
|
|
6
|
+
"typebox": "*",
|
|
7
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
8
|
+
"@earendil-works/pi-ai": "*"
|
|
9
|
+
},
|
|
10
|
+
"description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"version": "0.0.1",
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./index.ts"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"name": "pi-monofold",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^6.0.3",
|
|
21
|
+
"typebox": "^1.1.38",
|
|
22
|
+
"@types/node": "^25.9.1",
|
|
23
|
+
"@earendil-works/pi-coding-agent": "^0.75.4",
|
|
24
|
+
"@earendil-works/pi-ai": "^0.75.4"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"yaml": "^2.8.1"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"pi-package",
|
|
31
|
+
"pi-extension",
|
|
32
|
+
"ai-coding",
|
|
33
|
+
"virtual-monorepo",
|
|
34
|
+
"multi-repo"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/eiei114/pi-monofold.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/eiei114/pi-monofold/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/eiei114/pi-monofold#readme"
|
|
44
|
+
}
|