@lnai/core 0.5.0 → 0.6.5

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/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  declare const UNIFIED_DIR = ".ai";
4
- declare const TOOL_IDS: readonly ["claudeCode", "opencode", "cursor", "copilot", "windsurf", "gemini"];
4
+ declare const TOOL_IDS: readonly ["claudeCode", "opencode", "cursor", "copilot", "windsurf", "gemini", "codex"];
5
5
  type ToolId = (typeof TOOL_IDS)[number];
6
6
  declare const CONFIG_FILES: {
7
7
  readonly config: "config.json";
@@ -40,6 +40,10 @@ declare class PluginError extends LnaiError {
40
40
  readonly pluginId: string;
41
41
  constructor(message: string, pluginId: string, cause?: Error);
42
42
  }
43
+ declare class InvalidToolError extends LnaiError {
44
+ readonly invalidTools: string[];
45
+ constructor(invalidTools: string[], validTools: string[]);
46
+ }
43
47
 
44
48
  /** MCP Server configuration (Claude format as source of truth) */
45
49
  declare const mcpServerSchema: z.ZodObject<{
@@ -69,6 +73,7 @@ declare const toolIdSchema: z.ZodEnum<{
69
73
  copilot: "copilot";
70
74
  windsurf: "windsurf";
71
75
  gemini: "gemini";
76
+ codex: "codex";
72
77
  }>;
73
78
  /** Settings configuration (Claude format as source of truth) */
74
79
  declare const settingsSchema: z.ZodObject<{
@@ -116,6 +121,10 @@ declare const configSchema: z.ZodObject<{
116
121
  enabled: z.ZodBoolean;
117
122
  versionControl: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
118
123
  }, z.core.$strip>>;
124
+ codex: z.ZodOptional<z.ZodObject<{
125
+ enabled: z.ZodBoolean;
126
+ versionControl: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
127
+ }, z.core.$strip>>;
119
128
  }, z.core.$strip>>;
120
129
  }, z.core.$strip>;
121
130
  /** Skill frontmatter (name and description required) */
@@ -197,6 +206,25 @@ interface SyncResult {
197
206
  changes: ChangeResult[];
198
207
  validation: ValidationResult;
199
208
  }
209
+ /** Entry for a single file in the manifest */
210
+ interface ManifestEntry {
211
+ path: string;
212
+ type: "json" | "text" | "symlink";
213
+ hash?: string;
214
+ target?: string;
215
+ }
216
+ /** Manifest for a single tool's generated files */
217
+ interface ToolManifest {
218
+ version: 1;
219
+ tool: ToolId;
220
+ generatedAt: string;
221
+ files: ManifestEntry[];
222
+ }
223
+ /** Root manifest tracking all LNAI-generated files */
224
+ interface LnaiManifest {
225
+ version: 1;
226
+ tools: Partial<Record<ToolId, ToolManifest>>;
227
+ }
200
228
 
201
229
  declare function parseFrontmatter(content: string): {
202
230
  frontmatter: Record<string, unknown>;
@@ -249,6 +277,18 @@ interface Plugin {
249
277
  */
250
278
  declare const claudeCodePlugin: Plugin;
251
279
 
280
+ /**
281
+ * Codex plugin for exporting to .codex/ format
282
+ *
283
+ * Output structure:
284
+ * - AGENTS.md (symlink -> .ai/AGENTS.md) [at project root]
285
+ * - <dir>/AGENTS.md (generated from .ai/rules/*.md, per glob directory)
286
+ * - .codex/skills/<name>/ (symlink -> ../../.ai/skills/<name>)
287
+ * - .codex/config.toml (generated from settings.mcpServers)
288
+ * - .codex/<path> (symlink -> ../.ai/.codex/<path>) for override files
289
+ */
290
+ declare const codexPlugin: Plugin;
291
+
252
292
  /**
253
293
  * OpenCode plugin for exporting to opencode.json format
254
294
  *
@@ -281,8 +321,8 @@ interface SyncOptions {
281
321
  tools?: ToolId[];
282
322
  /** Preview changes without writing files */
283
323
  dryRun?: boolean;
284
- /** Enable verbose output */
285
- verbose?: boolean;
324
+ /** Skip cleanup of orphaned files */
325
+ skipCleanup?: boolean;
286
326
  }
287
327
  /**
288
328
  * Run the sync pipeline to export .ai/ config to native tool formats.
@@ -303,9 +343,50 @@ declare function writeFiles(files: OutputFile[], options: WriterOptions): Promis
303
343
  /**
304
344
  * Update .gitignore with paths that should not be version controlled.
305
345
  * Manages a dedicated "lnai-generated" section to avoid conflicts with user entries.
346
+ * Merges new paths with existing ones to support partial syncs (e.g., syncing one tool).
306
347
  */
307
348
  declare function updateGitignore(rootDir: string, paths: string[]): Promise<void>;
308
349
 
350
+ declare const MANIFEST_FILENAME = ".lnai-manifest.json";
351
+ /**
352
+ * Read the LNAI manifest from the .ai directory.
353
+ * Returns null if the manifest doesn't exist.
354
+ */
355
+ declare function readManifest(rootDir: string): Promise<LnaiManifest | null>;
356
+ /**
357
+ * Write the LNAI manifest to the .ai directory.
358
+ */
359
+ declare function writeManifest(rootDir: string, manifest: LnaiManifest): Promise<void>;
360
+ /**
361
+ * Build a tool manifest from output files.
362
+ */
363
+ declare function buildToolManifest(toolId: ToolId, files: OutputFile[]): ToolManifest;
364
+ /**
365
+ * Update a single tool's manifest entry.
366
+ */
367
+ declare function updateToolManifest(manifest: LnaiManifest, toolId: ToolId, files: OutputFile[]): LnaiManifest;
368
+ /**
369
+ * Create an empty manifest.
370
+ */
371
+ declare function createEmptyManifest(): LnaiManifest;
372
+
373
+ /**
374
+ * Compute which files should be deleted based on previous and current manifest entries.
375
+ * Returns paths that exist in previous but not in current.
376
+ */
377
+ declare function computeFilesToDelete(previousFiles: ManifestEntry[], currentFiles: OutputFile[]): string[];
378
+ /**
379
+ * Delete files that are no longer in the manifest.
380
+ * Returns ChangeResult[] with action: "delete" for each deleted file.
381
+ * When dryRun is true, no files are actually deleted.
382
+ */
383
+ declare function deleteFiles(paths: string[], rootDir: string, dryRun: boolean): Promise<ChangeResult[]>;
384
+ /**
385
+ * Clean up empty parent directories after file deletion.
386
+ * Stops at the project root directory.
387
+ */
388
+ declare function cleanupEmptyParentDirs(filePath: string, rootDir: string): Promise<void>;
389
+
309
390
  interface InitOptions {
310
391
  rootDir: string;
311
392
  tools?: ToolId[];
@@ -320,4 +401,4 @@ declare function initUnifiedConfig(options: InitOptions): Promise<InitResult>;
320
401
  declare function hasUnifiedConfig(rootDir: string): Promise<boolean>;
321
402
  declare function generateDefaultConfig(tools?: ToolId[], versionControl?: Record<ToolId, boolean>): Config;
322
403
 
323
- export { CONFIG_DIRS, CONFIG_FILES, type ChangeResult, type Config, FileNotFoundError, type InitOptions, type InitResult, LnaiError, type MarkdownFile, type MarkdownFrontmatter, type McpServer, type OutputFile, ParseError, type PermissionLevel, type Permissions, type Plugin, PluginError, type RuleFrontmatter, type Settings, type SkillFrontmatter, type SkippedFeatureDetail, type SyncOptions, type SyncResult, TOOL_IDS, TOOL_OUTPUT_DIRS, type ToolConfig, type ToolId, UNIFIED_DIR, type UnifiedState, ValidationError, type ValidationErrorDetail, type ValidationResult, type ValidationWarningDetail, WriteError, type WriterOptions, claudeCodePlugin, computeHash, configSchema, generateDefaultConfig, hasUnifiedConfig, initUnifiedConfig, mcpServerSchema, opencodePlugin, parseFrontmatter, parseUnifiedConfig, permissionsSchema, pluginRegistry, ruleFrontmatterSchema, runSyncPipeline, settingsSchema, skillFrontmatterSchema, toolConfigSchema, toolIdSchema, updateGitignore, validateConfig, validateSettings, validateUnifiedState, writeFiles };
404
+ export { CONFIG_DIRS, CONFIG_FILES, type ChangeResult, type Config, FileNotFoundError, type InitOptions, type InitResult, InvalidToolError, LnaiError, type LnaiManifest, MANIFEST_FILENAME, type ManifestEntry, type MarkdownFile, type MarkdownFrontmatter, type McpServer, type OutputFile, ParseError, type PermissionLevel, type Permissions, type Plugin, PluginError, type RuleFrontmatter, type Settings, type SkillFrontmatter, type SkippedFeatureDetail, type SyncOptions, type SyncResult, TOOL_IDS, TOOL_OUTPUT_DIRS, type ToolConfig, type ToolId, type ToolManifest, UNIFIED_DIR, type UnifiedState, ValidationError, type ValidationErrorDetail, type ValidationResult, type ValidationWarningDetail, WriteError, type WriterOptions, buildToolManifest, claudeCodePlugin, cleanupEmptyParentDirs, codexPlugin, computeFilesToDelete, computeHash, configSchema, createEmptyManifest, deleteFiles, generateDefaultConfig, hasUnifiedConfig, initUnifiedConfig, mcpServerSchema, opencodePlugin, parseFrontmatter, parseUnifiedConfig, permissionsSchema, pluginRegistry, readManifest, ruleFrontmatterSchema, runSyncPipeline, settingsSchema, skillFrontmatterSchema, toolConfigSchema, toolIdSchema, updateGitignore, updateToolManifest, validateConfig, validateSettings, validateUnifiedState, writeFiles, writeManifest };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import * as fs3 from 'fs/promises';
2
+ import * as fs4 from 'fs/promises';
3
3
  import * as path from 'path';
4
4
  import matter from 'gray-matter';
5
5
  import * as crypto from 'crypto';
@@ -12,7 +12,8 @@ var TOOL_IDS = [
12
12
  "cursor",
13
13
  "copilot",
14
14
  "windsurf",
15
- "gemini"
15
+ "gemini",
16
+ "codex"
16
17
  ];
17
18
  var CONFIG_FILES = {
18
19
  config: "config.json",
@@ -30,7 +31,8 @@ var TOOL_OUTPUT_DIRS = {
30
31
  cursor: ".cursor",
31
32
  copilot: ".github",
32
33
  windsurf: ".windsurf",
33
- gemini: ".gemini"
34
+ gemini: ".gemini",
35
+ codex: ".codex"
34
36
  };
35
37
  var OVERRIDE_DIRS = {
36
38
  claudeCode: ".claude",
@@ -38,7 +40,8 @@ var OVERRIDE_DIRS = {
38
40
  cursor: ".cursor",
39
41
  copilot: ".copilot",
40
42
  windsurf: ".windsurf",
41
- gemini: ".gemini"
43
+ gemini: ".gemini",
44
+ codex: ".codex"
42
45
  };
43
46
 
44
47
  // src/errors.ts
@@ -64,10 +67,10 @@ var ParseError = class extends LnaiError {
64
67
  var ValidationError = class extends LnaiError {
65
68
  path;
66
69
  value;
67
- constructor(message, path6, value) {
70
+ constructor(message, path8, value) {
68
71
  super(message, "VALIDATION_ERROR");
69
72
  this.name = "ValidationError";
70
- this.path = path6;
73
+ this.path = path8;
71
74
  this.value = value;
72
75
  }
73
76
  };
@@ -101,6 +104,17 @@ var PluginError = class extends LnaiError {
101
104
  }
102
105
  }
103
106
  };
107
+ var InvalidToolError = class extends LnaiError {
108
+ invalidTools;
109
+ constructor(invalidTools, validTools) {
110
+ super(
111
+ `Invalid tool(s): ${invalidTools.join(", ")}. Valid tools: ${validTools.join(", ")}`,
112
+ "INVALID_TOOL"
113
+ );
114
+ this.name = "InvalidToolError";
115
+ this.invalidTools = invalidTools;
116
+ }
117
+ };
104
118
  var mcpServerSchema = z.object({
105
119
  command: z.string().optional(),
106
120
  args: z.array(z.string()).optional(),
@@ -124,7 +138,8 @@ var toolIdSchema = z.enum([
124
138
  "cursor",
125
139
  "copilot",
126
140
  "windsurf",
127
- "gemini"
141
+ "gemini",
142
+ "codex"
128
143
  ]);
129
144
  var settingsSchema = z.object({
130
145
  permissions: permissionsSchema.optional(),
@@ -137,7 +152,8 @@ var configSchema = z.object({
137
152
  cursor: toolConfigSchema,
138
153
  copilot: toolConfigSchema,
139
154
  windsurf: toolConfigSchema,
140
- gemini: toolConfigSchema
155
+ gemini: toolConfigSchema,
156
+ codex: toolConfigSchema
141
157
  }).partial().optional()
142
158
  });
143
159
  var skillFrontmatterSchema = z.object({
@@ -199,7 +215,7 @@ async function parseUnifiedConfig(rootDir) {
199
215
  }
200
216
  async function fileExists(filePath) {
201
217
  try {
202
- await fs3.access(filePath);
218
+ await fs4.access(filePath);
203
219
  return true;
204
220
  } catch {
205
221
  return false;
@@ -207,7 +223,7 @@ async function fileExists(filePath) {
207
223
  }
208
224
  async function readJsonFile(filePath) {
209
225
  try {
210
- const content = await fs3.readFile(filePath, "utf-8");
226
+ const content = await fs4.readFile(filePath, "utf-8");
211
227
  return JSON.parse(content);
212
228
  } catch (error) {
213
229
  if (error.code === "ENOENT") {
@@ -222,7 +238,7 @@ async function readJsonFile(filePath) {
222
238
  }
223
239
  async function readMarkdownFile(filePath) {
224
240
  try {
225
- return await fs3.readFile(filePath, "utf-8");
241
+ return await fs4.readFile(filePath, "utf-8");
226
242
  } catch (error) {
227
243
  if (error.code === "ENOENT") {
228
244
  throw new FileNotFoundError(`File not found: ${filePath}`, filePath);
@@ -239,7 +255,7 @@ async function readMarkdownDirectory(dirPath) {
239
255
  if (!await fileExists(dirPath)) {
240
256
  return files;
241
257
  }
242
- const entries = await fs3.readdir(dirPath, { withFileTypes: true });
258
+ const entries = await fs4.readdir(dirPath, { withFileTypes: true });
243
259
  for (const entry of entries) {
244
260
  if (entry.isFile() && entry.name.endsWith(".md")) {
245
261
  const filePath = path.join(dirPath, entry.name);
@@ -259,7 +275,7 @@ async function readSkillsDirectory(dirPath) {
259
275
  if (!await fileExists(dirPath)) {
260
276
  return skills;
261
277
  }
262
- const entries = await fs3.readdir(dirPath, { withFileTypes: true });
278
+ const entries = await fs4.readdir(dirPath, { withFileTypes: true });
263
279
  for (const entry of entries) {
264
280
  if (entry.isDirectory()) {
265
281
  const skillFile = path.join(dirPath, entry.name, "SKILL.md");
@@ -400,7 +416,7 @@ function validateToolIds(tools) {
400
416
  async function scanOverrideDirectory(rootDir, toolId) {
401
417
  const overrideDir = path.join(rootDir, UNIFIED_DIR, OVERRIDE_DIRS[toolId]);
402
418
  try {
403
- await fs3.access(overrideDir);
419
+ await fs4.access(overrideDir);
404
420
  } catch {
405
421
  return [];
406
422
  }
@@ -409,7 +425,7 @@ async function scanOverrideDirectory(rootDir, toolId) {
409
425
  return files;
410
426
  }
411
427
  async function scanDir(baseDir, currentDir, files) {
412
- const entries = await fs3.readdir(currentDir, { withFileTypes: true });
428
+ const entries = await fs4.readdir(currentDir, { withFileTypes: true });
413
429
  for (const entry of entries) {
414
430
  const absolutePath = path.join(currentDir, entry.name);
415
431
  if (entry.isDirectory()) {
@@ -501,6 +517,209 @@ var claudeCodePlugin = {
501
517
  return { valid: true, errors: [], warnings, skipped: [] };
502
518
  }
503
519
  };
520
+ function getDirFromGlob(glob) {
521
+ const cleanPath = glob.replace(/(\*\*|\*|\{.*,.*\}).*$/, "");
522
+ const dir = cleanPath.replace(/\/$/, "");
523
+ if (dir === glob) {
524
+ const dirname5 = path.dirname(dir);
525
+ return dirname5 === "." && !dir.includes("/") ? "." : dirname5;
526
+ }
527
+ if (!dir) {
528
+ return ".";
529
+ }
530
+ return dir;
531
+ }
532
+ function groupRulesByDirectory(rules) {
533
+ const rulesMap = /* @__PURE__ */ new Map();
534
+ const addedRules = /* @__PURE__ */ new Map();
535
+ for (const rule of rules) {
536
+ for (const pathGlob of rule.frontmatter.paths) {
537
+ const dir = getDirFromGlob(pathGlob);
538
+ if (!rulesMap.has(dir)) {
539
+ rulesMap.set(dir, []);
540
+ addedRules.set(dir, /* @__PURE__ */ new Set());
541
+ }
542
+ if (addedRules.get(dir)?.has(rule.path)) {
543
+ continue;
544
+ }
545
+ addedRules.get(dir)?.add(rule.path);
546
+ const content = `## ${rule.path}
547
+
548
+ ${rule.content}
549
+ `;
550
+ rulesMap.get(dir)?.push(content);
551
+ }
552
+ }
553
+ return rulesMap;
554
+ }
555
+
556
+ // src/plugins/codex/index.ts
557
+ var codexPlugin = {
558
+ id: "codex",
559
+ name: "Codex",
560
+ async detect(_rootDir) {
561
+ return false;
562
+ },
563
+ async import(_rootDir) {
564
+ return null;
565
+ },
566
+ async export(state, rootDir) {
567
+ const files = [];
568
+ const outputDir = TOOL_OUTPUT_DIRS.codex;
569
+ if (state.agents) {
570
+ files.push({
571
+ path: "AGENTS.md",
572
+ type: "symlink",
573
+ target: `${UNIFIED_DIR}/AGENTS.md`
574
+ });
575
+ }
576
+ const rulesMap = groupRulesByDirectory(state.rules);
577
+ for (const [dir, contents] of rulesMap.entries()) {
578
+ if (dir === ".") {
579
+ continue;
580
+ }
581
+ const combinedContent = contents.join("\n---\n\n");
582
+ files.push({
583
+ path: `${dir}/AGENTS.md`,
584
+ type: "text",
585
+ content: combinedContent
586
+ });
587
+ }
588
+ for (const skill of state.skills) {
589
+ files.push({
590
+ path: `${outputDir}/skills/${skill.path}`,
591
+ type: "symlink",
592
+ target: `../../${UNIFIED_DIR}/skills/${skill.path}`
593
+ });
594
+ }
595
+ const configToml = buildCodexConfigToml(state.settings?.mcpServers);
596
+ if (configToml) {
597
+ files.push({
598
+ path: `${outputDir}/config.toml`,
599
+ type: "text",
600
+ content: configToml
601
+ });
602
+ }
603
+ return applyFileOverrides(files, rootDir, "codex");
604
+ },
605
+ validate(state) {
606
+ const warnings = [];
607
+ const skipped = [];
608
+ if (!state.agents) {
609
+ warnings.push({
610
+ path: ["AGENTS.md"],
611
+ message: "No AGENTS.md found - root AGENTS.md will not be created"
612
+ });
613
+ }
614
+ const rulesMap = groupRulesByDirectory(state.rules);
615
+ if (rulesMap.has(".")) {
616
+ warnings.push({
617
+ path: ["rules"],
618
+ message: "Rules with root globs are not exported - Codex only receives subdirectory AGENTS.md files"
619
+ });
620
+ }
621
+ const mcpServers = state.settings?.mcpServers;
622
+ if (mcpServers) {
623
+ for (const [name, server] of Object.entries(mcpServers)) {
624
+ if (!server.command && !server.url) {
625
+ warnings.push({
626
+ path: ["settings", "mcpServers", name],
627
+ message: `MCP server "${name}" has no command or url - it will be skipped`
628
+ });
629
+ }
630
+ }
631
+ }
632
+ if (state.settings?.permissions) {
633
+ const hasPermissions = (state.settings.permissions.allow?.length ?? 0) > 0 || (state.settings.permissions.ask?.length ?? 0) > 0 || (state.settings.permissions.deny?.length ?? 0) > 0;
634
+ if (hasPermissions) {
635
+ skipped.push({
636
+ feature: "permissions",
637
+ reason: "Codex rules are not generated from LNAI permissions"
638
+ });
639
+ }
640
+ }
641
+ return { valid: true, errors: [], warnings, skipped };
642
+ }
643
+ };
644
+ function buildCodexConfigToml(mcpServers) {
645
+ if (!mcpServers || Object.keys(mcpServers).length === 0) {
646
+ return void 0;
647
+ }
648
+ const lines = [];
649
+ for (const [name, server] of Object.entries(mcpServers)) {
650
+ const hasCommand = !!server.command;
651
+ const hasUrl = !!server.url;
652
+ if (!hasCommand && !hasUrl) {
653
+ continue;
654
+ }
655
+ lines.push(`[mcp_servers.${formatTomlKey(name)}]`);
656
+ if (server.command) {
657
+ lines.push(`command = ${formatTomlString(server.command)}`);
658
+ if (server.args && server.args.length > 0) {
659
+ lines.push(`args = ${formatTomlArray(server.args)}`);
660
+ }
661
+ if (server.env && Object.keys(server.env).length > 0) {
662
+ lines.push(`env = ${formatTomlInlineTable(server.env)}`);
663
+ }
664
+ }
665
+ if (server.url) {
666
+ lines.push(`url = ${formatTomlString(server.url)}`);
667
+ if (server.headers && Object.keys(server.headers).length > 0) {
668
+ lines.push(`http_headers = ${formatTomlInlineTable(server.headers)}`);
669
+ }
670
+ }
671
+ lines.push("");
672
+ }
673
+ if (lines.length === 0) {
674
+ return void 0;
675
+ }
676
+ return `${lines.join("\n").trimEnd()}
677
+ `;
678
+ }
679
+ function formatTomlString(value) {
680
+ return JSON.stringify(value);
681
+ }
682
+ function formatTomlArray(values) {
683
+ return `[${values.map(formatTomlString).join(", ")}]`;
684
+ }
685
+ function formatTomlKey(key) {
686
+ if (/^[A-Za-z0-9_-]+$/.test(key)) {
687
+ return key;
688
+ }
689
+ return JSON.stringify(key);
690
+ }
691
+ function formatTomlInlineTable(values) {
692
+ const entries = Object.entries(values).map(
693
+ ([key, value]) => `${formatTomlKey(key)} = ${formatTomlString(value)}`
694
+ );
695
+ return `{ ${entries.join(", ")} }`;
696
+ }
697
+
698
+ // src/utils/mcp.ts
699
+ function validateMcpServers(mcpServers, pathPrefix) {
700
+ const warnings = [];
701
+ if (!mcpServers) {
702
+ return warnings;
703
+ }
704
+ for (const [name, server] of Object.entries(mcpServers)) {
705
+ const isRemote = server.type === "http" || server.type === "sse";
706
+ const hasCommand = !!server.command;
707
+ const hasUrl = !!server.url;
708
+ if (!isRemote && !hasCommand) {
709
+ warnings.push({
710
+ path: [...pathPrefix, name],
711
+ message: `MCP server "${name}" has no command or type - it will be skipped`
712
+ });
713
+ }
714
+ if (isRemote && !hasUrl) {
715
+ warnings.push({
716
+ path: [...pathPrefix, name],
717
+ message: `MCP server "${name}" is remote but has no URL - it will be skipped`
718
+ });
719
+ }
720
+ }
721
+ return warnings;
722
+ }
504
723
 
505
724
  // src/utils/transforms.ts
506
725
  var ENV_VAR_PATTERN = /\$\{([^}:]+)(:-[^}]*)?\}/g;
@@ -672,24 +891,12 @@ var copilotPlugin = {
672
891
  reason: "GitHub Copilot does not support declarative permissions"
673
892
  });
674
893
  }
675
- const mcpServers = state.settings?.mcpServers;
676
- if (mcpServers) {
677
- for (const [name, server] of Object.entries(mcpServers)) {
678
- const isRemote = server.type === "http" || server.type === "sse";
679
- const hasCommand = !!server.command;
680
- if (isRemote && !server.url) {
681
- warnings.push({
682
- path: ["settings", "mcpServers", name],
683
- message: `MCP server "${name}" is type "${server.type}" but has no url - it will be skipped`
684
- });
685
- } else if (!isRemote && !hasCommand) {
686
- warnings.push({
687
- path: ["settings", "mcpServers", name],
688
- message: `MCP server "${name}" has no command or type - it will be skipped`
689
- });
690
- }
691
- }
692
- }
894
+ warnings.push(
895
+ ...validateMcpServers(state.settings?.mcpServers, [
896
+ "settings",
897
+ "mcpServers"
898
+ ])
899
+ );
693
900
  return { valid: true, errors: [], warnings, skipped };
694
901
  }
695
902
  };
@@ -884,19 +1091,12 @@ var cursorPlugin = {
884
1091
  message: 'Cursor does not support "ask" permission level - these rules will be treated as "allow"'
885
1092
  });
886
1093
  }
887
- const mcpServers = state.settings?.mcpServers;
888
- if (mcpServers) {
889
- for (const [name, server] of Object.entries(mcpServers)) {
890
- const isRemote = server.type === "http" || server.type === "sse";
891
- const hasCommand = !!server.command;
892
- if (!isRemote && !hasCommand) {
893
- warnings.push({
894
- path: ["settings", "mcpServers", name],
895
- message: `MCP server "${name}" has no command or type - it will be skipped`
896
- });
897
- }
898
- }
899
- }
1094
+ warnings.push(
1095
+ ...validateMcpServers(state.settings?.mcpServers, [
1096
+ "settings",
1097
+ "mcpServers"
1098
+ ])
1099
+ );
900
1100
  return { valid: true, errors: [], warnings, skipped: [] };
901
1101
  }
902
1102
  };
@@ -907,18 +1107,6 @@ function buildCliContent(permissions) {
907
1107
  }
908
1108
  return { permissions: permissionsResult.permissions };
909
1109
  }
910
- function getDirFromGlob(glob) {
911
- const cleanPath = glob.replace(/(\*\*|\*|\{.*,.*\}).*$/, "");
912
- const dir = cleanPath.replace(/\/$/, "");
913
- if (dir === glob) {
914
- const dirname4 = path.dirname(dir);
915
- return dirname4 === "." && !dir.includes("/") ? "." : dirname4;
916
- }
917
- if (!dir) {
918
- return ".";
919
- }
920
- return dir;
921
- }
922
1110
 
923
1111
  // src/plugins/gemini/transforms.ts
924
1112
  function transformMcpToGemini(mcpServers) {
@@ -941,23 +1129,6 @@ function transformMcpToGemini(mcpServers) {
941
1129
  }
942
1130
  return Object.keys(geminiMcp).length > 0 ? geminiMcp : void 0;
943
1131
  }
944
- function groupRulesByDirectory(rules) {
945
- const rulesMap = /* @__PURE__ */ new Map();
946
- for (const rule of rules) {
947
- for (const pathGlob of rule.frontmatter.paths) {
948
- const dir = getDirFromGlob(pathGlob);
949
- if (!rulesMap.has(dir)) {
950
- rulesMap.set(dir, []);
951
- }
952
- const content = `## ${rule.path}
953
-
954
- ${rule.content}
955
- `;
956
- rulesMap.get(dir)?.push(content);
957
- }
958
- }
959
- return rulesMap;
960
- }
961
1132
 
962
1133
  // src/plugins/gemini/index.ts
963
1134
  var geminiPlugin = {
@@ -1171,6 +1342,12 @@ var opencodePlugin = {
1171
1342
  message: "No AGENTS.md found - root AGENTS.md will not be created"
1172
1343
  });
1173
1344
  }
1345
+ warnings.push(
1346
+ ...validateMcpServers(state.settings?.mcpServers, [
1347
+ "settings",
1348
+ "mcpServers"
1349
+ ])
1350
+ );
1174
1351
  return { valid: true, errors: [], warnings, skipped: [] };
1175
1352
  }
1176
1353
  };
@@ -1304,24 +1481,69 @@ var windsurfPlugin = {
1304
1481
  pluginRegistry.register(claudeCodePlugin);
1305
1482
  pluginRegistry.register(copilotPlugin);
1306
1483
  pluginRegistry.register(cursorPlugin);
1484
+ pluginRegistry.register(codexPlugin);
1307
1485
  pluginRegistry.register(opencodePlugin);
1308
1486
  pluginRegistry.register(windsurfPlugin);
1309
1487
  pluginRegistry.register(geminiPlugin);
1488
+ function computeFilesToDelete(previousFiles, currentFiles) {
1489
+ const currentPaths = new Set(currentFiles.map((f) => f.path));
1490
+ return previousFiles.map((f) => f.path).filter((p) => !currentPaths.has(p));
1491
+ }
1492
+ async function deleteFiles(paths, rootDir, dryRun) {
1493
+ const results = [];
1494
+ for (const relativePath of paths) {
1495
+ const fullPath = path.join(rootDir, relativePath);
1496
+ try {
1497
+ await fs4.lstat(fullPath);
1498
+ } catch (error) {
1499
+ if (error.code === "ENOENT") {
1500
+ continue;
1501
+ }
1502
+ throw error;
1503
+ }
1504
+ if (!dryRun) {
1505
+ await fs4.unlink(fullPath);
1506
+ await cleanupEmptyParentDirs(fullPath, rootDir);
1507
+ }
1508
+ results.push({
1509
+ path: relativePath,
1510
+ action: "delete"
1511
+ });
1512
+ }
1513
+ return results;
1514
+ }
1515
+ async function cleanupEmptyParentDirs(filePath, rootDir) {
1516
+ let dir = path.dirname(filePath);
1517
+ const normalizedRoot = path.normalize(rootDir);
1518
+ while (dir !== normalizedRoot && dir.startsWith(normalizedRoot + path.sep)) {
1519
+ try {
1520
+ const entries = await fs4.readdir(dir);
1521
+ if (entries.length === 0) {
1522
+ await fs4.rmdir(dir);
1523
+ dir = path.dirname(dir);
1524
+ } else {
1525
+ break;
1526
+ }
1527
+ } catch {
1528
+ break;
1529
+ }
1530
+ }
1531
+ }
1310
1532
  function computeHash(content) {
1311
1533
  return crypto.createHash("sha256").update(content, "utf-8").digest("hex");
1312
1534
  }
1313
1535
  async function readExistingFile(filePath) {
1314
1536
  try {
1315
- return await fs3.readFile(filePath, "utf-8");
1537
+ return await fs4.readFile(filePath, "utf-8");
1316
1538
  } catch {
1317
1539
  return null;
1318
1540
  }
1319
1541
  }
1320
1542
  async function getSymlinkTarget(filePath) {
1321
1543
  try {
1322
- const stats = await fs3.lstat(filePath);
1544
+ const stats = await fs4.lstat(filePath);
1323
1545
  if (stats.isSymbolicLink()) {
1324
- return await fs3.readlink(filePath);
1546
+ return await fs4.readlink(filePath);
1325
1547
  }
1326
1548
  return null;
1327
1549
  } catch {
@@ -1329,15 +1551,15 @@ async function getSymlinkTarget(filePath) {
1329
1551
  }
1330
1552
  }
1331
1553
  async function ensureDir(dirPath) {
1332
- await fs3.mkdir(dirPath, { recursive: true });
1554
+ await fs4.mkdir(dirPath, { recursive: true });
1333
1555
  }
1334
1556
  async function removeIfExists(filePath) {
1335
1557
  try {
1336
- const stats = await fs3.lstat(filePath);
1558
+ const stats = await fs4.lstat(filePath);
1337
1559
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1338
- await fs3.rm(filePath, { recursive: true, force: true });
1560
+ await fs4.rm(filePath, { recursive: true, force: true });
1339
1561
  } else {
1340
- await fs3.unlink(filePath);
1562
+ await fs4.unlink(filePath);
1341
1563
  }
1342
1564
  } catch (error) {
1343
1565
  if (error.code !== "ENOENT") {
@@ -1349,6 +1571,12 @@ async function writeSingleFile(file, rootDir, dryRun) {
1349
1571
  const fullPath = path.join(rootDir, file.path);
1350
1572
  const dirPath = path.dirname(fullPath);
1351
1573
  if (file.type === "symlink") {
1574
+ if (!file.target) {
1575
+ throw new WriteError(
1576
+ `Symlink file missing target: ${file.path}`,
1577
+ file.path
1578
+ );
1579
+ }
1352
1580
  const target = file.target;
1353
1581
  const existingTarget = await getSymlinkTarget(fullPath);
1354
1582
  if (existingTarget === target) {
@@ -1360,7 +1588,7 @@ async function writeSingleFile(file, rootDir, dryRun) {
1360
1588
  if (!dryRun) {
1361
1589
  await ensureDir(dirPath);
1362
1590
  await removeIfExists(fullPath);
1363
- await fs3.symlink(target, fullPath);
1591
+ await fs4.symlink(target, fullPath);
1364
1592
  }
1365
1593
  return {
1366
1594
  path: file.path,
@@ -1381,7 +1609,11 @@ async function writeSingleFile(file, rootDir, dryRun) {
1381
1609
  }
1382
1610
  if (!dryRun) {
1383
1611
  await ensureDir(dirPath);
1384
- await fs3.writeFile(fullPath, content, "utf-8");
1612
+ const existingSymlink = await getSymlinkTarget(fullPath);
1613
+ if (existingSymlink !== null) {
1614
+ await removeIfExists(fullPath);
1615
+ }
1616
+ await fs4.writeFile(fullPath, content, "utf-8");
1385
1617
  }
1386
1618
  return {
1387
1619
  path: file.path,
@@ -1411,18 +1643,86 @@ async function updateGitignore(rootDir, paths) {
1411
1643
  const gitignorePath = path.join(rootDir, ".gitignore");
1412
1644
  let content = "";
1413
1645
  try {
1414
- content = await fs3.readFile(gitignorePath, "utf-8");
1646
+ content = await fs4.readFile(gitignorePath, "utf-8");
1415
1647
  } catch {
1416
1648
  }
1417
1649
  const marker = "# lnai-generated";
1418
1650
  const endMarker = "# end lnai-generated";
1651
+ const extractRegex = new RegExp(`${marker}\\n([\\s\\S]*?)${endMarker}`);
1652
+ const match = extractRegex.exec(content);
1653
+ const existingSection = match?.[1] ?? "";
1654
+ const existingPaths = existingSection.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
1419
1655
  const markerRegex = new RegExp(`${marker}[\\s\\S]*?${endMarker}\\n?`, "g");
1420
1656
  content = content.replace(markerRegex, "");
1421
1657
  content = content.trimEnd();
1422
- const uniquePaths = [...new Set(paths)];
1658
+ const uniquePaths = [.../* @__PURE__ */ new Set([...existingPaths, ...paths])].sort();
1423
1659
  const newSection = ["", marker, ...uniquePaths, endMarker, ""].join("\n");
1424
1660
  content = content + newSection;
1425
- await fs3.writeFile(gitignorePath, content, "utf-8");
1661
+ await fs4.writeFile(gitignorePath, content, "utf-8");
1662
+ }
1663
+
1664
+ // src/manifest/index.ts
1665
+ var MANIFEST_FILENAME = ".lnai-manifest.json";
1666
+ async function readManifest(rootDir) {
1667
+ const manifestPath = path.join(rootDir, UNIFIED_DIR, MANIFEST_FILENAME);
1668
+ try {
1669
+ const content = await fs4.readFile(manifestPath, "utf-8");
1670
+ const manifest = JSON.parse(content);
1671
+ if (manifest.version !== 1) {
1672
+ console.warn(
1673
+ `[lnai] Unknown manifest version ${manifest.version}, skipping cleanup`
1674
+ );
1675
+ return null;
1676
+ }
1677
+ return manifest;
1678
+ } catch (error) {
1679
+ if (error.code === "ENOENT") {
1680
+ return null;
1681
+ }
1682
+ console.warn(`[lnai] Failed to read manifest: ${error.message}`);
1683
+ return null;
1684
+ }
1685
+ }
1686
+ async function writeManifest(rootDir, manifest) {
1687
+ const manifestPath = path.join(rootDir, UNIFIED_DIR, MANIFEST_FILENAME);
1688
+ const content = JSON.stringify(manifest, null, 2) + "\n";
1689
+ await fs4.writeFile(manifestPath, content, "utf-8");
1690
+ }
1691
+ function buildToolManifest(toolId, files) {
1692
+ const entries = files.map((file) => {
1693
+ const entry = {
1694
+ path: file.path,
1695
+ type: file.type
1696
+ };
1697
+ if (file.type === "symlink") {
1698
+ entry.target = file.target;
1699
+ } else {
1700
+ const content = file.type === "json" ? JSON.stringify(file.content, null, 2) + "\n" : String(file.content);
1701
+ entry.hash = computeHash(content);
1702
+ }
1703
+ return entry;
1704
+ });
1705
+ return {
1706
+ version: 1,
1707
+ tool: toolId,
1708
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1709
+ files: entries
1710
+ };
1711
+ }
1712
+ function updateToolManifest(manifest, toolId, files) {
1713
+ return {
1714
+ ...manifest,
1715
+ tools: {
1716
+ ...manifest.tools,
1717
+ [toolId]: buildToolManifest(toolId, files)
1718
+ }
1719
+ };
1720
+ }
1721
+ function createEmptyManifest() {
1722
+ return {
1723
+ version: 1,
1724
+ tools: {}
1725
+ };
1426
1726
  }
1427
1727
 
1428
1728
  // src/pipeline/index.ts
@@ -1444,7 +1744,12 @@ function getToolsToSync(config, requestedTools) {
1444
1744
  return enabledTools;
1445
1745
  }
1446
1746
  async function runSyncPipeline(options) {
1447
- const { rootDir, dryRun = false, tools: requestedTools } = options;
1747
+ const {
1748
+ rootDir,
1749
+ dryRun = false,
1750
+ skipCleanup = false,
1751
+ tools: requestedTools
1752
+ } = options;
1448
1753
  if (requestedTools && requestedTools.length > 0) {
1449
1754
  const toolValidation = validateToolIds(requestedTools);
1450
1755
  if (!toolValidation.valid) {
@@ -1470,6 +1775,7 @@ async function runSyncPipeline(options) {
1470
1775
  if (toolsToSync.length === 0) {
1471
1776
  return [];
1472
1777
  }
1778
+ let manifest = await readManifest(rootDir) ?? createEmptyManifest();
1473
1779
  const results = [];
1474
1780
  const pathsToIgnore = [];
1475
1781
  for (const toolId of toolsToSync) {
@@ -1479,17 +1785,32 @@ async function runSyncPipeline(options) {
1479
1785
  }
1480
1786
  const validation = plugin.validate(state);
1481
1787
  const outputFiles = await plugin.export(state, rootDir);
1482
- const changes = await writeFiles(outputFiles, { rootDir, dryRun });
1788
+ let deleteChanges = [];
1789
+ if (!skipCleanup && manifest.tools[toolId]) {
1790
+ const toDelete = computeFilesToDelete(
1791
+ manifest.tools[toolId].files,
1792
+ outputFiles
1793
+ );
1794
+ deleteChanges = await deleteFiles(toDelete, rootDir, dryRun);
1795
+ }
1796
+ const writeChanges = await writeFiles(outputFiles, { rootDir, dryRun });
1797
+ const changes = [...deleteChanges, ...writeChanges];
1483
1798
  results.push({
1484
1799
  tool: toolId,
1485
1800
  changes,
1486
1801
  validation
1487
1802
  });
1803
+ if (!dryRun) {
1804
+ manifest = updateToolManifest(manifest, toolId, outputFiles);
1805
+ }
1488
1806
  const toolConfig = state.config.tools?.[toolId];
1489
1807
  if (!toolConfig?.versionControl) {
1490
1808
  pathsToIgnore.push(...outputFiles.map((f) => f.path));
1491
1809
  }
1492
1810
  }
1811
+ if (!dryRun) {
1812
+ await writeManifest(rootDir, manifest);
1813
+ }
1493
1814
  if (pathsToIgnore.length > 0 && !dryRun) {
1494
1815
  await updateGitignore(rootDir, pathsToIgnore);
1495
1816
  }
@@ -1515,12 +1836,12 @@ async function initUnifiedConfig(options) {
1515
1836
  );
1516
1837
  }
1517
1838
  if (exists) {
1518
- await fs3.rm(aiDir, { recursive: true, force: true });
1839
+ await fs4.rm(aiDir, { recursive: true, force: true });
1519
1840
  }
1520
- await fs3.mkdir(aiDir, { recursive: true });
1841
+ await fs4.mkdir(aiDir, { recursive: true });
1521
1842
  created.push(UNIFIED_DIR);
1522
1843
  const configPath = path.join(aiDir, CONFIG_FILES.config);
1523
- await fs3.writeFile(
1844
+ await fs4.writeFile(
1524
1845
  configPath,
1525
1846
  JSON.stringify(config, null, 2) + "\n",
1526
1847
  "utf-8"
@@ -1529,10 +1850,10 @@ async function initUnifiedConfig(options) {
1529
1850
  if (!minimal) {
1530
1851
  for (const dir of [CONFIG_DIRS.rules, CONFIG_DIRS.skills]) {
1531
1852
  const dirPath = path.join(aiDir, dir);
1532
- await fs3.mkdir(dirPath, { recursive: true });
1853
+ await fs4.mkdir(dirPath, { recursive: true });
1533
1854
  created.push(path.join(UNIFIED_DIR, dir));
1534
1855
  const gitkeepPath = path.join(dirPath, ".gitkeep");
1535
- await fs3.writeFile(gitkeepPath, "", "utf-8");
1856
+ await fs4.writeFile(gitkeepPath, "", "utf-8");
1536
1857
  created.push(path.join(UNIFIED_DIR, dir, ".gitkeep"));
1537
1858
  }
1538
1859
  }
@@ -1541,7 +1862,7 @@ async function initUnifiedConfig(options) {
1541
1862
  async function hasUnifiedConfig(rootDir) {
1542
1863
  const aiDir = path.join(rootDir, UNIFIED_DIR);
1543
1864
  try {
1544
- const stats = await fs3.stat(aiDir);
1865
+ const stats = await fs4.stat(aiDir);
1545
1866
  return stats.isDirectory();
1546
1867
  } catch {
1547
1868
  return false;
@@ -1570,4 +1891,4 @@ function generateDefaultConfig(tools, versionControl) {
1570
1891
  return { tools: toolsConfig };
1571
1892
  }
1572
1893
 
1573
- export { CONFIG_DIRS, CONFIG_FILES, FileNotFoundError, LnaiError, ParseError, PluginError, TOOL_IDS, TOOL_OUTPUT_DIRS, UNIFIED_DIR, ValidationError, WriteError, claudeCodePlugin, computeHash, configSchema, generateDefaultConfig, hasUnifiedConfig, initUnifiedConfig, mcpServerSchema, opencodePlugin, parseFrontmatter, parseUnifiedConfig, permissionsSchema, pluginRegistry, ruleFrontmatterSchema, runSyncPipeline, settingsSchema, skillFrontmatterSchema, toolConfigSchema, toolIdSchema, updateGitignore, validateConfig, validateSettings, validateUnifiedState, writeFiles };
1894
+ export { CONFIG_DIRS, CONFIG_FILES, FileNotFoundError, InvalidToolError, LnaiError, MANIFEST_FILENAME, ParseError, PluginError, TOOL_IDS, TOOL_OUTPUT_DIRS, UNIFIED_DIR, ValidationError, WriteError, buildToolManifest, claudeCodePlugin, cleanupEmptyParentDirs, codexPlugin, computeFilesToDelete, computeHash, configSchema, createEmptyManifest, deleteFiles, generateDefaultConfig, hasUnifiedConfig, initUnifiedConfig, mcpServerSchema, opencodePlugin, parseFrontmatter, parseUnifiedConfig, permissionsSchema, pluginRegistry, readManifest, ruleFrontmatterSchema, runSyncPipeline, settingsSchema, skillFrontmatterSchema, toolConfigSchema, toolIdSchema, updateGitignore, updateToolManifest, validateConfig, validateSettings, validateUnifiedState, writeFiles, writeManifest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lnai/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.5",
4
4
  "description": "Core library for LNAI - unified AI config management",
5
5
  "type": "module",
6
6
  "license": "MIT",