@agentex/agent 0.0.23 → 0.0.26
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/CHANGELOG.md +338 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/derived.d.ts +5 -3
- package/dist/derived.d.ts.map +1 -1
- package/dist/derived.js +11 -7
- package/dist/derived.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/acp/index.d.ts +1 -1
- package/dist/providers/acp/index.d.ts.map +1 -1
- package/dist/providers/acp/index.js +5 -97
- package/dist/providers/acp/index.js.map +1 -1
- package/dist/providers/acp/session.d.ts +8 -1
- package/dist/providers/acp/session.d.ts.map +1 -1
- package/dist/providers/acp/session.js +94 -0
- package/dist/providers/acp/session.js.map +1 -1
- package/dist/providers/claude/attach.d.ts +8 -0
- package/dist/providers/claude/attach.d.ts.map +1 -0
- package/dist/providers/claude/attach.js +113 -0
- package/dist/providers/claude/attach.js.map +1 -0
- package/dist/providers/claude/execute.d.ts.map +1 -1
- package/dist/providers/claude/execute.js +17 -2
- package/dist/providers/claude/execute.js.map +1 -1
- package/dist/providers/claude/goal-capability.d.ts +15 -0
- package/dist/providers/claude/goal-capability.d.ts.map +1 -0
- package/dist/providers/claude/goal-capability.js +20 -0
- package/dist/providers/claude/goal-capability.js.map +1 -0
- package/dist/providers/claude/index.d.ts.map +1 -1
- package/dist/providers/claude/index.js +8 -4
- package/dist/providers/claude/index.js.map +1 -1
- package/dist/providers/claude/session.d.ts +11 -9
- package/dist/providers/claude/session.d.ts.map +1 -1
- package/dist/providers/claude/session.js +36 -14
- package/dist/providers/claude/session.js.map +1 -1
- package/dist/providers/codex/attach.d.ts +9 -0
- package/dist/providers/codex/attach.d.ts.map +1 -0
- package/dist/providers/codex/attach.js +93 -0
- package/dist/providers/codex/attach.js.map +1 -0
- package/dist/providers/codex/execute.d.ts.map +1 -1
- package/dist/providers/codex/execute.js +17 -3
- package/dist/providers/codex/execute.js.map +1 -1
- package/dist/providers/codex/goal-capability.d.ts +13 -0
- package/dist/providers/codex/goal-capability.d.ts.map +1 -0
- package/dist/providers/codex/goal-capability.js +18 -0
- package/dist/providers/codex/goal-capability.js.map +1 -0
- package/dist/providers/codex/index.d.ts +1 -0
- package/dist/providers/codex/index.d.ts.map +1 -1
- package/dist/providers/codex/index.js +9 -6
- package/dist/providers/codex/index.js.map +1 -1
- package/dist/providers/codex/session.d.ts +11 -7
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +37 -12
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/providers/codex/transcript-normalize.d.ts +28 -0
- package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
- package/dist/providers/codex/transcript-normalize.js +191 -0
- package/dist/providers/codex/transcript-normalize.js.map +1 -0
- package/dist/providers/cursor/index.d.ts.map +1 -1
- package/dist/providers/cursor/index.js +2 -2
- package/dist/providers/cursor/index.js.map +1 -1
- package/dist/providers/openclaw/index.d.ts.map +1 -1
- package/dist/providers/openclaw/index.js +2 -2
- package/dist/providers/openclaw/index.js.map +1 -1
- package/dist/providers/opencode/index.d.ts.map +1 -1
- package/dist/providers/opencode/index.js +3 -5
- package/dist/providers/opencode/index.js.map +1 -1
- package/dist/providers/pi/index.d.ts.map +1 -1
- package/dist/providers/pi/index.js +3 -5
- package/dist/providers/pi/index.js.map +1 -1
- package/dist/providers/process/index.d.ts.map +1 -1
- package/dist/providers/process/index.js +2 -2
- package/dist/providers/process/index.js.map +1 -1
- package/dist/registry.d.ts +0 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +0 -4
- package/dist/registry.js.map +1 -1
- package/dist/sessions/index.d.ts +3 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/record.d.ts +43 -0
- package/dist/sessions/record.d.ts.map +1 -0
- package/dist/sessions/record.js +85 -0
- package/dist/sessions/record.js.map +1 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/endpoint.d.ts +38 -0
- package/dist/utils/endpoint.d.ts.map +1 -0
- package/dist/utils/endpoint.js +151 -0
- package/dist/utils/endpoint.js.map +1 -0
- package/dist/utils/env.d.ts.map +1 -1
- package/dist/utils/env.js +5 -1
- package/dist/utils/env.js.map +1 -1
- package/dist/utils/uuid.d.ts +7 -1
- package/dist/utils/uuid.d.ts.map +1 -1
- package/dist/utils/uuid.js +21 -1
- package/dist/utils/uuid.js.map +1 -1
- package/package.json +64 -7
- package/src/derived.ts +311 -0
- package/src/goals/controller.ts +442 -0
- package/src/goals/index.ts +21 -0
- package/src/goals/normalize.ts +173 -0
- package/src/goals/sentinel.ts +90 -0
- package/src/index.ts +270 -0
- package/src/providers/_shared/http-agent.ts +304 -0
- package/src/providers/acp/index.ts +103 -0
- package/src/providers/acp/parse.ts +131 -0
- package/src/providers/acp/session.ts +744 -0
- package/src/providers/claude/attach.ts +147 -0
- package/src/providers/claude/codec.ts +43 -0
- package/src/providers/claude/execute.ts +300 -0
- package/src/providers/claude/goal-capability.ts +21 -0
- package/src/providers/claude/index.ts +72 -0
- package/src/providers/claude/mcp.ts +82 -0
- package/src/providers/claude/parse.ts +824 -0
- package/src/providers/claude/session.ts +1192 -0
- package/src/providers/claude/transcript.ts +555 -0
- package/src/providers/codex/attach.ts +123 -0
- package/src/providers/codex/codec.ts +50 -0
- package/src/providers/codex/execute.ts +337 -0
- package/src/providers/codex/goal-capability.ts +19 -0
- package/src/providers/codex/index.ts +57 -0
- package/src/providers/codex/modes.ts +159 -0
- package/src/providers/codex/parse.ts +691 -0
- package/src/providers/codex/plan-mode.ts +49 -0
- package/src/providers/codex/session.ts +1287 -0
- package/src/providers/codex/transcript-normalize.ts +197 -0
- package/src/providers/codex/transcript.ts +487 -0
- package/src/providers/codex/usage-scanner.ts +178 -0
- package/src/providers/copilot/index.ts +19 -0
- package/src/providers/cursor/codec.ts +44 -0
- package/src/providers/cursor/execute.ts +271 -0
- package/src/providers/cursor/index.ts +25 -0
- package/src/providers/cursor/parse.ts +288 -0
- package/src/providers/gemini/index.ts +21 -0
- package/src/providers/openclaw/codec.ts +40 -0
- package/src/providers/openclaw/execute.ts +19 -0
- package/src/providers/openclaw/index.ts +29 -0
- package/src/providers/opencode/codec.ts +50 -0
- package/src/providers/opencode/event-parse.ts +141 -0
- package/src/providers/opencode/execute.ts +251 -0
- package/src/providers/opencode/http-session.ts +427 -0
- package/src/providers/opencode/index.ts +30 -0
- package/src/providers/opencode/parse.ts +203 -0
- package/src/providers/opencode/server.ts +0 -0
- package/src/providers/pi/codec.ts +44 -0
- package/src/providers/pi/execute.ts +297 -0
- package/src/providers/pi/index.ts +30 -0
- package/src/providers/pi/parse.ts +231 -0
- package/src/providers/pi/session.ts +381 -0
- package/src/providers/process/execute.ts +148 -0
- package/src/providers/process/index.ts +52 -0
- package/src/registry.ts +40 -0
- package/src/sessions/index.ts +8 -0
- package/src/sessions/record.ts +108 -0
- package/src/types.ts +1638 -0
- package/src/utils/ask-user-question.ts +57 -0
- package/src/utils/auth.ts +661 -0
- package/src/utils/binary.ts +179 -0
- package/src/utils/endpoint.ts +172 -0
- package/src/utils/env.ts +63 -0
- package/src/utils/execute-all.ts +68 -0
- package/src/utils/exit-plan-mode.ts +40 -0
- package/src/utils/instructions.ts +427 -0
- package/src/utils/process.ts +223 -0
- package/src/utils/runtime-config.ts +100 -0
- package/src/utils/runtime-homes.ts +49 -0
- package/src/utils/skill-commands.ts +493 -0
- package/src/utils/skills.ts +500 -0
- package/src/utils/template.ts +16 -0
- package/src/utils/tool-names.ts +51 -0
- package/src/utils/uuid.ts +21 -0
- package/src/utils/workspace.ts +156 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type { SkillRuntime, SkillLocation } from "./skills.js";
|
|
5
|
+
import { getDefaultRuntimeHome } from "./runtime-homes.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read an instructions file and return its content.
|
|
9
|
+
* Returns null if no path is provided.
|
|
10
|
+
* Throws a clear error if the file doesn't exist.
|
|
11
|
+
*/
|
|
12
|
+
export async function resolveInstructions(filePath?: string): Promise<string | null> {
|
|
13
|
+
if (!filePath) return null;
|
|
14
|
+
try {
|
|
15
|
+
return await fs.readFile(filePath, "utf-8");
|
|
16
|
+
} catch (err) {
|
|
17
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
18
|
+
if (code === "ENOENT") {
|
|
19
|
+
throw new Error(`Instructions file not found: ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ===========================================================================
|
|
26
|
+
// installInstructions — the instruction-file twin of installSkills.
|
|
27
|
+
//
|
|
28
|
+
// installSkills condenses every runtime into two discovery channels
|
|
29
|
+
// (.agents/skills + .claude/skills) with a per-runtime "native" escape hatch.
|
|
30
|
+
// Instruction files follow the same shape:
|
|
31
|
+
//
|
|
32
|
+
// - every runtime except Claude reads AGENTS.md; Claude reads CLAUDE.md
|
|
33
|
+
// - Gemini reads GEMINI.md by default (AGENTS.md only when configured), so it
|
|
34
|
+
// gets a native escape hatch
|
|
35
|
+
//
|
|
36
|
+
// Two locations, mirroring installSkills:
|
|
37
|
+
//
|
|
38
|
+
// - "workspace": files at {cwd}/ — the repo-root AGENTS.md convention. Files
|
|
39
|
+
// dedupe by name, so the default writes CLAUDE.md + AGENTS.md once each.
|
|
40
|
+
// - "global": each runtime reads its own file in its own home dir
|
|
41
|
+
// (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md, ...). There
|
|
42
|
+
// is no universal ~/AGENTS.md, so global is inherently per-runtime.
|
|
43
|
+
//
|
|
44
|
+
// Unlike skills (which are symlinked dirs), instruction files carry content, so
|
|
45
|
+
// installInstructions does a managed-region merge: it wraps `content` in marker
|
|
46
|
+
// comments and replaces only that region on re-install, preserving anything the
|
|
47
|
+
// user wrote outside it.
|
|
48
|
+
// ===========================================================================
|
|
49
|
+
|
|
50
|
+
interface RuntimeInstructionSpec {
|
|
51
|
+
/** File this runtime reads at a workspace/repo root. AGENTS.md for all but Claude. */
|
|
52
|
+
projectFile: string;
|
|
53
|
+
/** The runtime's own preferred filename. Differs from projectFile only for Gemini (GEMINI.md). */
|
|
54
|
+
nativeFile: string;
|
|
55
|
+
/** Whether the runtime has a file-based global config. False for Cursor (global = app User Rules). */
|
|
56
|
+
hasGlobalFile: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const RUNTIME_INSTRUCTIONS: Record<SkillRuntime, RuntimeInstructionSpec> = {
|
|
60
|
+
claude: { projectFile: "CLAUDE.md", nativeFile: "CLAUDE.md", hasGlobalFile: true },
|
|
61
|
+
codex: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: true },
|
|
62
|
+
opencode: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: true },
|
|
63
|
+
gemini: { projectFile: "AGENTS.md", nativeFile: "GEMINI.md", hasGlobalFile: true },
|
|
64
|
+
cursor: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: false },
|
|
65
|
+
pi: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: true },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const ALL_RUNTIMES: SkillRuntime[] = ["claude", "codex", "gemini", "cursor", "opencode", "pi"];
|
|
69
|
+
|
|
70
|
+
const DEFAULT_MANAGED_TAG = "agentex";
|
|
71
|
+
|
|
72
|
+
export interface InstallInstructionsOptions {
|
|
73
|
+
/** Which runtimes to write instruction files for. Defaults to all known runtimes. */
|
|
74
|
+
runtimes?: SkillRuntime[];
|
|
75
|
+
/** "workspace" ({cwd}/) — default — or "global" (each runtime's home dir). */
|
|
76
|
+
location?: SkillLocation;
|
|
77
|
+
/** Working directory. Required for "workspace". */
|
|
78
|
+
cwd?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Also write each runtime's native file when it differs from the shared
|
|
81
|
+
* standard (currently only Gemini's GEMINI.md). Only affects "workspace";
|
|
82
|
+
* "global" always uses native files. Default: false.
|
|
83
|
+
*/
|
|
84
|
+
includeNativeFiles?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Wrap `content` in managed markers and merge into any existing file,
|
|
87
|
+
* replacing only the previously-managed region and preserving everything the
|
|
88
|
+
* user wrote outside it. Default: true. When false, the file is overwritten
|
|
89
|
+
* with raw `content` (escape hatch for fully-owned files).
|
|
90
|
+
*/
|
|
91
|
+
managed?: boolean;
|
|
92
|
+
/** Marker tag, so the comment reads `<!-- <tag>:managed:start -->`. Default: "agentex". */
|
|
93
|
+
managedTag?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Override the home-directory base for "global" installs (sandboxes / tests).
|
|
96
|
+
* Defaults to os.homedir().
|
|
97
|
+
*/
|
|
98
|
+
homeDir?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type InstructionStatus = "created" | "updated" | "skipped" | "error";
|
|
102
|
+
|
|
103
|
+
export interface InstructionInstallEntry {
|
|
104
|
+
/** The filename written, e.g. "AGENTS.md", "CLAUDE.md", "GEMINI.md". */
|
|
105
|
+
filename: string;
|
|
106
|
+
/** Absolute path written. */
|
|
107
|
+
targetPath: string;
|
|
108
|
+
/** Which requested runtimes this file serves. */
|
|
109
|
+
runtimes: SkillRuntime[];
|
|
110
|
+
status: InstructionStatus;
|
|
111
|
+
error?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface InstructionInstallResult {
|
|
115
|
+
entries: InstructionInstallEntry[];
|
|
116
|
+
installed: number; // newly created files
|
|
117
|
+
updated: number; // existing files whose content changed
|
|
118
|
+
skipped: number; // content already current → no write
|
|
119
|
+
errors: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface InstructionTarget {
|
|
123
|
+
filename: string;
|
|
124
|
+
targetPath: string;
|
|
125
|
+
runtimes: SkillRuntime[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface RemoveInstructionsOptions {
|
|
129
|
+
runtimes?: SkillRuntime[];
|
|
130
|
+
location?: SkillLocation;
|
|
131
|
+
cwd?: string;
|
|
132
|
+
/** Marker tag whose managed region should be removed. Default: "agentex". */
|
|
133
|
+
managedTag?: string;
|
|
134
|
+
homeDir?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type InstructionRemoveStatus = "removed" | "not_found" | "skipped" | "error";
|
|
138
|
+
|
|
139
|
+
export interface InstructionRemoveEntry {
|
|
140
|
+
filename: string;
|
|
141
|
+
targetPath: string;
|
|
142
|
+
runtimes: SkillRuntime[];
|
|
143
|
+
status: InstructionRemoveStatus;
|
|
144
|
+
error?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface InstructionRemoveResult {
|
|
148
|
+
entries: InstructionRemoveEntry[];
|
|
149
|
+
removed: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ManagedBlockOptions {
|
|
153
|
+
/** Marker tag. Default: "agentex". */
|
|
154
|
+
tag?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Path resolution
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve which instruction files would be written for the given options,
|
|
163
|
+
* without touching disk.
|
|
164
|
+
*
|
|
165
|
+
* - "workspace": files dedupe by name under {cwd}/ (the default writes
|
|
166
|
+
* CLAUDE.md + AGENTS.md once each). `includeNativeFiles` adds GEMINI.md.
|
|
167
|
+
* - "global": one file per runtime in each runtime's home dir. Runtimes without
|
|
168
|
+
* a file-based global config (Cursor) are omitted.
|
|
169
|
+
*/
|
|
170
|
+
export function resolveInstructionTargets(options?: {
|
|
171
|
+
runtimes?: SkillRuntime[];
|
|
172
|
+
location?: SkillLocation;
|
|
173
|
+
cwd?: string;
|
|
174
|
+
includeNativeFiles?: boolean;
|
|
175
|
+
homeDir?: string;
|
|
176
|
+
}): InstructionTarget[] {
|
|
177
|
+
const location: SkillLocation = options?.location ?? "workspace";
|
|
178
|
+
const runtimes = dedupeRuntimes(options?.runtimes ?? ALL_RUNTIMES);
|
|
179
|
+
|
|
180
|
+
if (location === "workspace") {
|
|
181
|
+
const cwd = options?.cwd;
|
|
182
|
+
if (!cwd) throw new Error("cwd is required when location is 'workspace'");
|
|
183
|
+
|
|
184
|
+
const byFile = new Map<string, SkillRuntime[]>();
|
|
185
|
+
const add = (filename: string, runtime: SkillRuntime) => {
|
|
186
|
+
const list = byFile.get(filename) ?? [];
|
|
187
|
+
list.push(runtime);
|
|
188
|
+
byFile.set(filename, list);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
for (const runtime of runtimes) {
|
|
192
|
+
const spec = RUNTIME_INSTRUCTIONS[runtime];
|
|
193
|
+
add(spec.projectFile, runtime);
|
|
194
|
+
if (options?.includeNativeFiles && spec.nativeFile !== spec.projectFile) {
|
|
195
|
+
add(spec.nativeFile, runtime);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return [...byFile.entries()].map(([filename, rts]) => ({
|
|
200
|
+
filename,
|
|
201
|
+
targetPath: path.join(cwd, filename),
|
|
202
|
+
runtimes: dedupeRuntimes(rts),
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// global: each runtime reads its own native file in its own home dir.
|
|
207
|
+
const targets: InstructionTarget[] = [];
|
|
208
|
+
for (const runtime of runtimes) {
|
|
209
|
+
const spec = RUNTIME_INSTRUCTIONS[runtime];
|
|
210
|
+
if (!spec.hasGlobalFile) continue; // e.g. cursor global = app User Rules, not a file
|
|
211
|
+
const home = getDefaultRuntimeHome(runtime, options?.homeDir);
|
|
212
|
+
targets.push({
|
|
213
|
+
filename: spec.nativeFile,
|
|
214
|
+
targetPath: path.join(home, spec.nativeFile),
|
|
215
|
+
runtimes: [runtime],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return targets;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function dedupeRuntimes(runtimes: SkillRuntime[]): SkillRuntime[] {
|
|
222
|
+
return [...new Set(runtimes)];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Public API
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Install an instruction brief into the right per-runtime files.
|
|
231
|
+
*
|
|
232
|
+
* By default merges `content` into a managed region of `{cwd}/CLAUDE.md` and
|
|
233
|
+
* `{cwd}/AGENTS.md`, preserving any user-authored content outside the markers.
|
|
234
|
+
* Idempotent: a re-install with unchanged content reports every entry as
|
|
235
|
+
* "skipped".
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* // Workspace (repo-root) install — the common case.
|
|
240
|
+
* await installInstructions(brief, { location: "workspace", cwd: projectDir });
|
|
241
|
+
* // → {cwd}/CLAUDE.md + {cwd}/AGENTS.md
|
|
242
|
+
*
|
|
243
|
+
* // Also drop Gemini's native GEMINI.md (it doesn't read AGENTS.md by default).
|
|
244
|
+
* await installInstructions(brief, { location: "workspace", cwd, includeNativeFiles: true });
|
|
245
|
+
*
|
|
246
|
+
* // Global install — per-runtime home files.
|
|
247
|
+
* await installInstructions(brief, { location: "global" });
|
|
248
|
+
* // → ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md, ...
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
export async function installInstructions(
|
|
252
|
+
content: string,
|
|
253
|
+
options?: InstallInstructionsOptions,
|
|
254
|
+
): Promise<InstructionInstallResult> {
|
|
255
|
+
const managed = options?.managed ?? true;
|
|
256
|
+
const tag = options?.managedTag ?? DEFAULT_MANAGED_TAG;
|
|
257
|
+
const targets = resolveInstructionTargets(options);
|
|
258
|
+
const entries: InstructionInstallEntry[] = [];
|
|
259
|
+
|
|
260
|
+
for (const target of targets) {
|
|
261
|
+
try {
|
|
262
|
+
const existing = await readFileOrNull(target.targetPath);
|
|
263
|
+
const next = managed
|
|
264
|
+
? upsertManagedBlock(existing, content, { tag })
|
|
265
|
+
: ensureTrailingNewline(content);
|
|
266
|
+
|
|
267
|
+
let status: InstructionStatus;
|
|
268
|
+
if (existing === null) {
|
|
269
|
+
status = "created";
|
|
270
|
+
} else if (existing === next) {
|
|
271
|
+
status = "skipped";
|
|
272
|
+
} else {
|
|
273
|
+
status = "updated";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (status !== "skipped") {
|
|
277
|
+
await fs.mkdir(path.dirname(target.targetPath), { recursive: true });
|
|
278
|
+
await fs.writeFile(target.targetPath, next, { mode: 0o644 });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
entries.push({ ...target, status });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
entries.push({
|
|
284
|
+
...target,
|
|
285
|
+
status: "error",
|
|
286
|
+
error: err instanceof Error ? err.message : String(err),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
entries,
|
|
293
|
+
installed: entries.filter((e) => e.status === "created").length,
|
|
294
|
+
updated: entries.filter((e) => e.status === "updated").length,
|
|
295
|
+
skipped: entries.filter((e) => e.status === "skipped").length,
|
|
296
|
+
errors: entries.filter((e) => e.status === "error").length,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Remove the managed region installed by {@link installInstructions}, preserving
|
|
302
|
+
* any user-authored content outside the markers. If the file contains nothing
|
|
303
|
+
* but the managed block, it is deleted. User-owned files (no managed region) are
|
|
304
|
+
* left untouched and reported as "skipped".
|
|
305
|
+
*/
|
|
306
|
+
export async function removeInstructions(
|
|
307
|
+
options?: RemoveInstructionsOptions,
|
|
308
|
+
): Promise<InstructionRemoveResult> {
|
|
309
|
+
const tag = options?.managedTag ?? DEFAULT_MANAGED_TAG;
|
|
310
|
+
const targets = resolveInstructionTargets({
|
|
311
|
+
runtimes: options?.runtimes,
|
|
312
|
+
location: options?.location,
|
|
313
|
+
cwd: options?.cwd,
|
|
314
|
+
homeDir: options?.homeDir,
|
|
315
|
+
});
|
|
316
|
+
const entries: InstructionRemoveEntry[] = [];
|
|
317
|
+
|
|
318
|
+
for (const target of targets) {
|
|
319
|
+
try {
|
|
320
|
+
const existing = await readFileOrNull(target.targetPath);
|
|
321
|
+
if (existing === null) {
|
|
322
|
+
entries.push({ ...target, status: "not_found" });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const stripped = stripManagedBlock(existing, { tag });
|
|
327
|
+
if (stripped === existing) {
|
|
328
|
+
// No managed region present — never touch user-owned files.
|
|
329
|
+
entries.push({ ...target, status: "skipped" });
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (stripped === null) {
|
|
334
|
+
await fs.rm(target.targetPath, { force: true });
|
|
335
|
+
} else {
|
|
336
|
+
await fs.writeFile(target.targetPath, stripped, { mode: 0o644 });
|
|
337
|
+
}
|
|
338
|
+
entries.push({ ...target, status: "removed" });
|
|
339
|
+
} catch (err) {
|
|
340
|
+
entries.push({
|
|
341
|
+
...target,
|
|
342
|
+
status: "error",
|
|
343
|
+
error: err instanceof Error ? err.message : String(err),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { entries, removed: entries.filter((e) => e.status === "removed").length };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Managed-region merge (exported for low-level use / hosts with custom layouts)
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Merge `content` into `existing` as a managed region, preserving everything the
|
|
357
|
+
* user wrote outside the markers.
|
|
358
|
+
*
|
|
359
|
+
* - `existing` has a managed region → replace only the bytes between the markers.
|
|
360
|
+
* - `existing` has no markers → prepend the managed block, keep prior content below.
|
|
361
|
+
* - `existing` is null/empty → return just the managed block.
|
|
362
|
+
*
|
|
363
|
+
* The start marker embeds a short content hash, so re-running with identical
|
|
364
|
+
* content produces a byte-identical result (enabling cheap skip detection).
|
|
365
|
+
*/
|
|
366
|
+
export function upsertManagedBlock(
|
|
367
|
+
existing: string | null,
|
|
368
|
+
content: string,
|
|
369
|
+
options?: ManagedBlockOptions,
|
|
370
|
+
): string {
|
|
371
|
+
const tag = options?.tag ?? DEFAULT_MANAGED_TAG;
|
|
372
|
+
const block = buildManagedBlock(content, tag);
|
|
373
|
+
|
|
374
|
+
if (existing === null || existing.length === 0) {
|
|
375
|
+
return `${block}\n`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const re = managedBlockRegex(tag);
|
|
379
|
+
if (re.test(existing)) {
|
|
380
|
+
return existing.replace(re, block);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// No managed region yet — prepend it, keep the user's file below.
|
|
384
|
+
return `${block}\n\n${existing.replace(/^\n+/, "")}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Remove the managed region (if any) from `existing`, preserving user content.
|
|
389
|
+
* Returns the cleaned string, the original string if there was no managed
|
|
390
|
+
* region, or null if nothing but the managed block remained.
|
|
391
|
+
*/
|
|
392
|
+
export function stripManagedBlock(existing: string, options?: ManagedBlockOptions): string | null {
|
|
393
|
+
const tag = options?.tag ?? DEFAULT_MANAGED_TAG;
|
|
394
|
+
const re = managedBlockRegex(tag);
|
|
395
|
+
if (!re.test(existing)) return existing;
|
|
396
|
+
|
|
397
|
+
const stripped = existing.replace(re, "").replace(/^\n+/, "");
|
|
398
|
+
return stripped.trim().length === 0 ? null : stripped;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function buildManagedBlock(content: string, tag: string): string {
|
|
402
|
+
const body = content.replace(/\s+$/, "");
|
|
403
|
+
const hash = createHash("sha256").update(body).digest("hex").slice(0, 12);
|
|
404
|
+
return `<!-- ${tag}:managed:start hash=${hash} -->\n${body}\n<!-- ${tag}:managed:end -->`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function managedBlockRegex(tag: string): RegExp {
|
|
408
|
+
const t = escapeRegExp(tag);
|
|
409
|
+
return new RegExp(`<!--\\s*${t}:managed:start[^>]*-->[\\s\\S]*?<!--\\s*${t}:managed:end\\s*-->`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function escapeRegExp(s: string): string {
|
|
413
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function ensureTrailingNewline(s: string): string {
|
|
417
|
+
return s.endsWith("\n") ? s : `${s}\n`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function readFileOrNull(filePath: string): Promise<string | null> {
|
|
421
|
+
try {
|
|
422
|
+
return await fs.readFile(filePath, "utf-8");
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
425
|
+
throw err;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
export interface RunProcessOptions {
|
|
5
|
+
runId: string;
|
|
6
|
+
command: string;
|
|
7
|
+
args: string[];
|
|
8
|
+
cwd: string;
|
|
9
|
+
env: Record<string, string>;
|
|
10
|
+
stdin?: string;
|
|
11
|
+
timeoutSec?: number;
|
|
12
|
+
graceSec?: number;
|
|
13
|
+
maxCaptureBytes?: number;
|
|
14
|
+
onOutput?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
|
|
15
|
+
onStart?: (pid: number) => void;
|
|
16
|
+
/** AbortSignal to cancel the process. */
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RunProcessResult {
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
signal: string | null;
|
|
23
|
+
timedOut: boolean;
|
|
24
|
+
aborted: boolean;
|
|
25
|
+
stdout: string;
|
|
26
|
+
stderr: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_MAX_CAPTURE = 4 * 1024 * 1024; // 4MB
|
|
30
|
+
const DEFAULT_GRACE_SEC = 5;
|
|
31
|
+
|
|
32
|
+
export function deriveErrorCode(result: RunProcessResult): string | null {
|
|
33
|
+
if (result.aborted) return "aborted";
|
|
34
|
+
if (result.timedOut) return "timeout";
|
|
35
|
+
if (result.signal && !result.timedOut) return "killed";
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
40
|
+
if (process.platform === "win32") {
|
|
41
|
+
try {
|
|
42
|
+
spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
43
|
+
} catch {
|
|
44
|
+
// Best effort
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Unix: try process group kill
|
|
50
|
+
try {
|
|
51
|
+
process.kill(-pid, signal);
|
|
52
|
+
} catch {
|
|
53
|
+
// Fallback: kill individual process
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, signal);
|
|
56
|
+
} catch {
|
|
57
|
+
// Process already dead
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function runChildProcess(opts: RunProcessOptions): Promise<RunProcessResult> {
|
|
63
|
+
// If already aborted before spawn, return immediately
|
|
64
|
+
if (opts.signal?.aborted) {
|
|
65
|
+
return Promise.resolve({
|
|
66
|
+
exitCode: null,
|
|
67
|
+
signal: null,
|
|
68
|
+
timedOut: false,
|
|
69
|
+
aborted: true,
|
|
70
|
+
stdout: "",
|
|
71
|
+
stderr: "",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const maxCapture = opts.maxCaptureBytes ?? DEFAULT_MAX_CAPTURE;
|
|
77
|
+
const graceSec = opts.graceSec ?? DEFAULT_GRACE_SEC;
|
|
78
|
+
|
|
79
|
+
let stdoutBuf = "";
|
|
80
|
+
let stderrBuf = "";
|
|
81
|
+
let stdoutBytes = 0;
|
|
82
|
+
let stderrBytes = 0;
|
|
83
|
+
let timedOut = false;
|
|
84
|
+
let aborted = false;
|
|
85
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
let graceHandle: ReturnType<typeof setTimeout> | null = null;
|
|
87
|
+
|
|
88
|
+
// Chained promise for ordered callback execution
|
|
89
|
+
let callbackChain = Promise.resolve();
|
|
90
|
+
|
|
91
|
+
const child = spawn(opts.command, opts.args, {
|
|
92
|
+
cwd: opts.cwd,
|
|
93
|
+
env: opts.env,
|
|
94
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
95
|
+
shell: false,
|
|
96
|
+
detached: process.platform !== "win32", // Enable process group on Unix
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Notify caller of the child PID
|
|
100
|
+
if (opts.onStart && child.pid != null) {
|
|
101
|
+
opts.onStart(child.pid);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Write stdin and close
|
|
105
|
+
if (opts.stdin != null) {
|
|
106
|
+
child.stdin.write(opts.stdin);
|
|
107
|
+
}
|
|
108
|
+
child.stdin.end();
|
|
109
|
+
|
|
110
|
+
const appendWithCap = (
|
|
111
|
+
stream: "stdout" | "stderr",
|
|
112
|
+
chunk: string,
|
|
113
|
+
): void => {
|
|
114
|
+
const bytes = Buffer.byteLength(chunk, "utf-8");
|
|
115
|
+
if (stream === "stdout") {
|
|
116
|
+
if (stdoutBytes < maxCapture) {
|
|
117
|
+
const remaining = maxCapture - stdoutBytes;
|
|
118
|
+
stdoutBuf += bytes <= remaining ? chunk : chunk.slice(0, remaining);
|
|
119
|
+
}
|
|
120
|
+
stdoutBytes += bytes;
|
|
121
|
+
} else {
|
|
122
|
+
if (stderrBytes < maxCapture) {
|
|
123
|
+
const remaining = maxCapture - stderrBytes;
|
|
124
|
+
stderrBuf += bytes <= remaining ? chunk : chunk.slice(0, remaining);
|
|
125
|
+
}
|
|
126
|
+
stderrBytes += bytes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (opts.onOutput) {
|
|
130
|
+
const cb = opts.onOutput;
|
|
131
|
+
callbackChain = callbackChain
|
|
132
|
+
.then(() => cb(stream, chunk))
|
|
133
|
+
.catch(() => {
|
|
134
|
+
// Callback errors must not crash the process
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
child.stdout.setEncoding("utf-8");
|
|
140
|
+
child.stderr.setEncoding("utf-8");
|
|
141
|
+
|
|
142
|
+
child.stdout.on("data", (chunk: string) => appendWithCap("stdout", chunk));
|
|
143
|
+
child.stderr.on("data", (chunk: string) => appendWithCap("stderr", chunk));
|
|
144
|
+
|
|
145
|
+
// Timeout handling
|
|
146
|
+
if (opts.timeoutSec && opts.timeoutSec > 0) {
|
|
147
|
+
timeoutHandle = setTimeout(() => {
|
|
148
|
+
timedOut = true;
|
|
149
|
+
if (child.pid != null) {
|
|
150
|
+
killProcessTree(child.pid, "SIGTERM");
|
|
151
|
+
// Grace period then SIGKILL
|
|
152
|
+
graceHandle = setTimeout(() => {
|
|
153
|
+
if (child.pid != null) {
|
|
154
|
+
killProcessTree(child.pid, "SIGKILL");
|
|
155
|
+
}
|
|
156
|
+
}, graceSec * 1000);
|
|
157
|
+
}
|
|
158
|
+
}, opts.timeoutSec * 1000);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// AbortSignal handling
|
|
162
|
+
let abortGraceHandle: ReturnType<typeof setTimeout> | null = null;
|
|
163
|
+
const onAbort = () => {
|
|
164
|
+
aborted = true;
|
|
165
|
+
if (child.pid != null) {
|
|
166
|
+
killProcessTree(child.pid, "SIGTERM");
|
|
167
|
+
abortGraceHandle = setTimeout(() => {
|
|
168
|
+
if (child.pid != null) {
|
|
169
|
+
killProcessTree(child.pid, "SIGKILL");
|
|
170
|
+
}
|
|
171
|
+
}, graceSec * 1000);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
if (opts.signal) {
|
|
175
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cleanup = () => {
|
|
179
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
180
|
+
if (graceHandle) clearTimeout(graceHandle);
|
|
181
|
+
if (abortGraceHandle) clearTimeout(abortGraceHandle);
|
|
182
|
+
if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
child.on("close", (code, signal) => {
|
|
186
|
+
cleanup();
|
|
187
|
+
|
|
188
|
+
// Wait for callbacks to complete before resolving
|
|
189
|
+
callbackChain.then(() => {
|
|
190
|
+
resolve({
|
|
191
|
+
exitCode: code,
|
|
192
|
+
signal: signal ?? null,
|
|
193
|
+
timedOut,
|
|
194
|
+
aborted,
|
|
195
|
+
stdout: stdoutBuf,
|
|
196
|
+
stderr: stderrBuf,
|
|
197
|
+
});
|
|
198
|
+
}).catch(() => {
|
|
199
|
+
resolve({
|
|
200
|
+
exitCode: code,
|
|
201
|
+
signal: signal ?? null,
|
|
202
|
+
timedOut,
|
|
203
|
+
aborted,
|
|
204
|
+
stdout: stdoutBuf,
|
|
205
|
+
stderr: stderrBuf,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
child.on("error", (err) => {
|
|
211
|
+
cleanup();
|
|
212
|
+
|
|
213
|
+
resolve({
|
|
214
|
+
exitCode: null,
|
|
215
|
+
signal: null,
|
|
216
|
+
timedOut: false,
|
|
217
|
+
aborted: false,
|
|
218
|
+
stdout: stdoutBuf,
|
|
219
|
+
stderr: err.message,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|