@openfleet-tools/cli 0.0.0-stage → 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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/core/hooks.json +46 -0
  3. package/core/plugin.json +9 -0
  4. package/core/skills/openfleet-contracts/SKILL.md +31 -0
  5. package/core/skills/openfleet-finalize/SKILL.md +53 -0
  6. package/core/skills/openfleet-grill/SKILL.md +48 -0
  7. package/core/skills/openfleet-orchestrate/SKILL.md +96 -0
  8. package/core/skills/openfleet-records/SKILL.md +73 -0
  9. package/core/skills/openfleet-spec/SKILL.md +65 -0
  10. package/core/templates/agents/openfleet-coordinator.agent.md +53 -0
  11. package/core/templates/agents/openfleet-implementer.agent.md +36 -0
  12. package/core/templates/agents/openfleet-planner.agent.md +56 -0
  13. package/core/templates/agents/openfleet-tester.agent.md +42 -0
  14. package/dist/attribution.d.ts +21 -0
  15. package/dist/attribution.js +72 -0
  16. package/dist/bin.d.ts +2 -0
  17. package/dist/bin.js +3 -0
  18. package/dist/cli.d.ts +3 -0
  19. package/dist/cli.js +134 -0
  20. package/dist/commands/extensions.d.ts +7 -0
  21. package/dist/commands/extensions.js +60 -0
  22. package/dist/commands/internal.d.ts +19 -0
  23. package/dist/commands/internal.js +117 -0
  24. package/dist/commands/output.d.ts +2 -0
  25. package/dist/commands/output.js +6 -0
  26. package/dist/commands/project.d.ts +23 -0
  27. package/dist/commands/project.js +207 -0
  28. package/dist/commands/workspace.d.ts +10 -0
  29. package/dist/commands/workspace.js +63 -0
  30. package/dist/core.d.ts +17 -0
  31. package/dist/core.js +139 -0
  32. package/dist/coreindex.d.ts +17 -0
  33. package/dist/coreindex.js +7 -0
  34. package/dist/events.d.ts +29 -0
  35. package/dist/events.js +115 -0
  36. package/dist/extensions.d.ts +71 -0
  37. package/dist/extensions.js +297 -0
  38. package/dist/fsutil.d.ts +12 -0
  39. package/dist/fsutil.js +70 -0
  40. package/dist/git.d.ts +27 -0
  41. package/dist/git.js +97 -0
  42. package/dist/hooks/handlers.d.ts +43 -0
  43. package/dist/hooks/handlers.js +363 -0
  44. package/dist/hooks/main.d.ts +2 -0
  45. package/dist/hooks/main.js +37 -0
  46. package/dist/index.d.ts +12 -0
  47. package/dist/index.js +12 -0
  48. package/dist/launcher.d.ts +24 -0
  49. package/dist/launcher.js +75 -0
  50. package/dist/lock.d.ts +6 -0
  51. package/dist/lock.js +45 -0
  52. package/dist/redact.d.ts +2 -0
  53. package/dist/redact.js +39 -0
  54. package/dist/repos.d.ts +28 -0
  55. package/dist/repos.js +10 -0
  56. package/dist/state.d.ts +68 -0
  57. package/dist/state.js +111 -0
  58. package/dist/workspace.d.ts +36 -0
  59. package/dist/workspace.js +74 -0
  60. package/package.json +47 -3
  61. package/README.md +0 -3
