facult 2.4.0 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "facult",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Manage canonical AI capabilities, sync surfaces, and evolution state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,228 @@
1
+ import { basename, extname } from "node:path";
2
+ import { renderCanonicalText } from "../agents";
3
+ import { generateMcpConfig, parseMcpConfig } from "./mcp";
4
+ import { parseSkillsDir } from "./skills";
5
+ import type {
6
+ CanonicalMcpConfig,
7
+ CanonicalMcpServer,
8
+ ParsedManagedAgentFile,
9
+ RenderManagedAgentOptions,
10
+ ToolAdapter,
11
+ } from "./types";
12
+ import { detectExplicitVersion } from "./version";
13
+
14
+ const FRONTMATTER_LINE_SPLIT_REGEX = /\r?\n/;
15
+ const FACTORY_AGENT_FRONTMATTER_REGEX =
16
+ /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
17
+ const LEADING_WHITESPACE_REGEX = /^\s+/;
18
+ const TRAILING_WHITESPACE_REGEX = /\s+$/;
19
+
20
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
21
+ return !!value && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ function escapeTomlMultiline(value: string): string {
25
+ return value.replace(/"""/g, '\\"""');
26
+ }
27
+
28
+ function escapeYamlString(value: string): string {
29
+ return JSON.stringify(value);
30
+ }
31
+
32
+ function stringifyFrontmatter(values: Record<string, string>): string {
33
+ return Object.entries(values)
34
+ .map(([key, value]) => `${key}: ${escapeYamlString(value)}`)
35
+ .join("\n");
36
+ }
37
+
38
+ function parseFrontmatterScalar(value: string): string {
39
+ const trimmed = value.trim();
40
+ if (!trimmed) {
41
+ return "";
42
+ }
43
+ if (
44
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
45
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
46
+ ) {
47
+ const quote = trimmed[0];
48
+ const inner = trimmed.slice(1, -1);
49
+ if (quote === '"') {
50
+ try {
51
+ return JSON.parse(trimmed);
52
+ } catch {
53
+ return inner;
54
+ }
55
+ }
56
+ return inner;
57
+ }
58
+ return trimmed;
59
+ }
60
+
61
+ function parseFrontmatter(text: string): Record<string, string> {
62
+ const out: Record<string, string> = {};
63
+ for (const line of text.split(FRONTMATTER_LINE_SPLIT_REGEX)) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed || trimmed.startsWith("#")) {
66
+ continue;
67
+ }
68
+ const separator = trimmed.indexOf(":");
69
+ if (separator === -1) {
70
+ continue;
71
+ }
72
+ const key = trimmed.slice(0, separator).trim();
73
+ const value = parseFrontmatterScalar(trimmed.slice(separator + 1));
74
+ if (key) {
75
+ out[key] = value;
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function normalizeFactoryServer(
82
+ server: CanonicalMcpServer
83
+ ): CanonicalMcpServer {
84
+ if (!isPlainObject(server.vendorExtensions)) {
85
+ return server;
86
+ }
87
+
88
+ const { type, ...vendorExtensions } = server.vendorExtensions;
89
+ return {
90
+ ...server,
91
+ transport:
92
+ typeof type === "string" && !server.transport ? type : server.transport,
93
+ vendorExtensions:
94
+ Object.keys(vendorExtensions).length > 0 ? vendorExtensions : undefined,
95
+ };
96
+ }
97
+
98
+ function parseFactoryMcp(config: unknown): CanonicalMcpConfig {
99
+ const parsed = parseMcpConfig(config);
100
+ for (const [name, server] of Object.entries(parsed.servers)) {
101
+ parsed.servers[name] = normalizeFactoryServer({ ...server });
102
+ }
103
+ return parsed;
104
+ }
105
+
106
+ function generateFactoryMcp(
107
+ canonical: CanonicalMcpConfig
108
+ ): Record<string, unknown> {
109
+ const generated = generateMcpConfig(canonical, "mcpServers");
110
+ const servers = generated.mcpServers;
111
+ if (!isPlainObject(servers)) {
112
+ return generated;
113
+ }
114
+
115
+ for (const [name, value] of Object.entries(servers)) {
116
+ if (!isPlainObject(value)) {
117
+ continue;
118
+ }
119
+ const { transport, ...server } = value as Record<string, unknown>;
120
+ const inferredType =
121
+ (typeof transport === "string" ? transport : undefined) ??
122
+ (typeof server.url === "string"
123
+ ? "http"
124
+ : typeof server.command === "string"
125
+ ? "stdio"
126
+ : undefined);
127
+ if (inferredType && typeof server.type !== "string") {
128
+ server.type = inferredType;
129
+ }
130
+ if (typeof server.disabled !== "boolean") {
131
+ server.disabled = false;
132
+ }
133
+ servers[name] = server;
134
+ }
135
+
136
+ return generated;
137
+ }
138
+
139
+ async function renderFactoryAgent(
140
+ options: RenderManagedAgentOptions
141
+ ): Promise<string> {
142
+ const parsed = Bun.TOML.parse(options.raw) as Record<string, unknown>;
143
+ const name =
144
+ typeof parsed.name === "string"
145
+ ? parsed.name
146
+ : basename(options.targetPath, extname(options.targetPath));
147
+ const description =
148
+ typeof parsed.description === "string" ? parsed.description : undefined;
149
+ const instructions =
150
+ typeof parsed.developer_instructions === "string"
151
+ ? parsed.developer_instructions
152
+ : "";
153
+ const renderedInstructions = await renderCanonicalText(instructions, {
154
+ homeDir: options.homeDir,
155
+ rootDir: options.rootDir,
156
+ projectRoot: options.projectRoot,
157
+ targetTool: options.tool,
158
+ targetPath: options.targetPath,
159
+ });
160
+
161
+ const frontmatter = stringifyFrontmatter({
162
+ name,
163
+ ...(description ? { description } : {}),
164
+ model: "inherit",
165
+ });
166
+ const body = renderedInstructions.trim();
167
+
168
+ return body
169
+ ? `---\n${frontmatter}\n---\n\n${body}\n`
170
+ : `---\n${frontmatter}\n---\n`;
171
+ }
172
+
173
+ async function parseFactoryManagedAgentFile(
174
+ path: string
175
+ ): Promise<ParsedManagedAgentFile | null> {
176
+ const file = Bun.file(path);
177
+ if (!(await file.exists())) {
178
+ return null;
179
+ }
180
+
181
+ const raw = await file.text();
182
+ const match = raw.match(FACTORY_AGENT_FRONTMATTER_REGEX);
183
+ if (!match) {
184
+ return null;
185
+ }
186
+
187
+ const [, frontmatterRaw, bodyRaw] = match;
188
+ const frontmatter = parseFrontmatter(frontmatterRaw ?? "");
189
+ const name = frontmatter.name || basename(path, extname(path));
190
+ const description = frontmatter.description || undefined;
191
+ const body = (bodyRaw ?? "")
192
+ .replace(LEADING_WHITESPACE_REGEX, "")
193
+ .replace(TRAILING_WHITESPACE_REGEX, "");
194
+ const lines = [`name = ${JSON.stringify(name)}`];
195
+ if (description) {
196
+ lines.push(`description = ${JSON.stringify(description)}`);
197
+ }
198
+ lines.push("", 'developer_instructions = """');
199
+ if (body) {
200
+ lines.push(escapeTomlMultiline(body));
201
+ }
202
+ lines.push('"""', "");
203
+
204
+ return {
205
+ name,
206
+ raw: lines.join("\n"),
207
+ sourcePath: path,
208
+ };
209
+ }
210
+
211
+ export const factoryAdapter: ToolAdapter = {
212
+ id: "factory",
213
+ name: "Factory",
214
+ versions: ["v1"],
215
+ detectVersion: detectExplicitVersion,
216
+ getDefaultPaths: () => ({
217
+ mcp: "~/.factory/mcp.json",
218
+ skills: ["~/.factory/skills", ".factory/skills"],
219
+ agents: ["~/.factory/droids", ".factory/droids"],
220
+ }),
221
+ parseMcp: (config) => parseFactoryMcp(config),
222
+ generateMcp: (canonical) => generateFactoryMcp(canonical),
223
+ parseSkills: async (skillsDir) => await parseSkillsDir(skillsDir),
224
+ agentFileExtension: ".md",
225
+ renderAgent: async (options) => await renderFactoryAgent(options),
226
+ parseManagedAgentFile: async (path) =>
227
+ await parseFactoryManagedAgentFile(path),
228
+ };
@@ -3,6 +3,7 @@ import { claudeDesktopAdapter } from "./claude-desktop";
3
3
  import { clawdbotAdapter } from "./clawdbot";
4
4
  import { codexAdapter } from "./codex";
5
5
  import { cursorAdapter } from "./cursor";
6
+ import { factoryAdapter } from "./factory";
6
7
  import { referenceAdapter } from "./reference";
7
8
  import type { ResolveVersionOptions, ToolAdapter } from "./types";
8
9
 
@@ -64,6 +65,7 @@ export async function resolveAdapterVersion(
64
65
  registerAdapter(referenceAdapter);
65
66
  registerAdapter(cursorAdapter);
66
67
  registerAdapter(codexAdapter);
68
+ registerAdapter(factoryAdapter);
67
69
  registerAdapter(claudeCliAdapter);
68
70
  registerAdapter(claudeDesktopAdapter);
69
71
  registerAdapter(clawdbotAdapter);
@@ -18,6 +18,21 @@ export interface CanonicalSkill {
18
18
  path?: string;
19
19
  }
20
20
 
21
+ export interface RenderManagedAgentOptions {
22
+ raw: string;
23
+ rootDir: string;
24
+ tool: string;
25
+ targetPath: string;
26
+ homeDir?: string;
27
+ projectRoot?: string;
28
+ }
29
+
30
+ export interface ParsedManagedAgentFile {
31
+ name: string;
32
+ raw: string;
33
+ sourcePath: string;
34
+ }
35
+
21
36
  export interface AdapterDefaultPaths {
22
37
  mcp?: string;
23
38
  skills?: string | string[];
@@ -34,6 +49,13 @@ export interface ToolAdapter {
34
49
  parseSkills?: (skillsDir: string) => Promise<CanonicalSkill[]>;
35
50
  generateMcp?: (canonical: CanonicalMcpConfig, version?: string) => unknown;
36
51
  generateSkillsDir?: (skills: CanonicalSkill[]) => Promise<void>;
52
+ agentFileExtension?: string;
53
+ renderAgent?: (
54
+ options: RenderManagedAgentOptions
55
+ ) => Promise<string> | string;
56
+ parseManagedAgentFile?: (
57
+ path: string
58
+ ) => Promise<ParsedManagedAgentFile | null>;
37
59
  getDefaultPaths?: () => AdapterDefaultPaths;
38
60
  }
39
61
 
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readdir } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { getAdapter } from "./adapters";
4
5
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
5
6
  import {
6
7
  type AssetScope,
@@ -31,6 +32,10 @@ interface AssetEntryBase {
31
32
  shadow?: boolean;
32
33
  }
33
34
 
35
+ function managedAgentFileExtension(tool: string): string {
36
+ return getAdapter(tool)?.agentFileExtension ?? ".toml";
37
+ }
38
+
34
39
  export interface SkillEntry {
35
40
  name: string;
36
41
  path: string;
@@ -1213,6 +1218,7 @@ function registerManagedRenderedTargets(args: {
1213
1218
  const nodes = args.graph.nodes;
1214
1219
  for (const toolState of toolStates) {
1215
1220
  if (toolState.agentsDir) {
1221
+ const extension = managedAgentFileExtension(toolState.tool);
1216
1222
  for (const entry of Object.values(args.index.agents)) {
1217
1223
  const sourceNodeId = sourceNodeIdForEntry({
1218
1224
  kind: "agent",
@@ -1221,7 +1227,10 @@ function registerManagedRenderedTargets(args: {
1221
1227
  if (!nodes[sourceNodeId]) {
1222
1228
  continue;
1223
1229
  }
1224
- const targetPath = join(toolState.agentsDir, `${entry.name}.toml`);
1230
+ const targetPath = join(
1231
+ toolState.agentsDir,
1232
+ `${entry.name}${extension}`
1233
+ );
1225
1234
  registerRenderedTargetNode({
1226
1235
  graph: args.graph,
1227
1236
  currentScope: args.currentScope,
package/src/manage.ts CHANGED
@@ -203,6 +203,19 @@ function defaultToolPaths(
203
203
  skillsDir: toolBase(".antigravity", "skills"),
204
204
  mcpConfig: toolBase(".antigravity", "mcp.json"),
205
205
  },
206
+ factory: {
207
+ tool: "factory",
208
+ skillsDir: projectRoot
209
+ ? join(projectRoot, ".factory", "skills")
210
+ : homePath(home, ".factory", "skills"),
211
+ mcpConfig: projectRoot
212
+ ? join(projectRoot, ".factory", "mcp.json")
213
+ : homePath(home, ".factory", "mcp.json"),
214
+ agentsDir: projectRoot
215
+ ? join(projectRoot, ".factory", "droids")
216
+ : homePath(home, ".factory", "droids"),
217
+ toolHome: projectRoot ? undefined : homePath(home, ".factory"),
218
+ },
206
219
  };
207
220
 
208
221
  const adapterDefaults = (tool: string): ToolPaths | null => {
@@ -436,6 +449,69 @@ async function loadCanonicalAgents(
436
449
  return await loadAgentsFromRoot(homePath(rootDir, "agents"));
437
450
  }
438
451
 
452
+ function managedAgentFileExtension(tool: string): string {
453
+ return getAdapter(tool)?.agentFileExtension ?? ".toml";
454
+ }
455
+
456
+ async function renderManagedAgentFile(args: {
457
+ agent: { name: string; sourcePath: string; raw: string };
458
+ homeDir: string;
459
+ rootDir: string;
460
+ tool: string;
461
+ targetPath: string;
462
+ }): Promise<string> {
463
+ const adapter = getAdapter(args.tool);
464
+ if (adapter?.renderAgent) {
465
+ return await adapter.renderAgent({
466
+ raw: args.agent.raw,
467
+ homeDir: args.homeDir,
468
+ rootDir: args.rootDir,
469
+ projectRoot:
470
+ projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined,
471
+ tool: args.tool,
472
+ targetPath: args.targetPath,
473
+ });
474
+ }
475
+
476
+ return await renderCanonicalText(args.agent.raw, {
477
+ homeDir: args.homeDir,
478
+ rootDir: args.rootDir,
479
+ projectRoot: projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined,
480
+ targetTool: args.tool,
481
+ targetPath: args.targetPath,
482
+ });
483
+ }
484
+
485
+ async function loadManagedAgentsFromTool(args: {
486
+ tool: string;
487
+ agentsDir: string;
488
+ }): Promise<{ name: string; sourcePath: string; raw: string }[]> {
489
+ const adapter = getAdapter(args.tool);
490
+ if (!adapter?.parseManagedAgentFile) {
491
+ return await loadAgentsFromRoot(args.agentsDir);
492
+ }
493
+
494
+ const extension = managedAgentFileExtension(args.tool);
495
+ const entries = await readdir(args.agentsDir, { withFileTypes: true }).catch(
496
+ () => [] as import("node:fs").Dirent[]
497
+ );
498
+ const out: { name: string; sourcePath: string; raw: string }[] = [];
499
+
500
+ for (const entry of entries) {
501
+ if (!(entry.isFile() && entry.name.endsWith(extension))) {
502
+ continue;
503
+ }
504
+ const sourcePath = join(args.agentsDir, entry.name);
505
+ const parsed = await adapter.parseManagedAgentFile(sourcePath);
506
+ if (!parsed) {
507
+ continue;
508
+ }
509
+ out.push(parsed);
510
+ }
511
+
512
+ return out.sort((a, b) => a.name.localeCompare(b.name));
513
+ }
514
+
439
515
  interface AutomationEntry {
440
516
  name: string;
441
517
  sourceDir: string;
@@ -637,14 +713,15 @@ async function planAgentFileChanges({
637
713
  const contents = new Map<string, string>();
638
714
  const sources = new Map<string, string>();
639
715
  const desiredPaths = new Set<string>();
716
+ const extension = managedAgentFileExtension(tool);
640
717
 
641
718
  for (const agent of agents) {
642
- const target = homePath(agentsDir, `${agent.name}.toml`);
643
- const rendered = await renderCanonicalText(agent.raw, {
719
+ const target = homePath(agentsDir, `${agent.name}${extension}`);
720
+ const rendered = await renderManagedAgentFile({
721
+ agent,
644
722
  homeDir,
645
723
  rootDir,
646
- projectRoot: projectRootFromAiRoot(rootDir, homeDir) ?? undefined,
647
- targetTool: tool,
724
+ tool,
648
725
  targetPath: target,
649
726
  });
650
727
  desiredPaths.add(target);
@@ -659,7 +736,7 @@ async function planAgentFileChanges({
659
736
  const remove = new Set<string>();
660
737
 
661
738
  for (const entry of existing) {
662
- if (!(entry.isFile() && entry.name.endsWith(".toml"))) {
739
+ if (!(entry.isFile() && entry.name.endsWith(extension))) {
663
740
  continue;
664
741
  }
665
742
  const p = homePath(agentsDir, entry.name);
@@ -1214,11 +1291,15 @@ function logManagedImportPlan(tool: string, plan: ExistingManagedImportPlan) {
1214
1291
  }
1215
1292
 
1216
1293
  async function planExistingToolAgentAdoption(args: {
1294
+ tool: string;
1217
1295
  rootDir: string;
1218
1296
  agentsDir: string;
1219
1297
  }): Promise<ExistingManagedImportPlan> {
1220
1298
  const plan = emptyManagedImportPlan();
1221
- const agents = await loadAgentsFromRoot(args.agentsDir);
1299
+ const agents = await loadManagedAgentsFromTool({
1300
+ tool: args.tool,
1301
+ agentsDir: args.agentsDir,
1302
+ });
1222
1303
  for (const agent of agents) {
1223
1304
  const canonicalPath = join(
1224
1305
  args.rootDir,
@@ -1245,12 +1326,16 @@ async function planExistingToolAgentAdoption(args: {
1245
1326
  }
1246
1327
 
1247
1328
  async function adoptExistingToolAgents(args: {
1329
+ tool: string;
1248
1330
  rootDir: string;
1249
1331
  agentsDir: string;
1250
1332
  conflictMode: "keep-canonical" | "keep-existing";
1251
1333
  }): Promise<ExistingManagedItem[]> {
1252
1334
  const adopted: ExistingManagedItem[] = [];
1253
- const agents = await loadAgentsFromRoot(args.agentsDir);
1335
+ const agents = await loadManagedAgentsFromTool({
1336
+ tool: args.tool,
1337
+ agentsDir: args.agentsDir,
1338
+ });
1254
1339
  for (const agent of agents) {
1255
1340
  const canonicalPath = join(
1256
1341
  args.rootDir,
@@ -2019,6 +2104,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2019
2104
  asManagedSkillPlan(existingSkillPlan),
2020
2105
  toolPaths.agentsDir
2021
2106
  ? await planExistingToolAgentAdoption({
2107
+ tool,
2022
2108
  rootDir,
2023
2109
  agentsDir: toolPaths.agentsDir,
2024
2110
  })
@@ -2142,6 +2228,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2142
2228
  }
2143
2229
  if (toolPaths.agentsDir && opts.adoptExisting) {
2144
2230
  const result = await adoptExistingToolAgents({
2231
+ tool,
2145
2232
  rootDir,
2146
2233
  agentsDir: toolPaths.agentsDir,
2147
2234
  conflictMode: importConflictMode,
@@ -3006,6 +3093,7 @@ async function repairManagedCanonicalContent(args: {
3006
3093
 
3007
3094
  if (args.entry.agentsBackup) {
3008
3095
  const items = await adoptExistingToolAgents({
3096
+ tool: args.entry.tool,
3009
3097
  rootDir: args.rootDir,
3010
3098
  agentsDir: args.entry.agentsBackup,
3011
3099
  conflictMode: "keep-canonical",
package/src/scan.ts CHANGED
@@ -601,6 +601,19 @@ function defaultSourceSpecs(
601
601
  "~/.codex/mcp.json",
602
602
  ],
603
603
  },
604
+ {
605
+ id: "factory",
606
+ name: "Factory",
607
+ candidates: ["~/.factory", "~/.factory/mcp.json"],
608
+ skillDirs: ["~/.factory/skills"],
609
+ configFiles: ["~/.factory/mcp.json"],
610
+ assets: [
611
+ {
612
+ kind: "agents-instructions",
613
+ patterns: ["~/.factory/AGENTS.md"],
614
+ },
615
+ ],
616
+ },
604
617
  {
605
618
  id: "claude",
606
619
  name: "Claude (CLI)",
@@ -784,6 +797,13 @@ function defaultSourceSpecs(
784
797
  },
785
798
  ],
786
799
  },
800
+ {
801
+ id: "factory-project",
802
+ name: "Factory (project)",
803
+ candidates: [join(cwd, ".factory")],
804
+ skillDirs: [join(cwd, ".factory", "skills")],
805
+ configFiles: [join(cwd, ".factory", "mcp.json")],
806
+ },
787
807
  ];
788
808
 
789
809
  if (includeGitHooks) {
@@ -1288,7 +1308,12 @@ async function buildFromRootResult(args: {
1288
1308
  }
1289
1309
  continue;
1290
1310
  }
1291
- if (name === ".codex" || name === ".agents" || name === ".clawdbot") {
1311
+ if (
1312
+ name === ".codex" ||
1313
+ name === ".agents" ||
1314
+ name === ".clawdbot" ||
1315
+ name === ".factory"
1316
+ ) {
1292
1317
  await scanToolDotDir(child);
1293
1318
  continue;
1294
1319
  }