@foundation0/api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents.ts ADDED
@@ -0,0 +1,492 @@
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
+ interface ActiveConfigInput {
27
+ path: string
28
+ required: boolean
29
+ }
30
+
31
+ const CLI_NAME = 'f0'
32
+ const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
33
+ const VERSION_RE_CORE = `(?:${VERSION_RE})`
34
+ const VERSION_WITH_EXT_RE = new RegExp(`^(.+)\\.(${VERSION_RE})\\.([A-Za-z][A-Za-z0-9_-]*)$`)
35
+ const VERSION_ONLY_RE = new RegExp(`^(.+)\\.(${VERSION_RE})$`)
36
+ const PRIORITIZED_EXTS = ['.md', '.json', '.yaml', '.yml', '.ts', '.js', '.txt']
37
+
38
+ export function usage(): string {
39
+ return `Usage:\n ${CLI_NAME} agents <agent-name> <file-path> --set-active\n ${CLI_NAME} agents --list\n ${CLI_NAME} agents <agent-name> load\n\n` +
40
+ `Examples:\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` +
41
+ ` ${CLI_NAME} agents coder load\n` +
42
+ `\n` +
43
+ `file-path is relative to the agent root (leading slash required).\n` +
44
+ `Use --latest to resolve /file-name to the latest version.\n` +
45
+ `The active file created is [file].active.<ext>.\n`
46
+ }
47
+
48
+ export function resolveAgentsRoot(processRoot: string = process.cwd()): string {
49
+ const candidates = [
50
+ path.join(processRoot, 'agents'),
51
+ path.join(processRoot, 'wireborn', 'agents'),
52
+ ]
53
+
54
+ for (const candidatesPath of candidates) {
55
+ if (existsDir(candidatesPath)) {
56
+ return candidatesPath
57
+ }
58
+ }
59
+
60
+ const checked = candidates.map((candidate) => `\n checked path: ${candidate}`).join('')
61
+
62
+ throw new Error(
63
+ `Could not find agents directory relative to working directory.\n cwd: ${processRoot}${checked}`
64
+ )
65
+ }
66
+
67
+ export const resolveAgentsRootFrom = resolveAgentsRoot
68
+
69
+ export function listAgents(processRoot: string = process.cwd()): string[] {
70
+ const agentsPath = resolveAgentsRoot(processRoot)
71
+
72
+ return fs
73
+ .readdirSync(agentsPath, { withFileTypes: true })
74
+ .filter((entry) => entry.isDirectory())
75
+ .map((entry) => entry.name)
76
+ .sort((a, b) => a.localeCompare(b))
77
+ }
78
+
79
+ export function parseTargetSpec(spec: string): VersionedFileSpec {
80
+ if (!spec || typeof spec !== 'string') {
81
+ throw new Error('file-path is required.')
82
+ }
83
+
84
+ const normalized = spec.replace(/\\/g, '/').trim()
85
+
86
+ if (!normalized.startsWith('/')) {
87
+ throw new Error('file-path must start with a leading slash, e.g. /system/boot.v0.0.1')
88
+ }
89
+
90
+ const body = normalized.slice(1)
91
+ if (!body) {
92
+ throw new Error('file-path is empty.')
93
+ }
94
+
95
+ const parts = body.split('/').filter(Boolean)
96
+ if (parts.length < 2) {
97
+ throw new Error('file-path must include a file name, for example /system/boot.v0.0.1')
98
+ }
99
+
100
+ const file = parts.pop()
101
+ if (!file) {
102
+ throw new Error('file-path must include a file name, for example /system/boot.v0.0.1')
103
+ }
104
+
105
+ const withExt = file.match(VERSION_WITH_EXT_RE)
106
+ const withoutExt = file.match(VERSION_ONLY_RE)
107
+ if (withExt) {
108
+ return {
109
+ parts,
110
+ base: withExt[1],
111
+ version: withExt[2],
112
+ ext: withExt[3],
113
+ }
114
+ }
115
+
116
+ if (withoutExt) {
117
+ return {
118
+ parts,
119
+ base: withoutExt[1],
120
+ version: withoutExt[2],
121
+ ext: null,
122
+ }
123
+ }
124
+
125
+ if (parts.length === 0) {
126
+ throw new Error('file-path must include a file name, for example /system/boot or /system/boot.v0.0.1.')
127
+ }
128
+
129
+ return {
130
+ parts,
131
+ base: file,
132
+ version: null,
133
+ ext: null,
134
+ }
135
+ }
136
+
137
+ function parseVersion(version: string): number[] {
138
+ const clean = version.replace(/^v/, '')
139
+ return clean.split('.').filter(Boolean).map((part) => Number.parseInt(part, 10))
140
+ }
141
+
142
+ function escapeRegExp(value: string): string {
143
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
144
+ }
145
+
146
+ function compareVersions(left: string, right: string): number {
147
+ const a = parseVersion(left)
148
+ const b = parseVersion(right)
149
+ const size = Math.max(a.length, b.length)
150
+
151
+ for (let i = 0; i < size; i += 1) {
152
+ const leftValue = a[i] ?? 0
153
+ const rightValue = b[i] ?? 0
154
+ if (leftValue > rightValue) return 1
155
+ if (leftValue < rightValue) return -1
156
+ }
157
+
158
+ return 0
159
+ }
160
+
161
+ function findLatestVersionedFile(targetDir: string, base: string, ext: string | null): string | null {
162
+ const fileEntries = fs
163
+ .readdirSync(targetDir, { withFileTypes: true })
164
+ .filter((entry) => entry.isFile())
165
+ .map((entry) => entry.name)
166
+
167
+ const safeBase = escapeRegExp(base)
168
+ const regex = new RegExp(`^${safeBase}\\.(${VERSION_RE_CORE})\\.([^.]+)$`)
169
+ const matches = fileEntries
170
+ .map((entry) => {
171
+ const match = entry.match(regex)
172
+ if (!match) return null
173
+
174
+ const version = match[1]
175
+ const detectedExt = `.${match[2]}`
176
+ if (ext && detectedExt !== `.${ext}`) {
177
+ return null
178
+ }
179
+
180
+ return { entry, version, detectedExt }
181
+ })
182
+ .filter((value): value is { entry: string; version: string; detectedExt: string } => value !== null)
183
+
184
+ if (matches.length === 0) return null
185
+
186
+ matches.sort((a, b) => compareVersions(a.version, b.version))
187
+ const selected = matches[matches.length - 1]
188
+
189
+ const version = selected.version
190
+ const sameVersion = matches
191
+ .filter((item) => compareVersions(item.version, version) === 0)
192
+ .map((item) => item.entry)
193
+ .sort()
194
+
195
+ if (sameVersion.length <= 1) {
196
+ return sameVersion[0]
197
+ }
198
+
199
+ for (const extName of PRIORITIZED_EXTS) {
200
+ const preferred = sameVersion.find((entryName) => entryName.endsWith(extName))
201
+ if (preferred) {
202
+ return preferred
203
+ }
204
+ }
205
+
206
+ return sameVersion.sort()[0]
207
+ }
208
+
209
+ export function resolveTargetFile(targetDir: string, spec: VersionedFileSpec, options: { latest?: boolean } = {}): string | null {
210
+ const entries = fs
211
+ .readdirSync(targetDir, { withFileTypes: true })
212
+ .filter((entry) => entry.isFile())
213
+ .map((entry) => entry.name)
214
+
215
+ if (!spec.version) {
216
+ if (!options.latest) {
217
+ return null
218
+ }
219
+ return findLatestVersionedFile(targetDir, spec.base, null)
220
+ }
221
+
222
+ if (spec.ext) {
223
+ const exact = `${spec.base}.${spec.version}.${spec.ext}`
224
+ if (entries.includes(exact)) {
225
+ return exact
226
+ }
227
+ return null
228
+ }
229
+
230
+ const prefix = `${spec.base}.${spec.version}.`
231
+ const candidates = entries.filter((entry) => entry.startsWith(prefix))
232
+ if (candidates.length === 0) {
233
+ return null
234
+ }
235
+
236
+ for (const ext of PRIORITIZED_EXTS) {
237
+ const match = candidates.find((entry) => entry.endsWith(ext))
238
+ if (match) {
239
+ return match
240
+ }
241
+ }
242
+
243
+ return candidates.sort()[0]
244
+ }
245
+
246
+ export function setActiveLink(sourceFile: string, activeFile: string): 'symlink' {
247
+ if (!existsFile(sourceFile)) {
248
+ throw new Error(`Versioned file not found: ${sourceFile}`)
249
+ }
250
+
251
+ fs.mkdirSync(path.dirname(activeFile), { recursive: true })
252
+
253
+ if (fs.existsSync(activeFile)) {
254
+ fs.rmSync(activeFile, { force: true })
255
+ }
256
+
257
+ const linkTarget = path.relative(path.dirname(activeFile), sourceFile)
258
+
259
+ try {
260
+ fs.symlinkSync(linkTarget, activeFile, 'file')
261
+ return 'symlink'
262
+ } catch (error) {
263
+ if (process.platform === 'win32') {
264
+ const ok = createWindowsSymlink(activeFile, sourceFile)
265
+ if (ok) {
266
+ return 'symlink'
267
+ }
268
+ throw new Error(
269
+ `Failed to create symlink on Windows. Run shell as Administrator or enable Developer Mode.\n${String(error?.message ?? error)}`
270
+ )
271
+ }
272
+
273
+ throw error
274
+ }
275
+ }
276
+
277
+ function createWindowsSymlink(activeFile: string, sourceFile: string): boolean {
278
+ const quote = (value: string) => `"${value.replace(/\"/g, '""')}"`
279
+ const command = `mklink ${quote(path.resolve(activeFile))} ${quote(path.resolve(sourceFile))}`
280
+ const result = spawnSync('cmd', ['/c', command], {
281
+ shell: false,
282
+ encoding: 'utf8',
283
+ })
284
+
285
+ return result.status === 0
286
+ }
287
+
288
+ export function setActive(
289
+ agentName: string,
290
+ target: string,
291
+ processRoot: string = process.cwd(),
292
+ options: { latest?: boolean } = {}
293
+ ): ActiveFileResult {
294
+ const spec = parseTargetSpec(target)
295
+ const agentsRoot = resolveAgentsRootFrom(processRoot)
296
+ const targetDir = path.join(agentsRoot, agentName, ...spec.parts)
297
+ const agentDir = path.join(agentsRoot, agentName)
298
+
299
+ if (!existsDir(agentDir)) {
300
+ throw new Error(`Agent folder not found: ${agentDir}`)
301
+ }
302
+
303
+ if (!existsDir(targetDir)) {
304
+ throw new Error(`Target folder not found: ${targetDir}`)
305
+ }
306
+
307
+ const matchedFile = resolveTargetFile(targetDir, spec, options)
308
+ if (!matchedFile && !spec.version && options.latest) {
309
+ throw new Error(`No files found for /${spec.parts.join('/')}${spec.parts.length > 0 ? '/' : ''}${spec.base}`)
310
+ }
311
+ if (!matchedFile) {
312
+ if (!spec.version && !options.latest) {
313
+ throw new Error(
314
+ `Version missing for /${spec.parts.join('/')}/${spec.base}. Use --latest to select the latest version.`
315
+ )
316
+ }
317
+ const extensionHint = spec.ext ? `.${spec.ext}` : ' with any extension'
318
+ throw new Error(
319
+ `Versioned file not found for /${spec.parts.join('/')}/${spec.base}.${spec.version}${extensionHint}`
320
+ )
321
+ }
322
+
323
+ const sourceFile = path.join(targetDir, matchedFile)
324
+ const activeFile = path.join(targetDir, `${spec.base}.active${path.extname(matchedFile)}`)
325
+ const mode = setActiveLink(sourceFile, activeFile)
326
+
327
+ return {
328
+ agentName,
329
+ sourceFile,
330
+ activeFile,
331
+ matchedFile,
332
+ mode,
333
+ }
334
+ }
335
+
336
+ export async function loadAgent(agentName: string, processRoot: string = process.cwd()): Promise<AgentLoadResult> {
337
+ const agentsRoot = resolveAgentsRoot(processRoot)
338
+ const agentDir = path.join(agentsRoot, agentName)
339
+
340
+ if (!existsDir(agentDir)) {
341
+ throw new Error(`Agent folder not found: ${agentDir}`)
342
+ }
343
+
344
+ const loadFile = path.join(agentDir, 'load.ts')
345
+ if (!existsFile(loadFile)) {
346
+ throw new Error(`Load file not found for agent '${agentName}': ${loadFile}`)
347
+ }
348
+
349
+ validateActiveConfigInputs(agentDir)
350
+
351
+ const moduleUrl = pathToFileURL(loadFile).href
352
+ const module = await import(moduleUrl)
353
+ const loadFn = module.load
354
+
355
+ if (typeof loadFn !== 'function') {
356
+ throw new Error(`Load module for agent '${agentName}' must export a 'load' function.`)
357
+ }
358
+
359
+ return {
360
+ agentName,
361
+ loaded: await loadFn(),
362
+ }
363
+ }
364
+
365
+ function validateActiveConfigInputs(agentDir: string): void {
366
+ const issues: string[] = []
367
+
368
+ const requiredInputs = collectActiveConfigInputs(agentDir)
369
+
370
+ for (const input of requiredInputs) {
371
+ if (!existsFile(input.path)) {
372
+ if (input.required) {
373
+ issues.push(`missing: ${path.relative(agentDir, input.path)}`)
374
+ }
375
+ continue
376
+ }
377
+
378
+ if (!isSymbolicLink(input.path)) {
379
+ issues.push(`not a symlink: ${path.relative(agentDir, input.path)}`)
380
+ }
381
+ }
382
+
383
+ if (issues.length > 0) {
384
+ throw new Error(`Config input check failed: ${issues.join(', ')}`)
385
+ }
386
+ }
387
+
388
+ function collectActiveConfigInputs(agentDir: string): ActiveConfigInput[] {
389
+ const systemDir = path.join(agentDir, 'system')
390
+ const skillsDir = path.join(agentDir, 'skills')
391
+
392
+ const required: ActiveConfigInput[] = [
393
+ { path: path.join(systemDir, 'boot.active.md'), required: true },
394
+ { path: path.join(systemDir, 'workflow.active.md'), required: true },
395
+ { path: path.join(systemDir, 'tools.active.json'), required: true },
396
+ { path: path.join(systemDir, 'model.active.json'), required: true },
397
+ ]
398
+
399
+ const enabledSkillsPath = path.join(skillsDir, 'enabled-skills.md')
400
+ if (!existsFile(enabledSkillsPath)) {
401
+ return required
402
+ }
403
+
404
+ const lines = fs.readFileSync(enabledSkillsPath, 'utf8')
405
+ .split(/\r?\n/)
406
+ .map((line) => line.trim())
407
+ .filter(Boolean)
408
+
409
+ for (const line of lines) {
410
+ required.push({ path: path.join(skillsDir, line), required: true })
411
+ }
412
+
413
+ return required
414
+ }
415
+
416
+ function isSymbolicLink(filePath: string): boolean {
417
+ try {
418
+ return fs.lstatSync(filePath).isSymbolicLink()
419
+ } catch {
420
+ return false
421
+ }
422
+ }
423
+
424
+ export async function main(argv: string[], processRoot: string = process.cwd()): Promise<string> {
425
+ if (
426
+ argv.length === 0 ||
427
+ argv.includes('--help') ||
428
+ argv.includes('-h')
429
+ ) {
430
+ return usage()
431
+ }
432
+
433
+ const [scope, maybeAgentOrFlag, maybeTarget, ...rest] = argv
434
+ const listMode = scope === 'agents' && maybeAgentOrFlag === '--list'
435
+
436
+ if (scope !== 'agents') {
437
+ throw new Error('Expected command `agents` as first positional argument.')
438
+ }
439
+
440
+ if (listMode) {
441
+ const unknownFlags = rest.filter(Boolean)
442
+ if (unknownFlags.length > 0) {
443
+ throw new Error(`Unknown flags for list mode: ${unknownFlags.join(', ')}`)
444
+ }
445
+ return listAgents(processRoot).join('\n')
446
+ }
447
+
448
+ const agentName = maybeAgentOrFlag
449
+ const target = maybeTarget
450
+ const setActive = rest.includes('--set-active')
451
+ const useLatest = rest.includes('--latest')
452
+
453
+ if (target === 'load') {
454
+ if (rest.length > 0) {
455
+ throw new Error(`Unknown flags for load mode: ${rest.join(', ')}`)
456
+ }
457
+ const result = await loadAgent(agentName, processRoot)
458
+ return JSON.stringify(result.loaded, null, 2)
459
+ }
460
+
461
+ if (!agentName || !target) {
462
+ throw new Error('Missing required arguments: <agent-name> and <file-path>.')
463
+ }
464
+
465
+ if (!setActive) {
466
+ throw new Error('`--set-active` is required for this operation.')
467
+ }
468
+
469
+ const unknownFlags = rest.filter((arg) => arg !== '--set-active' && arg !== '--latest')
470
+ if (unknownFlags.length > 0) {
471
+ throw new Error(`Unknown flags: ${unknownFlags.join(', ')}`)
472
+ }
473
+
474
+ const result = setActive(agentName, target, processRoot, { latest: useLatest })
475
+ return `[${CLI_NAME}] set active: ${result.matchedFile} -> ${path.basename(result.activeFile)} (${result.mode})`
476
+ }
477
+
478
+ function existsDir(p: string): boolean {
479
+ try {
480
+ return fs.statSync(p).isDirectory()
481
+ } catch {
482
+ return false
483
+ }
484
+ }
485
+
486
+ function existsFile(p: string): boolean {
487
+ try {
488
+ return fs.statSync(p).isFile()
489
+ } catch {
490
+ return false
491
+ }
492
+ }