@@ -0,0 +1,71 @@
1
+ import { type Workspace } from "./workspace.js";
2
+ export declare const MANIFEST = "openfleet.extension.json";
3
+ export declare const SLOTS: readonly ["pre-spec", "post-plan", "post-test", "post-run"];
4
+ export declare const ROLES: readonly ["coordinator", "planner", "implementer", "tester"];
5
+ export declare const PERMISSIONS: readonly ["read", "write", "shell", "web"];
6
+ export declare const BUILTIN_COMMANDS: string[];
7
+ export type Slot = (typeof SLOTS)[number];
8
+ export type ExtRole = (typeof ROLES)[number];
9
+ export type Permission = (typeof PERMISSIONS)[number];
10
+ export interface ExtensionManifest {
11
+ name: string;
12
+ version: string;
13
+ description?: string;
14
+ /** Semver range of compatible @openfleet-tools/cli core versions. */
15
+ core: string;
16
+ mode: "phase" | "augment";
17
+ /** Required for mode "phase". */
18
+ slot?: Slot;
19
+ /** Required for mode "augment". */
20
+ role?: ExtRole;
21
+ /** Markdown instructions (relative path). Defaults to "instructions.md". */
22
+ instructions?: string;
23
+ /** CLI command name -> Markdown skill (relative path). */
24
+ commands?: Record<string, string>;
25
+ /** Defaults to ["read"]. */
26
+ permissions?: Permission[];
27
+ }
28
+ export interface LockEntry {
29
+ name: string;
30
+ /** Git URL or absolute local path the extension was installed from. */
31
+ source: string;
32
+ /** Pinned commit SHA (git sources) or "local". */
33
+ sha: string;
34
+ /** sha256 of installed contents. */
35
+ hash: string;
36
+ version: string;
37
+ enabled: boolean;
38
+ disabledReason?: string;
39
+ installedAt: string;
40
+ }
41
+ export interface LockFile {
42
+ v: 1;
43
+ extensions: LockEntry[];
44
+ }
45
+ export declare function emptyLock(): LockFile;
46
+ export declare function readLock(ws: Workspace): LockFile;
47
+ export declare function writeLock(ws: Workspace, lock: LockFile): void;
48
+ /** Extensions are instruction-only: reject anything that could execute. */
49
+ export declare function assertInstructionOnly(dir: string): void;
50
+ export declare function validateManifest(dir: string): ExtensionManifest;
51
+ export declare function permissionsOf(m: ExtensionManifest): Permission[];
52
+ /** Map declared permissions to a Copilot custom-agent tool allow-list. */
53
+ export declare function toolsFor(perms: Permission[]): string[];
54
+ export declare function describePermissions(m: ExtensionManifest): string;
55
+ export declare function isCompatible(m: ExtensionManifest, coreVersion: string): boolean;
56
+ export interface AddResult {
57
+ entry: LockEntry;
58
+ manifest: ExtensionManifest;
59
+ replaced: boolean;
60
+ }
61
+ export declare function addExtension(ws: Workspace, source: string, coreVersion: string, cwd?: string): AddResult;
62
+ export declare function removeExtension(ws: Workspace, name: string): void;
63
+ export interface LoadedExtension {
64
+ entry: LockEntry;
65
+ manifest: ExtensionManifest;
66
+ dir: string;
67
+ }
68
+ /** Enabled, valid extensions in lock order. Invalid ones are reported via `warn`. */
69
+ export declare function loadExtensions(ws: Workspace, warn?: (msg: string) => void): LoadedExtension[];
70
+ /** Disable extensions whose `core` range excludes `coreVersion`. Returns names disabled. */
71
+ export declare function disableIncompatible(ws: Workspace, coreVersion: string): string[];
@@ -0,0 +1,297 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import semver from "semver";
7
+ import { copyDir, isDir, isFile, isInside, readJson, readJsonOr, writeJson } from "./fsutil.js";
8
+ import { headSha, isGitRepo } from "./git.js";
9
+ import { UserError } from "./workspace.js";
10
+ export const MANIFEST = "openfleet.extension.json";
11
+ export const SLOTS = ["pre-spec", "post-plan", "post-test", "post-run"];
12
+ export const ROLES = ["coordinator", "planner", "implementer", "tester"];
13
+ export const PERMISSIONS = ["read", "write", "shell", "web"];
14
+ export const BUILTIN_COMMANDS = [
15
+ "init",
16
+ "update",
17
+ "new",
18
+ "resume",
19
+ "status",
20
+ "list",
21
+ "clean",
22
+ "add",
23
+ "remove",
24
+ "extensions",
25
+ "event",
26
+ "state",
27
+ "worktree",
28
+ "help",
29
+ "version",
30
+ ];
31
+ const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
32
+ const ALLOWED_EXT = new Set([".md", ".json", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
33
+ const ALLOWED_BARE = new Set(["LICENSE", "LICENCE", "NOTICE", "COPYING", ".gitignore", ".gitattributes"]);
34
+ export function emptyLock() {
35
+ return { v: 1, extensions: [] };
36
+ }
37
+ export function readLock(ws) {
38
+ return readJsonOr(ws.lockFile, emptyLock());
39
+ }
40
+ export function writeLock(ws, lock) {
41
+ writeJson(ws.lockFile, lock);
42
+ }
43
+ function fail(dir, msg) {
44
+ throw new UserError(`Invalid extension at ${dir}: ${msg}`);
45
+ }
46
+ function relFile(dir, rel, field) {
47
+ if (typeof rel !== "string" || !rel)
48
+ fail(dir, `"${field}" must be a relative path`);
49
+ const abs = path.resolve(dir, rel);
50
+ if (!isInside(dir, abs))
51
+ fail(dir, `"${field}" escapes the extension folder`);
52
+ if (!isFile(abs))
53
+ fail(dir, `"${field}" file not found: ${rel}`);
54
+ if (path.extname(abs) !== ".md")
55
+ fail(dir, `"${field}" must be a Markdown file`);
56
+ return rel;
57
+ }
58
+ function walk(dir, base = dir, out = []) {
59
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
60
+ if (entry.name === ".git")
61
+ continue;
62
+ const abs = path.join(dir, entry.name);
63
+ if (entry.isSymbolicLink())
64
+ fail(base, `symlinks are not allowed (${path.relative(base, abs)})`);
65
+ if (entry.isDirectory())
66
+ walk(abs, base, out);
67
+ else
68
+ out.push(abs);
69
+ }
70
+ return out;
71
+ }
72
+ /** Extensions are instruction-only: reject anything that could execute. */
73
+ export function assertInstructionOnly(dir) {
74
+ for (const file of walk(dir)) {
75
+ const rel = path.relative(dir, file);
76
+ const base = path.basename(file);
77
+ const ext = path.extname(file).toLowerCase();
78
+ if (!ALLOWED_EXT.has(ext) && !ALLOWED_BARE.has(base)) {
79
+ fail(dir, `only Markdown/JSON/text/image files are allowed; found ${rel}`);
80
+ }
81
+ if ((fs.statSync(file).mode & 0o111) !== 0)
82
+ fail(dir, `executable files are not allowed (${rel})`);
83
+ }
84
+ }
85
+ export function validateManifest(dir) {
86
+ const file = path.join(dir, MANIFEST);
87
+ if (!isFile(file))
88
+ fail(dir, `missing ${MANIFEST}`);
89
+ let m;
90
+ try {
91
+ m = readJson(file);
92
+ }
93
+ catch (err) {
94
+ fail(dir, `${MANIFEST} is not valid JSON (${err.message})`);
95
+ }
96
+ if (typeof m.name !== "string" || !NAME.test(m.name))
97
+ fail(dir, `"name" must match ${NAME}`);
98
+ if (typeof m.version !== "string" || !semver.valid(m.version))
99
+ fail(dir, `"version" must be a semver version`);
100
+ if (typeof m.core !== "string" || !semver.validRange(m.core))
101
+ fail(dir, `"core" must be a semver range`);
102
+ if (m.description !== undefined && typeof m.description !== "string")
103
+ fail(dir, `"description" must be a string`);
104
+ if (m.mode !== "phase" && m.mode !== "augment")
105
+ fail(dir, `"mode" must be "phase" or "augment"`);
106
+ if (m.mode === "phase" && !SLOTS.includes(m.slot))
107
+ fail(dir, `"slot" must be one of ${SLOTS.join(", ")}`);
108
+ if (m.mode === "augment" && !ROLES.includes(m.role))
109
+ fail(dir, `"role" must be one of ${ROLES.join(", ")}`);
110
+ if (m.mode === "phase" && m.role !== undefined)
111
+ fail(dir, `"role" is only valid for mode "augment"`);
112
+ if (m.mode === "augment" && m.slot !== undefined)
113
+ fail(dir, `"slot" is only valid for mode "phase"`);
114
+ relFile(dir, m.instructions ?? "instructions.md", "instructions");
115
+ if (m.commands !== undefined) {
116
+ if (typeof m.commands !== "object" || m.commands === null || Array.isArray(m.commands)) {
117
+ fail(dir, `"commands" must be an object of name -> skill.md`);
118
+ }
119
+ for (const [cmd, skill] of Object.entries(m.commands)) {
120
+ if (!NAME.test(cmd))
121
+ fail(dir, `command name "${cmd}" must match ${NAME}`);
122
+ if (BUILTIN_COMMANDS.includes(cmd))
123
+ fail(dir, `command "${cmd}" collides with a built-in command`);
124
+ relFile(dir, skill, `commands.${cmd}`);
125
+ }
126
+ }
127
+ if (m.permissions !== undefined) {
128
+ if (!Array.isArray(m.permissions) || !m.permissions.every((p) => PERMISSIONS.includes(p))) {
129
+ fail(dir, `"permissions" must be a subset of ${PERMISSIONS.join(", ")}`);
130
+ }
131
+ }
132
+ assertInstructionOnly(dir);
133
+ return m;
134
+ }
135
+ export function permissionsOf(m) {
136
+ const set = new Set(["read", ...(m.permissions ?? [])]);
137
+ return PERMISSIONS.filter((p) => set.has(p));
138
+ }
139
+ /** Map declared permissions to a Copilot custom-agent tool allow-list. */
140
+ export function toolsFor(perms) {
141
+ const tools = ["read", "search"];
142
+ if (perms.includes("write"))
143
+ tools.push("edit");
144
+ if (perms.includes("shell"))
145
+ tools.push("execute");
146
+ if (perms.includes("web"))
147
+ tools.push("web");
148
+ return tools;
149
+ }
150
+ export function describePermissions(m) {
151
+ const perms = permissionsOf(m);
152
+ const lines = perms.map((p) => {
153
+ switch (p) {
154
+ case "read":
155
+ return " - read: read and search files";
156
+ case "write":
157
+ return " - write: create and edit files";
158
+ case "shell":
159
+ return " - shell: run shell commands";
160
+ case "web":
161
+ return " - web: fetch URLs / search the web";
162
+ }
163
+ });
164
+ return lines.join("\n");
165
+ }
166
+ export function isCompatible(m, coreVersion) {
167
+ return semver.satisfies(coreVersion, m.core, { includePrerelease: true });
168
+ }
169
+ function hashDir(dir) {
170
+ const h = crypto.createHash("sha256");
171
+ for (const file of walk(dir).sort()) {
172
+ h.update(path.relative(dir, file).split(path.sep).join("/"));
173
+ h.update("\0");
174
+ h.update(fs.readFileSync(file));
175
+ h.update("\0");
176
+ }
177
+ return h.digest("hex");
178
+ }
179
+ function looksLikePath(source) {
180
+ return (source.startsWith(".") ||
181
+ source.startsWith("/") ||
182
+ source.startsWith("~") ||
183
+ /^[A-Za-z]:[\\/]/.test(source) ||
184
+ isDir(path.resolve(source)));
185
+ }
186
+ function fetchSource(source, cwd) {
187
+ if (looksLikePath(source)) {
188
+ const abs = path.resolve(cwd, source.replace(/^~(?=$|\/)/, os.homedir()));
189
+ if (!isDir(abs))
190
+ throw new UserError(`Extension path not found: ${abs}`);
191
+ const sha = isGitRepo(abs) ? (headSha(abs) ?? "local") : "local";
192
+ return { dir: abs, source: abs, sha, cleanup: () => { } };
193
+ }
194
+ const [url, ref] = source.split("#");
195
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openfleet-ext-"));
196
+ const cleanup = () => fs.rmSync(tmp, { recursive: true, force: true });
197
+ try {
198
+ execFileSync("git", ["clone", "--quiet", url, tmp], { stdio: ["ignore", "ignore", "pipe"] });
199
+ if (ref)
200
+ execFileSync("git", ["checkout", "--quiet", ref], { cwd: tmp, stdio: ["ignore", "ignore", "pipe"] });
201
+ const sha = headSha(tmp);
202
+ if (!sha)
203
+ throw new Error("could not resolve HEAD");
204
+ return { dir: tmp, source: url, sha, cleanup };
205
+ }
206
+ catch (err) {
207
+ cleanup();
208
+ const stderr = String(err.stderr ?? err.message).trim();
209
+ throw new UserError(`Failed to fetch extension from ${source}: ${stderr}`);
210
+ }
211
+ }
212
+ export function addExtension(ws, source, coreVersion, cwd = process.cwd()) {
213
+ const fetched = fetchSource(source, cwd);
214
+ try {
215
+ const manifest = validateManifest(fetched.dir);
216
+ if (!isCompatible(manifest, coreVersion)) {
217
+ throw new UserError(`Extension "${manifest.name}" requires core ${manifest.core}, but installed core is ${coreVersion}.`);
218
+ }
219
+ const dest = path.join(ws.extensions, manifest.name);
220
+ fs.rmSync(dest, { recursive: true, force: true });
221
+ copyDir(fetched.dir, dest, (rel) => rel === ".git" || rel.startsWith(`.git${path.sep}`));
222
+ const lock = readLock(ws);
223
+ const entry = {
224
+ name: manifest.name,
225
+ source: fetched.source,
226
+ sha: fetched.sha,
227
+ hash: hashDir(dest),
228
+ version: manifest.version,
229
+ enabled: true,
230
+ installedAt: new Date().toISOString(),
231
+ };
232
+ const idx = lock.extensions.findIndex((e) => e.name === manifest.name);
233
+ if (idx >= 0)
234
+ lock.extensions[idx] = entry;
235
+ else
236
+ lock.extensions.push(entry);
237
+ writeLock(ws, lock);
238
+ return { entry, manifest, replaced: idx >= 0 };
239
+ }
240
+ finally {
241
+ fetched.cleanup();
242
+ }
243
+ }
244
+ export function removeExtension(ws, name) {
245
+ const lock = readLock(ws);
246
+ const idx = lock.extensions.findIndex((e) => e.name === name);
247
+ if (idx < 0 && !isDir(path.join(ws.extensions, name))) {
248
+ throw new UserError(`Extension "${name}" is not installed.`);
249
+ }
250
+ if (idx >= 0)
251
+ lock.extensions.splice(idx, 1);
252
+ writeLock(ws, lock);
253
+ fs.rmSync(path.join(ws.extensions, name), { recursive: true, force: true });
254
+ }
255
+ /** Enabled, valid extensions in lock order. Invalid ones are reported via `warn`. */
256
+ export function loadExtensions(ws, warn = () => { }) {
257
+ const out = [];
258
+ for (const entry of readLock(ws).extensions) {
259
+ if (!entry.enabled)
260
+ continue;
261
+ const dir = path.join(ws.extensions, entry.name);
262
+ try {
263
+ out.push({ entry, manifest: validateManifest(dir), dir });
264
+ }
265
+ catch (err) {
266
+ warn(`Skipping extension "${entry.name}": ${err.message}`);
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+ /** Disable extensions whose `core` range excludes `coreVersion`. Returns names disabled. */
272
+ export function disableIncompatible(ws, coreVersion) {
273
+ const lock = readLock(ws);
274
+ const disabled = [];
275
+ for (const entry of lock.extensions) {
276
+ if (!entry.enabled)
277
+ continue;
278
+ const dir = path.join(ws.extensions, entry.name);
279
+ let reason;
280
+ try {
281
+ const m = validateManifest(dir);
282
+ if (!isCompatible(m, coreVersion))
283
+ reason = `requires core ${m.core}, installed core is ${coreVersion}`;
284
+ }
285
+ catch (err) {
286
+ reason = err.message;
287
+ }
288
+ if (reason) {
289
+ entry.enabled = false;
290
+ entry.disabledReason = reason;
291
+ disabled.push(entry.name);
292
+ }
293
+ }
294
+ if (disabled.length)
295
+ writeLock(ws, lock);
296
+ return disabled;
297
+ }
@@ -0,0 +1,12 @@
1
+ export declare function readJson<T>(file: string): T;
2
+ export declare function readJsonOr<T>(file: string, fallback: T): T;
3
+ /** Write via temp file + rename so readers never observe a half-written file. */
4
+ export declare function writeFileAtomic(file: string, content: string): void;
5
+ export declare function writeJson(file: string, value: unknown): void;
6
+ export declare function copyDir(src: string, dest: string, skip?: (rel: string) => boolean): void;
7
+ export declare function isDir(p: string): boolean;
8
+ export declare function isFile(p: string): boolean;
9
+ /** True when `child` is `parent` or nested inside it. */
10
+ /** Resolve symlinks in the longest existing prefix of `p` (the rest may not exist yet). */
11
+ export declare function realish(p: string): string;
12
+ export declare function isInside(parent: string, child: string): boolean;
package/dist/fsutil.js ADDED
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function readJson(file) {
4
+ return JSON.parse(fs.readFileSync(file, "utf8"));
5
+ }
6
+ export function readJsonOr(file, fallback) {
7
+ try {
8
+ return readJson(file);
9
+ }
10
+ catch (err) {
11
+ if (err.code === "ENOENT")
12
+ return fallback;
13
+ throw err;
14
+ }
15
+ }
16
+ /** Write via temp file + rename so readers never observe a half-written file. */
17
+ export function writeFileAtomic(file, content) {
18
+ fs.mkdirSync(path.dirname(file), { recursive: true });
19
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
20
+ fs.writeFileSync(tmp, content);
21
+ fs.renameSync(tmp, file);
22
+ }
23
+ export function writeJson(file, value) {
24
+ writeFileAtomic(file, JSON.stringify(value, null, 2) + "\n");
25
+ }
26
+ export function copyDir(src, dest, skip = () => false) {
27
+ fs.cpSync(src, dest, {
28
+ recursive: true,
29
+ filter: (from) => !skip(path.relative(src, from)),
30
+ });
31
+ }
32
+ export function isDir(p) {
33
+ try {
34
+ return fs.statSync(p).isDirectory();
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ export function isFile(p) {
41
+ try {
42
+ return fs.statSync(p).isFile();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ /** True when `child` is `parent` or nested inside it. */
49
+ /** Resolve symlinks in the longest existing prefix of `p` (the rest may not exist yet). */
50
+ export function realish(p) {
51
+ const abs = path.resolve(p);
52
+ const rest = [];
53
+ let cur = abs;
54
+ for (;;) {
55
+ try {
56
+ return path.join(fs.realpathSync.native(cur), ...rest.reverse());
57
+ }
58
+ catch {
59
+ const parent = path.dirname(cur);
60
+ if (parent === cur)
61
+ return abs;
62
+ rest.push(path.basename(cur));
63
+ cur = parent;
64
+ }
65
+ }
66
+ }
67
+ export function isInside(parent, child) {
68
+ const rel = path.relative(parent, child);
69
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
70
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { type ExecFileSyncOptions } from "node:child_process";
2
+ export declare function git(cwd: string, args: string[], opts?: ExecFileSyncOptions): string;
3
+ export declare function tryGit(cwd: string, args: string[]): string | undefined;
4
+ export declare function isGitRepo(dir: string): boolean;
5
+ export declare function headSha(dir: string): string | undefined;
6
+ export type FileKind = "added" | "edited" | "deleted";
7
+ export interface FileChange {
8
+ path: string;
9
+ kind: FileKind;
10
+ added: number;
11
+ removed: number;
12
+ binary?: boolean;
13
+ }
14
+ export interface Snapshot {
15
+ files: FileChange[];
16
+ patch: string;
17
+ }
18
+ /**
19
+ * Diff of a worktree against `base` (default HEAD), including untracked
20
+ * files, without touching the real index.
21
+ */
22
+ export declare function snapshotWorktree(worktree: string, base?: string): Snapshot;
23
+ /** Patch for a single file vs HEAD (handles untracked files). */
24
+ export declare function filePatch(worktree: string, file: string): string;
25
+ export declare function addWorktree(repoPath: string, worktree: string, branch: string, base?: string): void;
26
+ export declare function removeWorktree(repoPath: string, worktree: string, force?: boolean): void;
27
+ export declare function deleteBranch(repoPath: string, branch: string, force?: boolean): void;
package/dist/git.js ADDED
@@ -0,0 +1,97 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ export function git(cwd, args, opts = {}) {
6
+ return execFileSync("git", args, {
7
+ cwd,
8
+ encoding: "utf8",
9
+ stdio: ["ignore", "pipe", "pipe"],
10
+ maxBuffer: 256 * 1024 * 1024,
11
+ ...opts,
12
+ });
13
+ }
14
+ export function tryGit(cwd, args) {
15
+ try {
16
+ return git(cwd, args);
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ export function isGitRepo(dir) {
23
+ return tryGit(dir, ["rev-parse", "--is-inside-work-tree"])?.trim() === "true";
24
+ }
25
+ export function headSha(dir) {
26
+ return tryGit(dir, ["rev-parse", "HEAD"])?.trim();
27
+ }
28
+ function parseNumstat(out) {
29
+ const m = new Map();
30
+ for (const line of out.split("\n")) {
31
+ if (!line)
32
+ continue;
33
+ const [a, r, ...rest] = line.split("\t");
34
+ const file = rest.join("\t");
35
+ const binary = a === "-" || r === "-";
36
+ m.set(file, { added: binary ? 0 : Number(a), removed: binary ? 0 : Number(r), binary });
37
+ }
38
+ return m;
39
+ }
40
+ /**
41
+ * Diff of a worktree against `base` (default HEAD), including untracked
42
+ * files, without touching the real index.
43
+ */
44
+ export function snapshotWorktree(worktree, base = "HEAD") {
45
+ const tmpIndex = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "openfleet-idx-")), "index");
46
+ const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
47
+ try {
48
+ git(worktree, ["read-tree", base], { env });
49
+ git(worktree, ["add", "-A", "--", "."], { env });
50
+ const status = git(worktree, ["diff", "--cached", "--name-status", "--no-renames", base], { env });
51
+ const numstat = parseNumstat(git(worktree, ["diff", "--cached", "--numstat", "--no-renames", base], { env }));
52
+ const patch = git(worktree, ["diff", "--cached", "--binary", "--no-renames", base], { env });
53
+ const files = [];
54
+ for (const line of status.split("\n")) {
55
+ if (!line)
56
+ continue;
57
+ const [code, ...rest] = line.split("\t");
58
+ const file = rest.join("\t");
59
+ const kind = code === "A" ? "added" : code === "D" ? "deleted" : "edited";
60
+ const n = numstat.get(file) ?? { added: 0, removed: 0, binary: false };
61
+ files.push({ path: file, kind, added: n.added, removed: n.removed, ...(n.binary ? { binary: true } : {}) });
62
+ }
63
+ return { files, patch };
64
+ }
65
+ finally {
66
+ fs.rmSync(path.dirname(tmpIndex), { recursive: true, force: true });
67
+ }
68
+ }
69
+ /** Patch for a single file vs HEAD (handles untracked files). */
70
+ export function filePatch(worktree, file) {
71
+ const rel = path.isAbsolute(file) ? path.relative(worktree, file) : file;
72
+ const tracked = tryGit(worktree, ["ls-files", "--error-unmatch", "--", rel]) !== undefined;
73
+ if (tracked)
74
+ return tryGit(worktree, ["diff", "HEAD", "--", rel]) ?? "";
75
+ try {
76
+ git(worktree, ["diff", "--no-index", "--", "/dev/null", rel]);
77
+ return "";
78
+ }
79
+ catch (err) {
80
+ // `git diff --no-index` exits 1 when files differ; stdout holds the patch.
81
+ return String(err.stdout ?? "");
82
+ }
83
+ }
84
+ export function addWorktree(repoPath, worktree, branch, base) {
85
+ fs.mkdirSync(path.dirname(worktree), { recursive: true });
86
+ const exists = tryGit(repoPath, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]) !== undefined;
87
+ if (exists)
88
+ git(repoPath, ["worktree", "add", worktree, branch]);
89
+ else
90
+ git(repoPath, ["worktree", "add", "-b", branch, worktree, ...(base ? [base] : [])]);
91
+ }
92
+ export function removeWorktree(repoPath, worktree, force = false) {
93
+ git(repoPath, ["worktree", "remove", ...(force ? ["--force"] : []), worktree]);
94
+ }
95
+ export function deleteBranch(repoPath, branch, force = false) {
96
+ git(repoPath, ["branch", force ? "-D" : "-d", branch]);
97
+ }
@@ -0,0 +1,43 @@
1
+ import { type FileKind } from "../git.js";
2
+ import { type Workspace } from "../workspace.js";
3
+ export interface HookPayload {
4
+ sessionId?: string;
5
+ timestamp?: number | string;
6
+ cwd?: string;
7
+ source?: string;
8
+ reason?: string;
9
+ toolName?: string;
10
+ toolArgs?: unknown;
11
+ agentId?: string;
12
+ agentName?: string;
13
+ agentDisplayName?: string;
14
+ agentType?: string;
15
+ response?: string;
16
+ [k: string]: unknown;
17
+ }
18
+ export interface HookContext {
19
+ ws: Workspace;
20
+ /** Project from OPENFLEET_PROJECT, if the session was launched by the CLI. */
21
+ project?: string;
22
+ }
23
+ export interface HookOutput {
24
+ additionalContext?: string;
25
+ }
26
+ /** Hooks are a no-op unless the session is inside (or launched for) an OpenFleet workspace. */
27
+ export declare function resolveContext(payload: HookPayload, env?: NodeJS.ProcessEnv): HookContext | undefined;
28
+ export declare function onSessionStart(ctx: HookContext, payload: HookPayload): HookOutput;
29
+ export declare function onSessionEnd(ctx: HookContext, payload: HookPayload): HookOutput;
30
+ export declare function onSubagentStart(ctx: HookContext, payload: HookPayload): HookOutput;
31
+ export declare function onSubagentStop(ctx: HookContext, payload: HookPayload): HookOutput;
32
+ interface ToolFileChange {
33
+ path: string;
34
+ kind: FileKind;
35
+ added: number;
36
+ removed: number;
37
+ }
38
+ /** Extract file changes from a postToolUse payload. Only file-mutating tools are recognised. */
39
+ export declare function fileChangesFromTool(toolName: string | undefined, rawArgs: unknown): ToolFileChange[];
40
+ export declare function onPostToolUse(ctx: HookContext, payload: HookPayload): HookOutput;
41
+ export declare const HANDLERS: Record<string, (ctx: HookContext, payload: HookPayload) => HookOutput>;
42
+ export declare function runHook(event: string, payload: HookPayload, env?: NodeJS.ProcessEnv): HookOutput;
43
+ export {};