@foundation0/api 1.1.12 → 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/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
+ }