@foundation0/api 1.1.11 → 1.1.13
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 +128 -128
- package/agents.ts +918 -918
- package/git.ts +20 -20
- package/libs/curl.ts +770 -770
- package/mcp/cli.mjs +37 -37
- package/mcp/cli.ts +87 -87
- package/mcp/client.ts +565 -565
- package/mcp/index.ts +15 -15
- package/mcp/server.ts +2991 -2956
- package/net.ts +170 -170
- package/package.json +13 -9
- package/projects.ts +4250 -4318
- package/taskgraph-parser.ts +217 -217
- package/libs/curl.test.ts +0 -130
- package/mcp/AGENTS.md +0 -130
- package/mcp/client.test.ts +0 -142
- package/mcp/manual.md +0 -161
- package/mcp/server.test.ts +0 -870
package/agents.ts
CHANGED
|
@@ -1,918 +1,918 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
import { spawnSync } from 'child_process'
|
|
4
|
-
import { pathToFileURL } from 'url'
|
|
5
|
-
|
|
6
|
-
export interface VersionedFileSpec {
|
|
7
|
-
parts: string[]
|
|
8
|
-
base: string
|
|
9
|
-
version: string | null
|
|
10
|
-
ext: string | null
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ActiveFileResult {
|
|
14
|
-
agentName: string
|
|
15
|
-
sourceFile: string
|
|
16
|
-
activeFile: string
|
|
17
|
-
matchedFile: string
|
|
18
|
-
mode: 'symlink'
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface AgentLoadResult {
|
|
22
|
-
agentName: string
|
|
23
|
-
loaded: unknown
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface AgentRunResult {
|
|
27
|
-
agentName: string
|
|
28
|
-
exitCode: number
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface AgentCreateResult {
|
|
32
|
-
agentName: string
|
|
33
|
-
agentRoot: string
|
|
34
|
-
createdPaths: string[]
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface ActiveConfigInput {
|
|
38
|
-
path: string
|
|
39
|
-
required: boolean
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const CLI_NAME = 'f0'
|
|
43
|
-
const AGENT_INITIAL_VERSION = 'v0.0.1'
|
|
44
|
-
const DEFAULT_SKILL_NAME = 'coding-standards'
|
|
45
|
-
const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
|
|
46
|
-
const VERSION_RE_CORE = `(?:${VERSION_RE})`
|
|
47
|
-
const VERSION_WITH_EXT_RE = new RegExp(`^(.+)\\.(${VERSION_RE})\\.([A-Za-z][A-Za-z0-9_-]*)$`)
|
|
48
|
-
const VERSION_ONLY_RE = new RegExp(`^(.+)\\.(${VERSION_RE})$`)
|
|
49
|
-
const PRIORITIZED_EXTS = ['.md', '.json', '.yaml', '.yml', '.ts', '.js', '.txt']
|
|
50
|
-
|
|
51
|
-
export function usage(): string {
|
|
52
|
-
return `Usage:\n ${CLI_NAME} agents create <agent-name>\n ${CLI_NAME} agents <agent-name> <file-path> --set-active\n ${CLI_NAME} agents --list\n ${CLI_NAME} agents <agent-name> load\n ${CLI_NAME} agents <agent-name> run [codex-args...]\n\n` +
|
|
53
|
-
`Examples:\n ${CLI_NAME} agents create reviewer\n ${CLI_NAME} agents coder /system/boot.v0.0.1 --set-active\n ${CLI_NAME} agents coder /system/boot --set-active --latest\n ${CLI_NAME} agents coder /skills/coding-standards.v0.0.1 --set-active\n ${CLI_NAME} agents --list\n` +
|
|
54
|
-
` ${CLI_NAME} agents coder load\n` +
|
|
55
|
-
` ${CLI_NAME} agents coder run --model gpt-5\n` +
|
|
56
|
-
`\n` +
|
|
57
|
-
`file-path is relative to the agent root (leading slash required).\n` +
|
|
58
|
-
`Use --latest to resolve /file-name to the latest version.\n` +
|
|
59
|
-
`The active file created is [file].active.<ext>.\n` +
|
|
60
|
-
`Use "run" to start codex with developer_instructions from system/prompt.ts.\n` +
|
|
61
|
-
`Set F0_CODEX_BIN (or EXAMPLE_CODEX_BIN) to pin a specific codex binary.\n`
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function resolveAgentsRoot(processRoot: string = process.cwd()): string {
|
|
65
|
-
const candidates = [
|
|
66
|
-
path.join(processRoot, 'agents'),
|
|
67
|
-
path.join(processRoot, 'wireborn', 'agents'),
|
|
68
|
-
]
|
|
69
|
-
|
|
70
|
-
for (const candidatesPath of candidates) {
|
|
71
|
-
if (existsDir(candidatesPath)) {
|
|
72
|
-
return candidatesPath
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const checked = candidates.map((candidate) => `\n checked path: ${candidate}`).join('')
|
|
77
|
-
|
|
78
|
-
throw new Error(
|
|
79
|
-
`Could not find agents directory relative to working directory.\n cwd: ${processRoot}${checked}`
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export const resolveAgentsRootFrom = resolveAgentsRoot
|
|
84
|
-
|
|
85
|
-
export function listAgents(processRoot: string = process.cwd()): string[] {
|
|
86
|
-
const agentsPath = resolveAgentsRoot(processRoot)
|
|
87
|
-
|
|
88
|
-
return fs
|
|
89
|
-
.readdirSync(agentsPath, { withFileTypes: true })
|
|
90
|
-
.filter((entry) => entry.isDirectory())
|
|
91
|
-
.map((entry) => entry.name)
|
|
92
|
-
.sort((a, b) => a.localeCompare(b))
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function normalizeAgentName(agentName: string): string {
|
|
96
|
-
if (!agentName || typeof agentName !== 'string') {
|
|
97
|
-
throw new Error('agent-name is required.')
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const normalized = agentName.trim()
|
|
101
|
-
if (!normalized) {
|
|
102
|
-
throw new Error('agent-name is required.')
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (normalized.startsWith('-')) {
|
|
106
|
-
throw new Error(`Invalid agent-name: ${agentName}`)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (normalized === '.' || normalized === '..' || normalized.includes('/') || normalized.includes('\\')) {
|
|
110
|
-
throw new Error(`Invalid agent-name: ${agentName}`)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) {
|
|
114
|
-
throw new Error(`Invalid agent-name: ${agentName}`)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return normalized
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function titleCaseAgentName(agentName: string): string {
|
|
121
|
-
return agentName
|
|
122
|
-
.split(/[-_]+/)
|
|
123
|
-
.filter(Boolean)
|
|
124
|
-
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
125
|
-
.join(' ')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function buildBootDocument(agentName: string): string {
|
|
129
|
-
const title = titleCaseAgentName(agentName) || agentName
|
|
130
|
-
return `# ${title} Agent Boot (${AGENT_INITIAL_VERSION})
|
|
131
|
-
|
|
132
|
-
You are the \`${agentName}\` agent.
|
|
133
|
-
|
|
134
|
-
## Role
|
|
135
|
-
- Follow the active workflow and execute scoped tasks.
|
|
136
|
-
- Keep outputs concise, concrete, and implementation-focused.
|
|
137
|
-
- Avoid changing unrelated files or behavior.
|
|
138
|
-
|
|
139
|
-
## Startup Sequence
|
|
140
|
-
1. Read active boot/workflow docs and enabled skills.
|
|
141
|
-
2. Confirm scope and constraints before changes.
|
|
142
|
-
3. Apply the smallest safe change set.
|
|
143
|
-
4. Report changed files and outcomes.
|
|
144
|
-
`
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function buildWorkflowDocument(agentName: string): string {
|
|
148
|
-
return `# ${titleCaseAgentName(agentName) || agentName} Workflow (${AGENT_INITIAL_VERSION})
|
|
149
|
-
|
|
150
|
-
1. Parse request and constraints.
|
|
151
|
-
2. Identify exact files and minimal edits.
|
|
152
|
-
3. Implement and verify changes.
|
|
153
|
-
4. If blocked, stop and report the blocker.
|
|
154
|
-
5. Return result summary with next actions if needed.
|
|
155
|
-
`
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function buildToolsConfig(): string {
|
|
159
|
-
return `${JSON.stringify({
|
|
160
|
-
tools: [
|
|
161
|
-
{
|
|
162
|
-
name: 'shell_command',
|
|
163
|
-
enabled: true,
|
|
164
|
-
description: 'Execute shell commands for file and workspace operations.',
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
name: 'apply_patch',
|
|
168
|
-
enabled: true,
|
|
169
|
-
description: 'Apply focused file edits safely.',
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
name: 'read_file',
|
|
173
|
-
enabled: true,
|
|
174
|
-
description: 'Read files when needed for context.',
|
|
175
|
-
},
|
|
176
|
-
],
|
|
177
|
-
permissions: {
|
|
178
|
-
network: false,
|
|
179
|
-
write: true,
|
|
180
|
-
delete: true,
|
|
181
|
-
},
|
|
182
|
-
}, null, 2)}
|
|
183
|
-
`
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function buildModelConfig(): string {
|
|
187
|
-
return `${JSON.stringify({
|
|
188
|
-
provider: 'openai',
|
|
189
|
-
model: 'gpt-5',
|
|
190
|
-
temperature: 0.2,
|
|
191
|
-
max_tokens: 4096,
|
|
192
|
-
top_p: 0.95,
|
|
193
|
-
}, null, 2)}
|
|
194
|
-
`
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function buildDefaultSkillDocument(): string {
|
|
198
|
-
return `# Coding Standards (${AGENT_INITIAL_VERSION})
|
|
199
|
-
|
|
200
|
-
- Keep changes minimal and focused on the request.
|
|
201
|
-
- Preserve existing behavior unless explicitly changing it.
|
|
202
|
-
- Prefer readable, deterministic implementations.
|
|
203
|
-
`
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const SYSTEM_PROMPT_TS_TEMPLATE = `import * as fs from 'fs'
|
|
207
|
-
import * as path from 'path'
|
|
208
|
-
|
|
209
|
-
const base = path.join(__dirname)
|
|
210
|
-
const readActive = (p: string) => fs.readFileSync(path.join(base, p), 'utf8')
|
|
211
|
-
|
|
212
|
-
export const boot = readActive('boot.active.md')
|
|
213
|
-
export const workflow = readActive('workflow.active.md')
|
|
214
|
-
export const tools = JSON.parse(readActive('tools.active.json'))
|
|
215
|
-
export const model = JSON.parse(readActive('model.active.json'))
|
|
216
|
-
|
|
217
|
-
export const prompt = [boot, workflow].join('\\n\\n')
|
|
218
|
-
|
|
219
|
-
export const agentConfig = {
|
|
220
|
-
boot,
|
|
221
|
-
workflow,
|
|
222
|
-
tools,
|
|
223
|
-
model,
|
|
224
|
-
prompt,
|
|
225
|
-
}
|
|
226
|
-
`
|
|
227
|
-
|
|
228
|
-
const SKILLS_TS_TEMPLATE = `import * as fs from 'fs'
|
|
229
|
-
import * as path from 'path'
|
|
230
|
-
|
|
231
|
-
export interface SkillFile {
|
|
232
|
-
id: string
|
|
233
|
-
version: string
|
|
234
|
-
activeFile: string
|
|
235
|
-
resolvedFile: string
|
|
236
|
-
content: string
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const skillsDir = path.join(__dirname)
|
|
240
|
-
const enabledListPath = path.join(skillsDir, 'enabled-skills.md')
|
|
241
|
-
|
|
242
|
-
const resolveVersionAndId = (activeFile: string) => {
|
|
243
|
-
const baseName = path.basename(activeFile, '.active.md')
|
|
244
|
-
const resolved = \`\${baseName}.${AGENT_INITIAL_VERSION}.md\`
|
|
245
|
-
return {
|
|
246
|
-
id: baseName,
|
|
247
|
-
version: '${AGENT_INITIAL_VERSION}',
|
|
248
|
-
resolvedFile: resolved,
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const enabledLines = fs
|
|
253
|
-
.readFileSync(enabledListPath, 'utf8')
|
|
254
|
-
.split(/\\r?\\n/)
|
|
255
|
-
.map((line) => line.trim())
|
|
256
|
-
.filter(Boolean)
|
|
257
|
-
|
|
258
|
-
export const skills: SkillFile[] = enabledLines.map((activeFile) => {
|
|
259
|
-
const resolved = resolveVersionAndId(activeFile)
|
|
260
|
-
return {
|
|
261
|
-
...resolved,
|
|
262
|
-
activeFile,
|
|
263
|
-
content: fs.readFileSync(path.join(skillsDir, resolved.resolvedFile), 'utf8'),
|
|
264
|
-
}
|
|
265
|
-
})
|
|
266
|
-
`
|
|
267
|
-
|
|
268
|
-
const LOAD_TS_TEMPLATE = `import { readFileSync } from 'fs'
|
|
269
|
-
import * as path from 'path'
|
|
270
|
-
|
|
271
|
-
import { agentConfig } from './system/prompt'
|
|
272
|
-
import { skills } from './skills/skills'
|
|
273
|
-
|
|
274
|
-
const activeDoc = path.join(__dirname, 'system')
|
|
275
|
-
|
|
276
|
-
const loadActive = (name: string) =>
|
|
277
|
-
readFileSync(path.join(activeDoc, name), 'utf8').trim()
|
|
278
|
-
|
|
279
|
-
export const load = () => ({
|
|
280
|
-
...agentConfig,
|
|
281
|
-
bootDoc: loadActive('boot.active.md'),
|
|
282
|
-
workflowDoc: loadActive('workflow.active.md'),
|
|
283
|
-
skills,
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
export type AgentBundle = ReturnType<typeof load>
|
|
287
|
-
`
|
|
288
|
-
|
|
289
|
-
export function parseTargetSpec(spec: string): VersionedFileSpec {
|
|
290
|
-
if (!spec || typeof spec !== 'string') {
|
|
291
|
-
throw new Error('file-path is required.')
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const normalized = spec.replace(/\\/g, '/').trim()
|
|
295
|
-
|
|
296
|
-
if (!normalized.startsWith('/')) {
|
|
297
|
-
throw new Error('file-path must start with a leading slash, e.g. /system/boot.v0.0.1')
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const body = normalized.slice(1)
|
|
301
|
-
if (!body) {
|
|
302
|
-
throw new Error('file-path is empty.')
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const parts = body.split('/').filter(Boolean)
|
|
306
|
-
if (parts.length < 2) {
|
|
307
|
-
throw new Error('file-path must include a file name, for example /system/boot.v0.0.1')
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const file = parts.pop()
|
|
311
|
-
if (!file) {
|
|
312
|
-
throw new Error('file-path must include a file name, for example /system/boot.v0.0.1')
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const withExt = file.match(VERSION_WITH_EXT_RE)
|
|
316
|
-
const withoutExt = file.match(VERSION_ONLY_RE)
|
|
317
|
-
if (withExt) {
|
|
318
|
-
return {
|
|
319
|
-
parts,
|
|
320
|
-
base: withExt[1],
|
|
321
|
-
version: withExt[2],
|
|
322
|
-
ext: withExt[3],
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (withoutExt) {
|
|
327
|
-
return {
|
|
328
|
-
parts,
|
|
329
|
-
base: withoutExt[1],
|
|
330
|
-
version: withoutExt[2],
|
|
331
|
-
ext: null,
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (parts.length === 0) {
|
|
336
|
-
throw new Error('file-path must include a file name, for example /system/boot or /system/boot.v0.0.1.')
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return {
|
|
340
|
-
parts,
|
|
341
|
-
base: file,
|
|
342
|
-
version: null,
|
|
343
|
-
ext: null,
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function parseVersion(version: string): number[] {
|
|
348
|
-
const clean = version.replace(/^v/, '')
|
|
349
|
-
return clean.split('.').filter(Boolean).map((part) => Number.parseInt(part, 10))
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function escapeRegExp(value: string): string {
|
|
353
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function compareVersions(left: string, right: string): number {
|
|
357
|
-
const a = parseVersion(left)
|
|
358
|
-
const b = parseVersion(right)
|
|
359
|
-
const size = Math.max(a.length, b.length)
|
|
360
|
-
|
|
361
|
-
for (let i = 0; i < size; i += 1) {
|
|
362
|
-
const leftValue = a[i] ?? 0
|
|
363
|
-
const rightValue = b[i] ?? 0
|
|
364
|
-
if (leftValue > rightValue) return 1
|
|
365
|
-
if (leftValue < rightValue) return -1
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return 0
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function findLatestVersionedFile(targetDir: string, base: string, ext: string | null): string | null {
|
|
372
|
-
const fileEntries = fs
|
|
373
|
-
.readdirSync(targetDir, { withFileTypes: true })
|
|
374
|
-
.filter((entry) => entry.isFile())
|
|
375
|
-
.map((entry) => entry.name)
|
|
376
|
-
|
|
377
|
-
const safeBase = escapeRegExp(base)
|
|
378
|
-
const regex = new RegExp(`^${safeBase}\\.(${VERSION_RE_CORE})\\.([^.]+)$`)
|
|
379
|
-
const matches = fileEntries
|
|
380
|
-
.map((entry) => {
|
|
381
|
-
const match = entry.match(regex)
|
|
382
|
-
if (!match) return null
|
|
383
|
-
|
|
384
|
-
const version = match[1]
|
|
385
|
-
const detectedExt = `.${match[2]}`
|
|
386
|
-
if (ext && detectedExt !== `.${ext}`) {
|
|
387
|
-
return null
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return { entry, version, detectedExt }
|
|
391
|
-
})
|
|
392
|
-
.filter((value): value is { entry: string; version: string; detectedExt: string } => value !== null)
|
|
393
|
-
|
|
394
|
-
if (matches.length === 0) return null
|
|
395
|
-
|
|
396
|
-
matches.sort((a, b) => compareVersions(a.version, b.version))
|
|
397
|
-
const selected = matches[matches.length - 1]
|
|
398
|
-
|
|
399
|
-
const version = selected.version
|
|
400
|
-
const sameVersion = matches
|
|
401
|
-
.filter((item) => compareVersions(item.version, version) === 0)
|
|
402
|
-
.map((item) => item.entry)
|
|
403
|
-
.sort()
|
|
404
|
-
|
|
405
|
-
if (sameVersion.length <= 1) {
|
|
406
|
-
return sameVersion[0]
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
for (const extName of PRIORITIZED_EXTS) {
|
|
410
|
-
const preferred = sameVersion.find((entryName) => entryName.endsWith(extName))
|
|
411
|
-
if (preferred) {
|
|
412
|
-
return preferred
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return sameVersion.sort()[0]
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function resolveTargetFile(targetDir: string, spec: VersionedFileSpec, options: { latest?: boolean } = {}): string | null {
|
|
420
|
-
const entries = fs
|
|
421
|
-
.readdirSync(targetDir, { withFileTypes: true })
|
|
422
|
-
.filter((entry) => entry.isFile())
|
|
423
|
-
.map((entry) => entry.name)
|
|
424
|
-
|
|
425
|
-
if (!spec.version) {
|
|
426
|
-
if (!options.latest) {
|
|
427
|
-
return null
|
|
428
|
-
}
|
|
429
|
-
return findLatestVersionedFile(targetDir, spec.base, null)
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (spec.ext) {
|
|
433
|
-
const exact = `${spec.base}.${spec.version}.${spec.ext}`
|
|
434
|
-
if (entries.includes(exact)) {
|
|
435
|
-
return exact
|
|
436
|
-
}
|
|
437
|
-
return null
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const prefix = `${spec.base}.${spec.version}.`
|
|
441
|
-
const candidates = entries.filter((entry) => entry.startsWith(prefix))
|
|
442
|
-
if (candidates.length === 0) {
|
|
443
|
-
return null
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
for (const ext of PRIORITIZED_EXTS) {
|
|
447
|
-
const match = candidates.find((entry) => entry.endsWith(ext))
|
|
448
|
-
if (match) {
|
|
449
|
-
return match
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
return candidates.sort()[0]
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
export function setActiveLink(sourceFile: string, activeFile: string): 'symlink' {
|
|
457
|
-
if (!existsFile(sourceFile)) {
|
|
458
|
-
throw new Error(`Versioned file not found: ${sourceFile}`)
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
fs.mkdirSync(path.dirname(activeFile), { recursive: true })
|
|
462
|
-
|
|
463
|
-
// Remove the active pointer first. Using rmSync directly (instead of existsSync)
|
|
464
|
-
// ensures we also clear broken symlinks (existsSync returns false for them).
|
|
465
|
-
fs.rmSync(activeFile, { force: true })
|
|
466
|
-
|
|
467
|
-
const linkTarget = path.relative(path.dirname(activeFile), sourceFile)
|
|
468
|
-
|
|
469
|
-
try {
|
|
470
|
-
fs.symlinkSync(linkTarget, activeFile, 'file')
|
|
471
|
-
return 'symlink'
|
|
472
|
-
} catch (error) {
|
|
473
|
-
if (process.platform === 'win32') {
|
|
474
|
-
const ok = createWindowsSymlink(activeFile, sourceFile)
|
|
475
|
-
if (ok) {
|
|
476
|
-
return 'symlink'
|
|
477
|
-
}
|
|
478
|
-
throw new Error(
|
|
479
|
-
`Failed to create symlink on Windows. Run shell as Administrator or enable Developer Mode.\n${String(error?.message ?? error)}`
|
|
480
|
-
)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
throw error
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function createWindowsSymlink(activeFile: string, sourceFile: string): boolean {
|
|
488
|
-
const quote = (value: string) => `"${value.replace(/\"/g, '""')}"`
|
|
489
|
-
const command = `mklink ${quote(path.resolve(activeFile))} ${quote(path.resolve(sourceFile))}`
|
|
490
|
-
const result = spawnSync('cmd', ['/c', command], {
|
|
491
|
-
shell: false,
|
|
492
|
-
encoding: 'utf8',
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
return result.status === 0
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export function createAgent(agentName: string, processRoot: string = process.cwd()): AgentCreateResult {
|
|
499
|
-
const normalizedAgentName = normalizeAgentName(agentName)
|
|
500
|
-
const agentsRoot = resolveAgentsRootFrom(processRoot)
|
|
501
|
-
const agentRoot = path.join(agentsRoot, normalizedAgentName)
|
|
502
|
-
|
|
503
|
-
if (fs.existsSync(agentRoot)) {
|
|
504
|
-
throw new Error(`Agent folder already exists: ${agentRoot}`)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const systemDir = path.join(agentRoot, 'system')
|
|
508
|
-
const skillsDir = path.join(agentRoot, 'skills')
|
|
509
|
-
|
|
510
|
-
fs.mkdirSync(systemDir, { recursive: true })
|
|
511
|
-
fs.mkdirSync(skillsDir, { recursive: true })
|
|
512
|
-
|
|
513
|
-
const createdPaths: string[] = []
|
|
514
|
-
const writeFile = (relativePath: string, content: string) => {
|
|
515
|
-
const absolutePath = path.join(agentRoot, relativePath)
|
|
516
|
-
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
|
|
517
|
-
fs.writeFileSync(absolutePath, content, 'utf8')
|
|
518
|
-
createdPaths.push(absolutePath)
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const bootFileName = `boot.${AGENT_INITIAL_VERSION}.md`
|
|
522
|
-
const workflowFileName = `workflow.${AGENT_INITIAL_VERSION}.md`
|
|
523
|
-
const toolsFileName = `tools.${AGENT_INITIAL_VERSION}.json`
|
|
524
|
-
const modelFileName = `model.${AGENT_INITIAL_VERSION}.json`
|
|
525
|
-
const skillFileName = `${DEFAULT_SKILL_NAME}.${AGENT_INITIAL_VERSION}.md`
|
|
526
|
-
const activeSkillFileName = `${DEFAULT_SKILL_NAME}.active.md`
|
|
527
|
-
|
|
528
|
-
writeFile(path.join('system', bootFileName), buildBootDocument(normalizedAgentName))
|
|
529
|
-
writeFile(path.join('system', workflowFileName), buildWorkflowDocument(normalizedAgentName))
|
|
530
|
-
writeFile(path.join('system', toolsFileName), buildToolsConfig())
|
|
531
|
-
writeFile(path.join('system', modelFileName), buildModelConfig())
|
|
532
|
-
writeFile(path.join('system', 'prompt.ts'), SYSTEM_PROMPT_TS_TEMPLATE)
|
|
533
|
-
|
|
534
|
-
writeFile(path.join('skills', skillFileName), buildDefaultSkillDocument())
|
|
535
|
-
writeFile(path.join('skills', 'enabled-skills.md'), `${activeSkillFileName}\n`)
|
|
536
|
-
writeFile(path.join('skills', 'skills.ts'), SKILLS_TS_TEMPLATE)
|
|
537
|
-
|
|
538
|
-
writeFile('load.ts', LOAD_TS_TEMPLATE)
|
|
539
|
-
|
|
540
|
-
const createActiveLink = (versionedFile: string, activeFile: string) => {
|
|
541
|
-
const source = path.join(agentRoot, versionedFile)
|
|
542
|
-
const target = path.join(agentRoot, activeFile)
|
|
543
|
-
setActiveLink(source, target)
|
|
544
|
-
createdPaths.push(target)
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
createActiveLink(path.join('system', bootFileName), path.join('system', 'boot.active.md'))
|
|
548
|
-
createActiveLink(path.join('system', workflowFileName), path.join('system', 'workflow.active.md'))
|
|
549
|
-
createActiveLink(path.join('system', toolsFileName), path.join('system', 'tools.active.json'))
|
|
550
|
-
createActiveLink(path.join('system', modelFileName), path.join('system', 'model.active.json'))
|
|
551
|
-
createActiveLink(path.join('skills', skillFileName), path.join('skills', activeSkillFileName))
|
|
552
|
-
|
|
553
|
-
return {
|
|
554
|
-
agentName: normalizedAgentName,
|
|
555
|
-
agentRoot,
|
|
556
|
-
createdPaths: createdPaths.sort((a, b) => a.localeCompare(b)),
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
export function setActive(
|
|
561
|
-
agentName: string,
|
|
562
|
-
target: string,
|
|
563
|
-
processRoot: string = process.cwd(),
|
|
564
|
-
options: { latest?: boolean } = {}
|
|
565
|
-
): ActiveFileResult {
|
|
566
|
-
const spec = parseTargetSpec(target)
|
|
567
|
-
const agentsRoot = resolveAgentsRootFrom(processRoot)
|
|
568
|
-
const targetDir = path.join(agentsRoot, agentName, ...spec.parts)
|
|
569
|
-
const agentDir = path.join(agentsRoot, agentName)
|
|
570
|
-
|
|
571
|
-
if (!existsDir(agentDir)) {
|
|
572
|
-
throw new Error(`Agent folder not found: ${agentDir}`)
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
if (!existsDir(targetDir)) {
|
|
576
|
-
throw new Error(`Target folder not found: ${targetDir}`)
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const matchedFile = resolveTargetFile(targetDir, spec, options)
|
|
580
|
-
if (!matchedFile && !spec.version && options.latest) {
|
|
581
|
-
throw new Error(`No files found for /${spec.parts.join('/')}${spec.parts.length > 0 ? '/' : ''}${spec.base}`)
|
|
582
|
-
}
|
|
583
|
-
if (!matchedFile) {
|
|
584
|
-
if (!spec.version && !options.latest) {
|
|
585
|
-
throw new Error(
|
|
586
|
-
`Version missing for /${spec.parts.join('/')}/${spec.base}. Use --latest to select the latest version.`
|
|
587
|
-
)
|
|
588
|
-
}
|
|
589
|
-
const extensionHint = spec.ext ? `.${spec.ext}` : ' with any extension'
|
|
590
|
-
throw new Error(
|
|
591
|
-
`Versioned file not found for /${spec.parts.join('/')}/${spec.base}.${spec.version}${extensionHint}`
|
|
592
|
-
)
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const sourceFile = path.join(targetDir, matchedFile)
|
|
596
|
-
const activeFile = path.join(targetDir, `${spec.base}.active${path.extname(matchedFile)}`)
|
|
597
|
-
const mode = setActiveLink(sourceFile, activeFile)
|
|
598
|
-
|
|
599
|
-
return {
|
|
600
|
-
agentName,
|
|
601
|
-
sourceFile,
|
|
602
|
-
activeFile,
|
|
603
|
-
matchedFile,
|
|
604
|
-
mode,
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
export async function loadAgent(agentName: string, processRoot: string = process.cwd()): Promise<AgentLoadResult> {
|
|
609
|
-
const agentsRoot = resolveAgentsRoot(processRoot)
|
|
610
|
-
const agentDir = path.join(agentsRoot, agentName)
|
|
611
|
-
|
|
612
|
-
if (!existsDir(agentDir)) {
|
|
613
|
-
throw new Error(`Agent folder not found: ${agentDir}`)
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const loadFile = path.join(agentDir, 'load.ts')
|
|
617
|
-
if (!existsFile(loadFile)) {
|
|
618
|
-
throw new Error(`Load file not found for agent '${agentName}': ${loadFile}`)
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
validateActiveConfigInputs(agentDir)
|
|
622
|
-
|
|
623
|
-
const moduleUrl = pathToFileURL(loadFile).href
|
|
624
|
-
const module = await import(moduleUrl)
|
|
625
|
-
const loadFn = module.load
|
|
626
|
-
|
|
627
|
-
if (typeof loadFn !== 'function') {
|
|
628
|
-
throw new Error(`Load module for agent '${agentName}' must export a 'load' function.`)
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
return {
|
|
632
|
-
agentName,
|
|
633
|
-
loaded: await loadFn(),
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
type SpawnCodexResult = {
|
|
638
|
-
status: number | null
|
|
639
|
-
signal: NodeJS.Signals | null
|
|
640
|
-
error?: Error
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
type SpawnCodexFn = (args: string[]) => SpawnCodexResult
|
|
644
|
-
|
|
645
|
-
function getCodexCommand(): string {
|
|
646
|
-
const override = process.env.F0_CODEX_BIN?.trim() ?? process.env.EXAMPLE_CODEX_BIN?.trim()
|
|
647
|
-
if (override) {
|
|
648
|
-
return override
|
|
649
|
-
}
|
|
650
|
-
if (process.platform === 'win32') {
|
|
651
|
-
// Prefer npm/pnpm cmd shim over stale codex.exe binaries.
|
|
652
|
-
return 'codex.cmd'
|
|
653
|
-
}
|
|
654
|
-
return 'codex'
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
const defaultSpawnCodex: SpawnCodexFn = (args) => {
|
|
658
|
-
const spawnOptions = {
|
|
659
|
-
stdio: 'inherit' as const,
|
|
660
|
-
env: process.env,
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const primaryCommand = getCodexCommand()
|
|
664
|
-
const primary = spawnSync(primaryCommand, args, spawnOptions)
|
|
665
|
-
if (primary.error) {
|
|
666
|
-
const err = primary.error as NodeJS.ErrnoException
|
|
667
|
-
if (
|
|
668
|
-
process.platform === 'win32'
|
|
669
|
-
&& !process.env.F0_CODEX_BIN
|
|
670
|
-
&& !process.env.EXAMPLE_CODEX_BIN
|
|
671
|
-
&& primaryCommand === 'codex.cmd'
|
|
672
|
-
&& err.code === 'ENOENT'
|
|
673
|
-
) {
|
|
674
|
-
return spawnSync('codex', args, spawnOptions)
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
return primary
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
function toExitCode(signal: NodeJS.Signals | null): number {
|
|
682
|
-
if (!signal) {
|
|
683
|
-
return 1
|
|
684
|
-
}
|
|
685
|
-
if (signal === 'SIGINT') {
|
|
686
|
-
return 130
|
|
687
|
-
}
|
|
688
|
-
if (signal === 'SIGTERM') {
|
|
689
|
-
return 143
|
|
690
|
-
}
|
|
691
|
-
return 1
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
695
|
-
return typeof value === 'object' && value !== null
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
async function resolvePromptValue(value: unknown): Promise<unknown> {
|
|
699
|
-
if (typeof value === 'function') {
|
|
700
|
-
return await value()
|
|
701
|
-
}
|
|
702
|
-
return value
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function extractPromptString(value: unknown): string | null {
|
|
706
|
-
if (typeof value === 'string') {
|
|
707
|
-
return value
|
|
708
|
-
}
|
|
709
|
-
if (isRecord(value) && typeof value.prompt === 'string') {
|
|
710
|
-
return value.prompt
|
|
711
|
-
}
|
|
712
|
-
return null
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
export async function loadAgentPrompt(agentName: string, processRoot: string = process.cwd()): Promise<string> {
|
|
716
|
-
const agentsRoot = resolveAgentsRoot(processRoot)
|
|
717
|
-
const agentDir = path.join(agentsRoot, agentName)
|
|
718
|
-
|
|
719
|
-
if (!existsDir(agentDir)) {
|
|
720
|
-
throw new Error(`Agent folder not found: ${agentDir}`)
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const promptFile = path.join(agentDir, 'system', 'prompt.ts')
|
|
724
|
-
if (!existsFile(promptFile)) {
|
|
725
|
-
throw new Error(`Prompt file not found for agent '${agentName}': ${promptFile}`)
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const promptModuleUrl = pathToFileURL(promptFile).href
|
|
729
|
-
const promptModule = await import(promptModuleUrl)
|
|
730
|
-
const candidates = [
|
|
731
|
-
await resolvePromptValue(promptModule.prompt),
|
|
732
|
-
await resolvePromptValue(promptModule.agentConfig),
|
|
733
|
-
await resolvePromptValue(promptModule.default),
|
|
734
|
-
]
|
|
735
|
-
|
|
736
|
-
for (const candidate of candidates) {
|
|
737
|
-
const promptText = extractPromptString(candidate)
|
|
738
|
-
if (promptText !== null) {
|
|
739
|
-
return promptText
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
throw new Error(`Prompt module for agent '${agentName}' must export a prompt string.`)
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
export async function runAgent(
|
|
747
|
-
agentName: string,
|
|
748
|
-
codexArgs: string[] = [],
|
|
749
|
-
processRoot: string = process.cwd(),
|
|
750
|
-
options: { spawnCodex?: SpawnCodexFn } = {}
|
|
751
|
-
): Promise<AgentRunResult> {
|
|
752
|
-
const prompt = await loadAgentPrompt(agentName, processRoot)
|
|
753
|
-
const spawnCodex = options.spawnCodex ?? defaultSpawnCodex
|
|
754
|
-
const args = ['--config', `developer_instructions=${prompt}`, ...codexArgs]
|
|
755
|
-
const result = spawnCodex(args)
|
|
756
|
-
|
|
757
|
-
if (result.error) {
|
|
758
|
-
throw new Error(`Failed to start codex: ${result.error.message}`)
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
return {
|
|
762
|
-
agentName,
|
|
763
|
-
exitCode: typeof result.status === 'number' ? result.status : toExitCode(result.signal),
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
function validateActiveConfigInputs(agentDir: string): void {
|
|
768
|
-
const issues: string[] = []
|
|
769
|
-
|
|
770
|
-
const requiredInputs = collectActiveConfigInputs(agentDir)
|
|
771
|
-
|
|
772
|
-
for (const input of requiredInputs) {
|
|
773
|
-
if (!existsFile(input.path)) {
|
|
774
|
-
if (input.required) {
|
|
775
|
-
issues.push(`missing: ${path.relative(agentDir, input.path)}`)
|
|
776
|
-
}
|
|
777
|
-
continue
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if (!isSymbolicLink(input.path)) {
|
|
781
|
-
issues.push(`not a symlink: ${path.relative(agentDir, input.path)}`)
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (issues.length > 0) {
|
|
786
|
-
throw new Error(`Config input check failed: ${issues.join(', ')}`)
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function collectActiveConfigInputs(agentDir: string): ActiveConfigInput[] {
|
|
791
|
-
const systemDir = path.join(agentDir, 'system')
|
|
792
|
-
const skillsDir = path.join(agentDir, 'skills')
|
|
793
|
-
|
|
794
|
-
const required: ActiveConfigInput[] = [
|
|
795
|
-
{ path: path.join(systemDir, 'boot.active.md'), required: true },
|
|
796
|
-
{ path: path.join(systemDir, 'workflow.active.md'), required: true },
|
|
797
|
-
{ path: path.join(systemDir, 'tools.active.json'), required: true },
|
|
798
|
-
{ path: path.join(systemDir, 'model.active.json'), required: true },
|
|
799
|
-
]
|
|
800
|
-
|
|
801
|
-
const enabledSkillsPath = path.join(skillsDir, 'enabled-skills.md')
|
|
802
|
-
if (!existsFile(enabledSkillsPath)) {
|
|
803
|
-
return required
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const lines = fs.readFileSync(enabledSkillsPath, 'utf8')
|
|
807
|
-
.split(/\r?\n/)
|
|
808
|
-
.map((line) => line.trim())
|
|
809
|
-
.filter(Boolean)
|
|
810
|
-
|
|
811
|
-
for (const line of lines) {
|
|
812
|
-
required.push({ path: path.join(skillsDir, line), required: true })
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
return required
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function isSymbolicLink(filePath: string): boolean {
|
|
819
|
-
try {
|
|
820
|
-
return fs.lstatSync(filePath).isSymbolicLink()
|
|
821
|
-
} catch {
|
|
822
|
-
return false
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
export async function main(argv: string[], processRoot: string = process.cwd()): Promise<string> {
|
|
827
|
-
if (
|
|
828
|
-
argv.length === 0 ||
|
|
829
|
-
argv.includes('--help') ||
|
|
830
|
-
argv.includes('-h')
|
|
831
|
-
) {
|
|
832
|
-
return usage()
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
const [scope, maybeAgentOrFlag, maybeTarget, ...rest] = argv
|
|
836
|
-
const listMode = scope === 'agents' && maybeAgentOrFlag === '--list'
|
|
837
|
-
const createMode = scope === 'agents' && maybeAgentOrFlag === 'create'
|
|
838
|
-
|
|
839
|
-
if (scope !== 'agents') {
|
|
840
|
-
throw new Error('Expected command `agents` as first positional argument.')
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
if (listMode) {
|
|
844
|
-
const unknownFlags = rest.filter(Boolean)
|
|
845
|
-
if (unknownFlags.length > 0) {
|
|
846
|
-
throw new Error(`Unknown flags for list mode: ${unknownFlags.join(', ')}`)
|
|
847
|
-
}
|
|
848
|
-
return listAgents(processRoot).join('\n')
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (createMode) {
|
|
852
|
-
if (!maybeTarget) {
|
|
853
|
-
throw new Error('Missing required argument: <agent-name>.')
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
const unknownFlags = rest.filter(Boolean)
|
|
857
|
-
if (unknownFlags.length > 0) {
|
|
858
|
-
throw new Error(`Unknown flags for create mode: ${unknownFlags.join(', ')}`)
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
const result = createAgent(maybeTarget, processRoot)
|
|
862
|
-
const displayPath = path.relative(processRoot, result.agentRoot) || result.agentRoot
|
|
863
|
-
return `[${CLI_NAME}] created agent: ${result.agentName} (${displayPath})`
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
const agentName = maybeAgentOrFlag
|
|
867
|
-
const target = maybeTarget
|
|
868
|
-
const setActiveRequested = rest.includes('--set-active')
|
|
869
|
-
const useLatest = rest.includes('--latest')
|
|
870
|
-
|
|
871
|
-
if (target === 'load') {
|
|
872
|
-
if (rest.length > 0) {
|
|
873
|
-
throw new Error(`Unknown flags for load mode: ${rest.join(', ')}`)
|
|
874
|
-
}
|
|
875
|
-
const result = await loadAgent(agentName, processRoot)
|
|
876
|
-
return JSON.stringify(result.loaded, null, 2)
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
if (target === 'run') {
|
|
880
|
-
const result = await runAgent(agentName, rest, processRoot)
|
|
881
|
-
if (result.exitCode !== 0) {
|
|
882
|
-
process.exitCode = result.exitCode
|
|
883
|
-
}
|
|
884
|
-
return ''
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (!agentName || !target) {
|
|
888
|
-
throw new Error('Missing required arguments: <agent-name> and <file-path>.')
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
if (!setActiveRequested) {
|
|
892
|
-
throw new Error('`--set-active` is required for this operation.')
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const unknownFlags = rest.filter((arg) => arg !== '--set-active' && arg !== '--latest')
|
|
896
|
-
if (unknownFlags.length > 0) {
|
|
897
|
-
throw new Error(`Unknown flags: ${unknownFlags.join(', ')}`)
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
const result = setActive(agentName, target, processRoot, { latest: useLatest })
|
|
901
|
-
return `[${CLI_NAME}] set active: ${result.matchedFile} -> ${path.basename(result.activeFile)} (${result.mode})`
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function existsDir(p: string): boolean {
|
|
905
|
-
try {
|
|
906
|
-
return fs.statSync(p).isDirectory()
|
|
907
|
-
} catch {
|
|
908
|
-
return false
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
function existsFile(p: string): boolean {
|
|
913
|
-
try {
|
|
914
|
-
return fs.statSync(p).isFile()
|
|
915
|
-
} catch {
|
|
916
|
-
return false
|
|
917
|
-
}
|
|
918
|
-
}
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { spawnSync } from 'child_process'
|
|
4
|
+
import { pathToFileURL } from 'url'
|
|
5
|
+
|
|
6
|
+
export interface VersionedFileSpec {
|
|
7
|
+
parts: string[]
|
|
8
|
+
base: string
|
|
9
|
+
version: string | null
|
|
10
|
+
ext: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ActiveFileResult {
|
|
14
|
+
agentName: string
|
|
15
|
+
sourceFile: string
|
|
16
|
+
activeFile: string
|
|
17
|
+
matchedFile: string
|
|
18
|
+
mode: 'symlink'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AgentLoadResult {
|
|
22
|
+
agentName: string
|
|
23
|
+
loaded: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AgentRunResult {
|
|
27
|
+
agentName: string
|
|
28
|
+
exitCode: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AgentCreateResult {
|
|
32
|
+
agentName: string
|
|
33
|
+
agentRoot: string
|
|
34
|
+
createdPaths: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ActiveConfigInput {
|
|
38
|
+
path: string
|
|
39
|
+
required: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CLI_NAME = 'f0'
|
|
43
|
+
const AGENT_INITIAL_VERSION = 'v0.0.1'
|
|
44
|
+
const DEFAULT_SKILL_NAME = 'coding-standards'
|
|
45
|
+
const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
|
|
46
|
+
const VERSION_RE_CORE = `(?:${VERSION_RE})`
|
|
47
|
+
const VERSION_WITH_EXT_RE = new RegExp(`^(.+)\\.(${VERSION_RE})\\.([A-Za-z][A-Za-z0-9_-]*)$`)
|
|
48
|
+
const VERSION_ONLY_RE = new RegExp(`^(.+)\\.(${VERSION_RE})$`)
|
|
49
|
+
const PRIORITIZED_EXTS = ['.md', '.json', '.yaml', '.yml', '.ts', '.js', '.txt']
|
|
50
|
+
|
|
51
|
+
export function usage(): string {
|
|
52
|
+
return `Usage:\n ${CLI_NAME} agents create <agent-name>\n ${CLI_NAME} agents <agent-name> <file-path> --set-active\n ${CLI_NAME} agents --list\n ${CLI_NAME} agents <agent-name> load\n ${CLI_NAME} agents <agent-name> run [codex-args...]\n\n` +
|
|
53
|
+
`Examples:\n ${CLI_NAME} agents create reviewer\n ${CLI_NAME} agents coder /system/boot.v0.0.1 --set-active\n ${CLI_NAME} agents coder /system/boot --set-active --latest\n ${CLI_NAME} agents coder /skills/coding-standards.v0.0.1 --set-active\n ${CLI_NAME} agents --list\n` +
|
|
54
|
+
` ${CLI_NAME} agents coder load\n` +
|
|
55
|
+
` ${CLI_NAME} agents coder run --model gpt-5\n` +
|
|
56
|
+
`\n` +
|
|
57
|
+
`file-path is relative to the agent root (leading slash required).\n` +
|
|
58
|
+
`Use --latest to resolve /file-name to the latest version.\n` +
|
|
59
|
+
`The active file created is [file].active.<ext>.\n` +
|
|
60
|
+
`Use "run" to start codex with developer_instructions from system/prompt.ts.\n` +
|
|
61
|
+
`Set F0_CODEX_BIN (or EXAMPLE_CODEX_BIN) to pin a specific codex binary.\n`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveAgentsRoot(processRoot: string = process.cwd()): string {
|
|
65
|
+
const candidates = [
|
|
66
|
+
path.join(processRoot, 'agents'),
|
|
67
|
+
path.join(processRoot, 'wireborn', 'agents'),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
for (const candidatesPath of candidates) {
|
|
71
|
+
if (existsDir(candidatesPath)) {
|
|
72
|
+
return candidatesPath
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const checked = candidates.map((candidate) => `\n checked path: ${candidate}`).join('')
|
|
77
|
+
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Could not find agents directory relative to working directory.\n cwd: ${processRoot}${checked}`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const resolveAgentsRootFrom = resolveAgentsRoot
|
|
84
|
+
|
|
85
|
+
export function listAgents(processRoot: string = process.cwd()): string[] {
|
|
86
|
+
const agentsPath = resolveAgentsRoot(processRoot)
|
|
87
|
+
|
|
88
|
+
return fs
|
|
89
|
+
.readdirSync(agentsPath, { withFileTypes: true })
|
|
90
|
+
.filter((entry) => entry.isDirectory())
|
|
91
|
+
.map((entry) => entry.name)
|
|
92
|
+
.sort((a, b) => a.localeCompare(b))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeAgentName(agentName: string): string {
|
|
96
|
+
if (!agentName || typeof agentName !== 'string') {
|
|
97
|
+
throw new Error('agent-name is required.')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const normalized = agentName.trim()
|
|
101
|
+
if (!normalized) {
|
|
102
|
+
throw new Error('agent-name is required.')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (normalized.startsWith('-')) {
|
|
106
|
+
throw new Error(`Invalid agent-name: ${agentName}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (normalized === '.' || normalized === '..' || normalized.includes('/') || normalized.includes('\\')) {
|
|
110
|
+
throw new Error(`Invalid agent-name: ${agentName}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) {
|
|
114
|
+
throw new Error(`Invalid agent-name: ${agentName}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return normalized
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function titleCaseAgentName(agentName: string): string {
|
|
121
|
+
return agentName
|
|
122
|
+
.split(/[-_]+/)
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
125
|
+
.join(' ')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildBootDocument(agentName: string): string {
|
|
129
|
+
const title = titleCaseAgentName(agentName) || agentName
|
|
130
|
+
return `# ${title} Agent Boot (${AGENT_INITIAL_VERSION})
|
|
131
|
+
|
|
132
|
+
You are the \`${agentName}\` agent.
|
|
133
|
+
|
|
134
|
+
## Role
|
|
135
|
+
- Follow the active workflow and execute scoped tasks.
|
|
136
|
+
- Keep outputs concise, concrete, and implementation-focused.
|
|
137
|
+
- Avoid changing unrelated files or behavior.
|
|
138
|
+
|
|
139
|
+
## Startup Sequence
|
|
140
|
+
1. Read active boot/workflow docs and enabled skills.
|
|
141
|
+
2. Confirm scope and constraints before changes.
|
|
142
|
+
3. Apply the smallest safe change set.
|
|
143
|
+
4. Report changed files and outcomes.
|
|
144
|
+
`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildWorkflowDocument(agentName: string): string {
|
|
148
|
+
return `# ${titleCaseAgentName(agentName) || agentName} Workflow (${AGENT_INITIAL_VERSION})
|
|
149
|
+
|
|
150
|
+
1. Parse request and constraints.
|
|
151
|
+
2. Identify exact files and minimal edits.
|
|
152
|
+
3. Implement and verify changes.
|
|
153
|
+
4. If blocked, stop and report the blocker.
|
|
154
|
+
5. Return result summary with next actions if needed.
|
|
155
|
+
`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildToolsConfig(): string {
|
|
159
|
+
return `${JSON.stringify({
|
|
160
|
+
tools: [
|
|
161
|
+
{
|
|
162
|
+
name: 'shell_command',
|
|
163
|
+
enabled: true,
|
|
164
|
+
description: 'Execute shell commands for file and workspace operations.',
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'apply_patch',
|
|
168
|
+
enabled: true,
|
|
169
|
+
description: 'Apply focused file edits safely.',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'read_file',
|
|
173
|
+
enabled: true,
|
|
174
|
+
description: 'Read files when needed for context.',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
permissions: {
|
|
178
|
+
network: false,
|
|
179
|
+
write: true,
|
|
180
|
+
delete: true,
|
|
181
|
+
},
|
|
182
|
+
}, null, 2)}
|
|
183
|
+
`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildModelConfig(): string {
|
|
187
|
+
return `${JSON.stringify({
|
|
188
|
+
provider: 'openai',
|
|
189
|
+
model: 'gpt-5',
|
|
190
|
+
temperature: 0.2,
|
|
191
|
+
max_tokens: 4096,
|
|
192
|
+
top_p: 0.95,
|
|
193
|
+
}, null, 2)}
|
|
194
|
+
`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildDefaultSkillDocument(): string {
|
|
198
|
+
return `# Coding Standards (${AGENT_INITIAL_VERSION})
|
|
199
|
+
|
|
200
|
+
- Keep changes minimal and focused on the request.
|
|
201
|
+
- Preserve existing behavior unless explicitly changing it.
|
|
202
|
+
- Prefer readable, deterministic implementations.
|
|
203
|
+
`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SYSTEM_PROMPT_TS_TEMPLATE = `import * as fs from 'fs'
|
|
207
|
+
import * as path from 'path'
|
|
208
|
+
|
|
209
|
+
const base = path.join(__dirname)
|
|
210
|
+
const readActive = (p: string) => fs.readFileSync(path.join(base, p), 'utf8')
|
|
211
|
+
|
|
212
|
+
export const boot = readActive('boot.active.md')
|
|
213
|
+
export const workflow = readActive('workflow.active.md')
|
|
214
|
+
export const tools = JSON.parse(readActive('tools.active.json'))
|
|
215
|
+
export const model = JSON.parse(readActive('model.active.json'))
|
|
216
|
+
|
|
217
|
+
export const prompt = [boot, workflow].join('\\n\\n')
|
|
218
|
+
|
|
219
|
+
export const agentConfig = {
|
|
220
|
+
boot,
|
|
221
|
+
workflow,
|
|
222
|
+
tools,
|
|
223
|
+
model,
|
|
224
|
+
prompt,
|
|
225
|
+
}
|
|
226
|
+
`
|
|
227
|
+
|
|
228
|
+
const SKILLS_TS_TEMPLATE = `import * as fs from 'fs'
|
|
229
|
+
import * as path from 'path'
|
|
230
|
+
|
|
231
|
+
export interface SkillFile {
|
|
232
|
+
id: string
|
|
233
|
+
version: string
|
|
234
|
+
activeFile: string
|
|
235
|
+
resolvedFile: string
|
|
236
|
+
content: string
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const skillsDir = path.join(__dirname)
|
|
240
|
+
const enabledListPath = path.join(skillsDir, 'enabled-skills.md')
|
|
241
|
+
|
|
242
|
+
const resolveVersionAndId = (activeFile: string) => {
|
|
243
|
+
const baseName = path.basename(activeFile, '.active.md')
|
|
244
|
+
const resolved = \`\${baseName}.${AGENT_INITIAL_VERSION}.md\`
|
|
245
|
+
return {
|
|
246
|
+
id: baseName,
|
|
247
|
+
version: '${AGENT_INITIAL_VERSION}',
|
|
248
|
+
resolvedFile: resolved,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const enabledLines = fs
|
|
253
|
+
.readFileSync(enabledListPath, 'utf8')
|
|
254
|
+
.split(/\\r?\\n/)
|
|
255
|
+
.map((line) => line.trim())
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
|
|
258
|
+
export const skills: SkillFile[] = enabledLines.map((activeFile) => {
|
|
259
|
+
const resolved = resolveVersionAndId(activeFile)
|
|
260
|
+
return {
|
|
261
|
+
...resolved,
|
|
262
|
+
activeFile,
|
|
263
|
+
content: fs.readFileSync(path.join(skillsDir, resolved.resolvedFile), 'utf8'),
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
`
|
|
267
|
+
|
|
268
|
+
const LOAD_TS_TEMPLATE = `import { readFileSync } from 'fs'
|
|
269
|
+
import * as path from 'path'
|
|
270
|
+
|
|
271
|
+
import { agentConfig } from './system/prompt'
|
|
272
|
+
import { skills } from './skills/skills'
|
|
273
|
+
|
|
274
|
+
const activeDoc = path.join(__dirname, 'system')
|
|
275
|
+
|
|
276
|
+
const loadActive = (name: string) =>
|
|
277
|
+
readFileSync(path.join(activeDoc, name), 'utf8').trim()
|
|
278
|
+
|
|
279
|
+
export const load = () => ({
|
|
280
|
+
...agentConfig,
|
|
281
|
+
bootDoc: loadActive('boot.active.md'),
|
|
282
|
+
workflowDoc: loadActive('workflow.active.md'),
|
|
283
|
+
skills,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
export type AgentBundle = ReturnType<typeof load>
|
|
287
|
+
`
|
|
288
|
+
|
|
289
|
+
export function parseTargetSpec(spec: string): VersionedFileSpec {
|
|
290
|
+
if (!spec || typeof spec !== 'string') {
|
|
291
|
+
throw new Error('file-path is required.')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const normalized = spec.replace(/\\/g, '/').trim()
|
|
295
|
+
|
|
296
|
+
if (!normalized.startsWith('/')) {
|
|
297
|
+
throw new Error('file-path must start with a leading slash, e.g. /system/boot.v0.0.1')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const body = normalized.slice(1)
|
|
301
|
+
if (!body) {
|
|
302
|
+
throw new Error('file-path is empty.')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const parts = body.split('/').filter(Boolean)
|
|
306
|
+
if (parts.length < 2) {
|
|
307
|
+
throw new Error('file-path must include a file name, for example /system/boot.v0.0.1')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const file = parts.pop()
|
|
311
|
+
if (!file) {
|
|
312
|
+
throw new Error('file-path must include a file name, for example /system/boot.v0.0.1')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const withExt = file.match(VERSION_WITH_EXT_RE)
|
|
316
|
+
const withoutExt = file.match(VERSION_ONLY_RE)
|
|
317
|
+
if (withExt) {
|
|
318
|
+
return {
|
|
319
|
+
parts,
|
|
320
|
+
base: withExt[1],
|
|
321
|
+
version: withExt[2],
|
|
322
|
+
ext: withExt[3],
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (withoutExt) {
|
|
327
|
+
return {
|
|
328
|
+
parts,
|
|
329
|
+
base: withoutExt[1],
|
|
330
|
+
version: withoutExt[2],
|
|
331
|
+
ext: null,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (parts.length === 0) {
|
|
336
|
+
throw new Error('file-path must include a file name, for example /system/boot or /system/boot.v0.0.1.')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
parts,
|
|
341
|
+
base: file,
|
|
342
|
+
version: null,
|
|
343
|
+
ext: null,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseVersion(version: string): number[] {
|
|
348
|
+
const clean = version.replace(/^v/, '')
|
|
349
|
+
return clean.split('.').filter(Boolean).map((part) => Number.parseInt(part, 10))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function escapeRegExp(value: string): string {
|
|
353
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function compareVersions(left: string, right: string): number {
|
|
357
|
+
const a = parseVersion(left)
|
|
358
|
+
const b = parseVersion(right)
|
|
359
|
+
const size = Math.max(a.length, b.length)
|
|
360
|
+
|
|
361
|
+
for (let i = 0; i < size; i += 1) {
|
|
362
|
+
const leftValue = a[i] ?? 0
|
|
363
|
+
const rightValue = b[i] ?? 0
|
|
364
|
+
if (leftValue > rightValue) return 1
|
|
365
|
+
if (leftValue < rightValue) return -1
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return 0
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function findLatestVersionedFile(targetDir: string, base: string, ext: string | null): string | null {
|
|
372
|
+
const fileEntries = fs
|
|
373
|
+
.readdirSync(targetDir, { withFileTypes: true })
|
|
374
|
+
.filter((entry) => entry.isFile())
|
|
375
|
+
.map((entry) => entry.name)
|
|
376
|
+
|
|
377
|
+
const safeBase = escapeRegExp(base)
|
|
378
|
+
const regex = new RegExp(`^${safeBase}\\.(${VERSION_RE_CORE})\\.([^.]+)$`)
|
|
379
|
+
const matches = fileEntries
|
|
380
|
+
.map((entry) => {
|
|
381
|
+
const match = entry.match(regex)
|
|
382
|
+
if (!match) return null
|
|
383
|
+
|
|
384
|
+
const version = match[1]
|
|
385
|
+
const detectedExt = `.${match[2]}`
|
|
386
|
+
if (ext && detectedExt !== `.${ext}`) {
|
|
387
|
+
return null
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { entry, version, detectedExt }
|
|
391
|
+
})
|
|
392
|
+
.filter((value): value is { entry: string; version: string; detectedExt: string } => value !== null)
|
|
393
|
+
|
|
394
|
+
if (matches.length === 0) return null
|
|
395
|
+
|
|
396
|
+
matches.sort((a, b) => compareVersions(a.version, b.version))
|
|
397
|
+
const selected = matches[matches.length - 1]
|
|
398
|
+
|
|
399
|
+
const version = selected.version
|
|
400
|
+
const sameVersion = matches
|
|
401
|
+
.filter((item) => compareVersions(item.version, version) === 0)
|
|
402
|
+
.map((item) => item.entry)
|
|
403
|
+
.sort()
|
|
404
|
+
|
|
405
|
+
if (sameVersion.length <= 1) {
|
|
406
|
+
return sameVersion[0]
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const extName of PRIORITIZED_EXTS) {
|
|
410
|
+
const preferred = sameVersion.find((entryName) => entryName.endsWith(extName))
|
|
411
|
+
if (preferred) {
|
|
412
|
+
return preferred
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return sameVersion.sort()[0]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function resolveTargetFile(targetDir: string, spec: VersionedFileSpec, options: { latest?: boolean } = {}): string | null {
|
|
420
|
+
const entries = fs
|
|
421
|
+
.readdirSync(targetDir, { withFileTypes: true })
|
|
422
|
+
.filter((entry) => entry.isFile())
|
|
423
|
+
.map((entry) => entry.name)
|
|
424
|
+
|
|
425
|
+
if (!spec.version) {
|
|
426
|
+
if (!options.latest) {
|
|
427
|
+
return null
|
|
428
|
+
}
|
|
429
|
+
return findLatestVersionedFile(targetDir, spec.base, null)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (spec.ext) {
|
|
433
|
+
const exact = `${spec.base}.${spec.version}.${spec.ext}`
|
|
434
|
+
if (entries.includes(exact)) {
|
|
435
|
+
return exact
|
|
436
|
+
}
|
|
437
|
+
return null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const prefix = `${spec.base}.${spec.version}.`
|
|
441
|
+
const candidates = entries.filter((entry) => entry.startsWith(prefix))
|
|
442
|
+
if (candidates.length === 0) {
|
|
443
|
+
return null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const ext of PRIORITIZED_EXTS) {
|
|
447
|
+
const match = candidates.find((entry) => entry.endsWith(ext))
|
|
448
|
+
if (match) {
|
|
449
|
+
return match
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return candidates.sort()[0]
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function setActiveLink(sourceFile: string, activeFile: string): 'symlink' {
|
|
457
|
+
if (!existsFile(sourceFile)) {
|
|
458
|
+
throw new Error(`Versioned file not found: ${sourceFile}`)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
fs.mkdirSync(path.dirname(activeFile), { recursive: true })
|
|
462
|
+
|
|
463
|
+
// Remove the active pointer first. Using rmSync directly (instead of existsSync)
|
|
464
|
+
// ensures we also clear broken symlinks (existsSync returns false for them).
|
|
465
|
+
fs.rmSync(activeFile, { force: true })
|
|
466
|
+
|
|
467
|
+
const linkTarget = path.relative(path.dirname(activeFile), sourceFile)
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
fs.symlinkSync(linkTarget, activeFile, 'file')
|
|
471
|
+
return 'symlink'
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (process.platform === 'win32') {
|
|
474
|
+
const ok = createWindowsSymlink(activeFile, sourceFile)
|
|
475
|
+
if (ok) {
|
|
476
|
+
return 'symlink'
|
|
477
|
+
}
|
|
478
|
+
throw new Error(
|
|
479
|
+
`Failed to create symlink on Windows. Run shell as Administrator or enable Developer Mode.\n${String(error?.message ?? error)}`
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
throw error
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function createWindowsSymlink(activeFile: string, sourceFile: string): boolean {
|
|
488
|
+
const quote = (value: string) => `"${value.replace(/\"/g, '""')}"`
|
|
489
|
+
const command = `mklink ${quote(path.resolve(activeFile))} ${quote(path.resolve(sourceFile))}`
|
|
490
|
+
const result = spawnSync('cmd', ['/c', command], {
|
|
491
|
+
shell: false,
|
|
492
|
+
encoding: 'utf8',
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
return result.status === 0
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function createAgent(agentName: string, processRoot: string = process.cwd()): AgentCreateResult {
|
|
499
|
+
const normalizedAgentName = normalizeAgentName(agentName)
|
|
500
|
+
const agentsRoot = resolveAgentsRootFrom(processRoot)
|
|
501
|
+
const agentRoot = path.join(agentsRoot, normalizedAgentName)
|
|
502
|
+
|
|
503
|
+
if (fs.existsSync(agentRoot)) {
|
|
504
|
+
throw new Error(`Agent folder already exists: ${agentRoot}`)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const systemDir = path.join(agentRoot, 'system')
|
|
508
|
+
const skillsDir = path.join(agentRoot, 'skills')
|
|
509
|
+
|
|
510
|
+
fs.mkdirSync(systemDir, { recursive: true })
|
|
511
|
+
fs.mkdirSync(skillsDir, { recursive: true })
|
|
512
|
+
|
|
513
|
+
const createdPaths: string[] = []
|
|
514
|
+
const writeFile = (relativePath: string, content: string) => {
|
|
515
|
+
const absolutePath = path.join(agentRoot, relativePath)
|
|
516
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
|
|
517
|
+
fs.writeFileSync(absolutePath, content, 'utf8')
|
|
518
|
+
createdPaths.push(absolutePath)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const bootFileName = `boot.${AGENT_INITIAL_VERSION}.md`
|
|
522
|
+
const workflowFileName = `workflow.${AGENT_INITIAL_VERSION}.md`
|
|
523
|
+
const toolsFileName = `tools.${AGENT_INITIAL_VERSION}.json`
|
|
524
|
+
const modelFileName = `model.${AGENT_INITIAL_VERSION}.json`
|
|
525
|
+
const skillFileName = `${DEFAULT_SKILL_NAME}.${AGENT_INITIAL_VERSION}.md`
|
|
526
|
+
const activeSkillFileName = `${DEFAULT_SKILL_NAME}.active.md`
|
|
527
|
+
|
|
528
|
+
writeFile(path.join('system', bootFileName), buildBootDocument(normalizedAgentName))
|
|
529
|
+
writeFile(path.join('system', workflowFileName), buildWorkflowDocument(normalizedAgentName))
|
|
530
|
+
writeFile(path.join('system', toolsFileName), buildToolsConfig())
|
|
531
|
+
writeFile(path.join('system', modelFileName), buildModelConfig())
|
|
532
|
+
writeFile(path.join('system', 'prompt.ts'), SYSTEM_PROMPT_TS_TEMPLATE)
|
|
533
|
+
|
|
534
|
+
writeFile(path.join('skills', skillFileName), buildDefaultSkillDocument())
|
|
535
|
+
writeFile(path.join('skills', 'enabled-skills.md'), `${activeSkillFileName}\n`)
|
|
536
|
+
writeFile(path.join('skills', 'skills.ts'), SKILLS_TS_TEMPLATE)
|
|
537
|
+
|
|
538
|
+
writeFile('load.ts', LOAD_TS_TEMPLATE)
|
|
539
|
+
|
|
540
|
+
const createActiveLink = (versionedFile: string, activeFile: string) => {
|
|
541
|
+
const source = path.join(agentRoot, versionedFile)
|
|
542
|
+
const target = path.join(agentRoot, activeFile)
|
|
543
|
+
setActiveLink(source, target)
|
|
544
|
+
createdPaths.push(target)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
createActiveLink(path.join('system', bootFileName), path.join('system', 'boot.active.md'))
|
|
548
|
+
createActiveLink(path.join('system', workflowFileName), path.join('system', 'workflow.active.md'))
|
|
549
|
+
createActiveLink(path.join('system', toolsFileName), path.join('system', 'tools.active.json'))
|
|
550
|
+
createActiveLink(path.join('system', modelFileName), path.join('system', 'model.active.json'))
|
|
551
|
+
createActiveLink(path.join('skills', skillFileName), path.join('skills', activeSkillFileName))
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
agentName: normalizedAgentName,
|
|
555
|
+
agentRoot,
|
|
556
|
+
createdPaths: createdPaths.sort((a, b) => a.localeCompare(b)),
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function setActive(
|
|
561
|
+
agentName: string,
|
|
562
|
+
target: string,
|
|
563
|
+
processRoot: string = process.cwd(),
|
|
564
|
+
options: { latest?: boolean } = {}
|
|
565
|
+
): ActiveFileResult {
|
|
566
|
+
const spec = parseTargetSpec(target)
|
|
567
|
+
const agentsRoot = resolveAgentsRootFrom(processRoot)
|
|
568
|
+
const targetDir = path.join(agentsRoot, agentName, ...spec.parts)
|
|
569
|
+
const agentDir = path.join(agentsRoot, agentName)
|
|
570
|
+
|
|
571
|
+
if (!existsDir(agentDir)) {
|
|
572
|
+
throw new Error(`Agent folder not found: ${agentDir}`)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!existsDir(targetDir)) {
|
|
576
|
+
throw new Error(`Target folder not found: ${targetDir}`)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const matchedFile = resolveTargetFile(targetDir, spec, options)
|
|
580
|
+
if (!matchedFile && !spec.version && options.latest) {
|
|
581
|
+
throw new Error(`No files found for /${spec.parts.join('/')}${spec.parts.length > 0 ? '/' : ''}${spec.base}`)
|
|
582
|
+
}
|
|
583
|
+
if (!matchedFile) {
|
|
584
|
+
if (!spec.version && !options.latest) {
|
|
585
|
+
throw new Error(
|
|
586
|
+
`Version missing for /${spec.parts.join('/')}/${spec.base}. Use --latest to select the latest version.`
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
const extensionHint = spec.ext ? `.${spec.ext}` : ' with any extension'
|
|
590
|
+
throw new Error(
|
|
591
|
+
`Versioned file not found for /${spec.parts.join('/')}/${spec.base}.${spec.version}${extensionHint}`
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const sourceFile = path.join(targetDir, matchedFile)
|
|
596
|
+
const activeFile = path.join(targetDir, `${spec.base}.active${path.extname(matchedFile)}`)
|
|
597
|
+
const mode = setActiveLink(sourceFile, activeFile)
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
agentName,
|
|
601
|
+
sourceFile,
|
|
602
|
+
activeFile,
|
|
603
|
+
matchedFile,
|
|
604
|
+
mode,
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export async function loadAgent(agentName: string, processRoot: string = process.cwd()): Promise<AgentLoadResult> {
|
|
609
|
+
const agentsRoot = resolveAgentsRoot(processRoot)
|
|
610
|
+
const agentDir = path.join(agentsRoot, agentName)
|
|
611
|
+
|
|
612
|
+
if (!existsDir(agentDir)) {
|
|
613
|
+
throw new Error(`Agent folder not found: ${agentDir}`)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const loadFile = path.join(agentDir, 'load.ts')
|
|
617
|
+
if (!existsFile(loadFile)) {
|
|
618
|
+
throw new Error(`Load file not found for agent '${agentName}': ${loadFile}`)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
validateActiveConfigInputs(agentDir)
|
|
622
|
+
|
|
623
|
+
const moduleUrl = pathToFileURL(loadFile).href
|
|
624
|
+
const module = await import(moduleUrl)
|
|
625
|
+
const loadFn = module.load
|
|
626
|
+
|
|
627
|
+
if (typeof loadFn !== 'function') {
|
|
628
|
+
throw new Error(`Load module for agent '${agentName}' must export a 'load' function.`)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
agentName,
|
|
633
|
+
loaded: await loadFn(),
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
type SpawnCodexResult = {
|
|
638
|
+
status: number | null
|
|
639
|
+
signal: NodeJS.Signals | null
|
|
640
|
+
error?: Error
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
type SpawnCodexFn = (args: string[]) => SpawnCodexResult
|
|
644
|
+
|
|
645
|
+
function getCodexCommand(): string {
|
|
646
|
+
const override = process.env.F0_CODEX_BIN?.trim() ?? process.env.EXAMPLE_CODEX_BIN?.trim()
|
|
647
|
+
if (override) {
|
|
648
|
+
return override
|
|
649
|
+
}
|
|
650
|
+
if (process.platform === 'win32') {
|
|
651
|
+
// Prefer npm/pnpm cmd shim over stale codex.exe binaries.
|
|
652
|
+
return 'codex.cmd'
|
|
653
|
+
}
|
|
654
|
+
return 'codex'
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const defaultSpawnCodex: SpawnCodexFn = (args) => {
|
|
658
|
+
const spawnOptions = {
|
|
659
|
+
stdio: 'inherit' as const,
|
|
660
|
+
env: process.env,
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const primaryCommand = getCodexCommand()
|
|
664
|
+
const primary = spawnSync(primaryCommand, args, spawnOptions)
|
|
665
|
+
if (primary.error) {
|
|
666
|
+
const err = primary.error as NodeJS.ErrnoException
|
|
667
|
+
if (
|
|
668
|
+
process.platform === 'win32'
|
|
669
|
+
&& !process.env.F0_CODEX_BIN
|
|
670
|
+
&& !process.env.EXAMPLE_CODEX_BIN
|
|
671
|
+
&& primaryCommand === 'codex.cmd'
|
|
672
|
+
&& err.code === 'ENOENT'
|
|
673
|
+
) {
|
|
674
|
+
return spawnSync('codex', args, spawnOptions)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return primary
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function toExitCode(signal: NodeJS.Signals | null): number {
|
|
682
|
+
if (!signal) {
|
|
683
|
+
return 1
|
|
684
|
+
}
|
|
685
|
+
if (signal === 'SIGINT') {
|
|
686
|
+
return 130
|
|
687
|
+
}
|
|
688
|
+
if (signal === 'SIGTERM') {
|
|
689
|
+
return 143
|
|
690
|
+
}
|
|
691
|
+
return 1
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
695
|
+
return typeof value === 'object' && value !== null
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function resolvePromptValue(value: unknown): Promise<unknown> {
|
|
699
|
+
if (typeof value === 'function') {
|
|
700
|
+
return await value()
|
|
701
|
+
}
|
|
702
|
+
return value
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function extractPromptString(value: unknown): string | null {
|
|
706
|
+
if (typeof value === 'string') {
|
|
707
|
+
return value
|
|
708
|
+
}
|
|
709
|
+
if (isRecord(value) && typeof value.prompt === 'string') {
|
|
710
|
+
return value.prompt
|
|
711
|
+
}
|
|
712
|
+
return null
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export async function loadAgentPrompt(agentName: string, processRoot: string = process.cwd()): Promise<string> {
|
|
716
|
+
const agentsRoot = resolveAgentsRoot(processRoot)
|
|
717
|
+
const agentDir = path.join(agentsRoot, agentName)
|
|
718
|
+
|
|
719
|
+
if (!existsDir(agentDir)) {
|
|
720
|
+
throw new Error(`Agent folder not found: ${agentDir}`)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const promptFile = path.join(agentDir, 'system', 'prompt.ts')
|
|
724
|
+
if (!existsFile(promptFile)) {
|
|
725
|
+
throw new Error(`Prompt file not found for agent '${agentName}': ${promptFile}`)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const promptModuleUrl = pathToFileURL(promptFile).href
|
|
729
|
+
const promptModule = await import(promptModuleUrl)
|
|
730
|
+
const candidates = [
|
|
731
|
+
await resolvePromptValue(promptModule.prompt),
|
|
732
|
+
await resolvePromptValue(promptModule.agentConfig),
|
|
733
|
+
await resolvePromptValue(promptModule.default),
|
|
734
|
+
]
|
|
735
|
+
|
|
736
|
+
for (const candidate of candidates) {
|
|
737
|
+
const promptText = extractPromptString(candidate)
|
|
738
|
+
if (promptText !== null) {
|
|
739
|
+
return promptText
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
throw new Error(`Prompt module for agent '${agentName}' must export a prompt string.`)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export async function runAgent(
|
|
747
|
+
agentName: string,
|
|
748
|
+
codexArgs: string[] = [],
|
|
749
|
+
processRoot: string = process.cwd(),
|
|
750
|
+
options: { spawnCodex?: SpawnCodexFn } = {}
|
|
751
|
+
): Promise<AgentRunResult> {
|
|
752
|
+
const prompt = await loadAgentPrompt(agentName, processRoot)
|
|
753
|
+
const spawnCodex = options.spawnCodex ?? defaultSpawnCodex
|
|
754
|
+
const args = ['--config', `developer_instructions=${prompt}`, ...codexArgs]
|
|
755
|
+
const result = spawnCodex(args)
|
|
756
|
+
|
|
757
|
+
if (result.error) {
|
|
758
|
+
throw new Error(`Failed to start codex: ${result.error.message}`)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
agentName,
|
|
763
|
+
exitCode: typeof result.status === 'number' ? result.status : toExitCode(result.signal),
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function validateActiveConfigInputs(agentDir: string): void {
|
|
768
|
+
const issues: string[] = []
|
|
769
|
+
|
|
770
|
+
const requiredInputs = collectActiveConfigInputs(agentDir)
|
|
771
|
+
|
|
772
|
+
for (const input of requiredInputs) {
|
|
773
|
+
if (!existsFile(input.path)) {
|
|
774
|
+
if (input.required) {
|
|
775
|
+
issues.push(`missing: ${path.relative(agentDir, input.path)}`)
|
|
776
|
+
}
|
|
777
|
+
continue
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!isSymbolicLink(input.path)) {
|
|
781
|
+
issues.push(`not a symlink: ${path.relative(agentDir, input.path)}`)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (issues.length > 0) {
|
|
786
|
+
throw new Error(`Config input check failed: ${issues.join(', ')}`)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function collectActiveConfigInputs(agentDir: string): ActiveConfigInput[] {
|
|
791
|
+
const systemDir = path.join(agentDir, 'system')
|
|
792
|
+
const skillsDir = path.join(agentDir, 'skills')
|
|
793
|
+
|
|
794
|
+
const required: ActiveConfigInput[] = [
|
|
795
|
+
{ path: path.join(systemDir, 'boot.active.md'), required: true },
|
|
796
|
+
{ path: path.join(systemDir, 'workflow.active.md'), required: true },
|
|
797
|
+
{ path: path.join(systemDir, 'tools.active.json'), required: true },
|
|
798
|
+
{ path: path.join(systemDir, 'model.active.json'), required: true },
|
|
799
|
+
]
|
|
800
|
+
|
|
801
|
+
const enabledSkillsPath = path.join(skillsDir, 'enabled-skills.md')
|
|
802
|
+
if (!existsFile(enabledSkillsPath)) {
|
|
803
|
+
return required
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const lines = fs.readFileSync(enabledSkillsPath, 'utf8')
|
|
807
|
+
.split(/\r?\n/)
|
|
808
|
+
.map((line) => line.trim())
|
|
809
|
+
.filter(Boolean)
|
|
810
|
+
|
|
811
|
+
for (const line of lines) {
|
|
812
|
+
required.push({ path: path.join(skillsDir, line), required: true })
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return required
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function isSymbolicLink(filePath: string): boolean {
|
|
819
|
+
try {
|
|
820
|
+
return fs.lstatSync(filePath).isSymbolicLink()
|
|
821
|
+
} catch {
|
|
822
|
+
return false
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export async function main(argv: string[], processRoot: string = process.cwd()): Promise<string> {
|
|
827
|
+
if (
|
|
828
|
+
argv.length === 0 ||
|
|
829
|
+
argv.includes('--help') ||
|
|
830
|
+
argv.includes('-h')
|
|
831
|
+
) {
|
|
832
|
+
return usage()
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const [scope, maybeAgentOrFlag, maybeTarget, ...rest] = argv
|
|
836
|
+
const listMode = scope === 'agents' && maybeAgentOrFlag === '--list'
|
|
837
|
+
const createMode = scope === 'agents' && maybeAgentOrFlag === 'create'
|
|
838
|
+
|
|
839
|
+
if (scope !== 'agents') {
|
|
840
|
+
throw new Error('Expected command `agents` as first positional argument.')
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (listMode) {
|
|
844
|
+
const unknownFlags = rest.filter(Boolean)
|
|
845
|
+
if (unknownFlags.length > 0) {
|
|
846
|
+
throw new Error(`Unknown flags for list mode: ${unknownFlags.join(', ')}`)
|
|
847
|
+
}
|
|
848
|
+
return listAgents(processRoot).join('\n')
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (createMode) {
|
|
852
|
+
if (!maybeTarget) {
|
|
853
|
+
throw new Error('Missing required argument: <agent-name>.')
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const unknownFlags = rest.filter(Boolean)
|
|
857
|
+
if (unknownFlags.length > 0) {
|
|
858
|
+
throw new Error(`Unknown flags for create mode: ${unknownFlags.join(', ')}`)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const result = createAgent(maybeTarget, processRoot)
|
|
862
|
+
const displayPath = path.relative(processRoot, result.agentRoot) || result.agentRoot
|
|
863
|
+
return `[${CLI_NAME}] created agent: ${result.agentName} (${displayPath})`
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const agentName = maybeAgentOrFlag
|
|
867
|
+
const target = maybeTarget
|
|
868
|
+
const setActiveRequested = rest.includes('--set-active')
|
|
869
|
+
const useLatest = rest.includes('--latest')
|
|
870
|
+
|
|
871
|
+
if (target === 'load') {
|
|
872
|
+
if (rest.length > 0) {
|
|
873
|
+
throw new Error(`Unknown flags for load mode: ${rest.join(', ')}`)
|
|
874
|
+
}
|
|
875
|
+
const result = await loadAgent(agentName, processRoot)
|
|
876
|
+
return JSON.stringify(result.loaded, null, 2)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (target === 'run') {
|
|
880
|
+
const result = await runAgent(agentName, rest, processRoot)
|
|
881
|
+
if (result.exitCode !== 0) {
|
|
882
|
+
process.exitCode = result.exitCode
|
|
883
|
+
}
|
|
884
|
+
return ''
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (!agentName || !target) {
|
|
888
|
+
throw new Error('Missing required arguments: <agent-name> and <file-path>.')
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (!setActiveRequested) {
|
|
892
|
+
throw new Error('`--set-active` is required for this operation.')
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const unknownFlags = rest.filter((arg) => arg !== '--set-active' && arg !== '--latest')
|
|
896
|
+
if (unknownFlags.length > 0) {
|
|
897
|
+
throw new Error(`Unknown flags: ${unknownFlags.join(', ')}`)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const result = setActive(agentName, target, processRoot, { latest: useLatest })
|
|
901
|
+
return `[${CLI_NAME}] set active: ${result.matchedFile} -> ${path.basename(result.activeFile)} (${result.mode})`
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function existsDir(p: string): boolean {
|
|
905
|
+
try {
|
|
906
|
+
return fs.statSync(p).isDirectory()
|
|
907
|
+
} catch {
|
|
908
|
+
return false
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function existsFile(p: string): boolean {
|
|
913
|
+
try {
|
|
914
|
+
return fs.statSync(p).isFile()
|
|
915
|
+
} catch {
|
|
916
|
+
return false
|
|
917
|
+
}
|
|
918
|
+
}
|