@lnai/core 0.6.0 → 0.6.6

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
@@ -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<{
@@ -202,6 +206,25 @@ interface SyncResult {
202
206
  changes: ChangeResult[];
203
207
  validation: ValidationResult;
204
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
+ }
205
228
 
206
229
  declare function parseFrontmatter(content: string): {
207
230
  frontmatter: Record<string, unknown>;
@@ -260,7 +283,7 @@ declare const claudeCodePlugin: Plugin;
260
283
  * Output structure:
261
284
  * - AGENTS.md (symlink -> .ai/AGENTS.md) [at project root]
262
285
  * - <dir>/AGENTS.md (generated from .ai/rules/*.md, per glob directory)
263
- * - .codex/skills/<name>/ (symlink -> ../../.ai/skills/<name>)
286
+ * - .agents/skills/<name>/ (symlink -> ../../.ai/skills/<name>)
264
287
  * - .codex/config.toml (generated from settings.mcpServers)
265
288
  * - .codex/<path> (symlink -> ../.ai/.codex/<path>) for override files
266
289
  */
@@ -272,7 +295,7 @@ declare const codexPlugin: Plugin;
272
295
  * Output structure:
273
296
  * - AGENTS.md (symlink -> .ai/AGENTS.md) [at project root]
274
297
  * - .opencode/rules/ (symlink -> ../.ai/rules)
275
- * - .opencode/skills/<name>/ (symlink -> ../../.ai/skills/<name>)
298
+ * - .agents/skills/<name>/ (symlink -> ../../.ai/skills/<name>)
276
299
  * - opencode.json (generated config merged with .ai/.opencode/opencode.json)
277
300
  * - .opencode/<path> (symlink -> ../.ai/.opencode/<path>) for other override files
278
301
  */
@@ -298,8 +321,8 @@ interface SyncOptions {
298
321
  tools?: ToolId[];
299
322
  /** Preview changes without writing files */
300
323
  dryRun?: boolean;
301
- /** Enable verbose output */
302
- verbose?: boolean;
324
+ /** Skip cleanup of orphaned files */
325
+ skipCleanup?: boolean;
303
326
  }
304
327
  /**
305
328
  * Run the sync pipeline to export .ai/ config to native tool formats.
@@ -320,9 +343,50 @@ declare function writeFiles(files: OutputFile[], options: WriterOptions): Promis
320
343
  /**
321
344
  * Update .gitignore with paths that should not be version controlled.
322
345
  * Manages a dedicated "lnai-generated" section to avoid conflicts with user entries.
346
+ * Replaces the managed section on each run so stale paths are removed.
323
347
  */
324
348
  declare function updateGitignore(rootDir: string, paths: string[]): Promise<void>;
325
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
+
326
390
  interface InitOptions {
327
391
  rootDir: string;
328
392
  tools?: ToolId[];
@@ -337,4 +401,4 @@ declare function initUnifiedConfig(options: InitOptions): Promise<InitResult>;
337
401
  declare function hasUnifiedConfig(rootDir: string): Promise<boolean>;
338
402
  declare function generateDefaultConfig(tools?: ToolId[], versionControl?: Record<ToolId, boolean>): Config;
339
403
 
340
- 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, codexPlugin, 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';
@@ -67,10 +67,10 @@ var ParseError = class extends LnaiError {
67
67
  var ValidationError = class extends LnaiError {
68
68
  path;
69
69
  value;
70
- constructor(message, path6, value) {
70
+ constructor(message, path8, value) {
71
71
  super(message, "VALIDATION_ERROR");
72
72
  this.name = "ValidationError";
73
- this.path = path6;
73
+ this.path = path8;
74
74
  this.value = value;
75
75
  }
76
76
  };
@@ -104,6 +104,17 @@ var PluginError = class extends LnaiError {
104
104
  }
105
105
  }
106
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
+ };
107
118
  var mcpServerSchema = z.object({
108
119
  command: z.string().optional(),
109
120
  args: z.array(z.string()).optional(),
@@ -204,7 +215,7 @@ async function parseUnifiedConfig(rootDir) {
204
215
  }
205
216
  async function fileExists(filePath) {
206
217
  try {
207
- await fs3.access(filePath);
218
+ await fs4.access(filePath);
208
219
  return true;
209
220
  } catch {
210
221
  return false;
@@ -212,7 +223,7 @@ async function fileExists(filePath) {
212
223
  }
213
224
  async function readJsonFile(filePath) {
214
225
  try {
215
- const content = await fs3.readFile(filePath, "utf-8");
226
+ const content = await fs4.readFile(filePath, "utf-8");
216
227
  return JSON.parse(content);
217
228
  } catch (error) {
218
229
  if (error.code === "ENOENT") {
@@ -227,7 +238,7 @@ async function readJsonFile(filePath) {
227
238
  }
228
239
  async function readMarkdownFile(filePath) {
229
240
  try {
230
- return await fs3.readFile(filePath, "utf-8");
241
+ return await fs4.readFile(filePath, "utf-8");
231
242
  } catch (error) {
232
243
  if (error.code === "ENOENT") {
233
244
  throw new FileNotFoundError(`File not found: ${filePath}`, filePath);
@@ -244,7 +255,7 @@ async function readMarkdownDirectory(dirPath) {
244
255
  if (!await fileExists(dirPath)) {
245
256
  return files;
246
257
  }
247
- const entries = await fs3.readdir(dirPath, { withFileTypes: true });
258
+ const entries = await fs4.readdir(dirPath, { withFileTypes: true });
248
259
  for (const entry of entries) {
249
260
  if (entry.isFile() && entry.name.endsWith(".md")) {
250
261
  const filePath = path.join(dirPath, entry.name);
@@ -264,7 +275,7 @@ async function readSkillsDirectory(dirPath) {
264
275
  if (!await fileExists(dirPath)) {
265
276
  return skills;
266
277
  }
267
- const entries = await fs3.readdir(dirPath, { withFileTypes: true });
278
+ const entries = await fs4.readdir(dirPath, { withFileTypes: true });
268
279
  for (const entry of entries) {
269
280
  if (entry.isDirectory()) {
270
281
  const skillFile = path.join(dirPath, entry.name, "SKILL.md");
@@ -402,10 +413,43 @@ function validateToolIds(tools) {
402
413
  }
403
414
  return { valid: true, errors: [], warnings: [], skipped: [] };
404
415
  }
416
+
417
+ // src/utils/agents.ts
418
+ function createRootAgentsMdSymlink(state) {
419
+ if (!state.agents) {
420
+ return null;
421
+ }
422
+ return {
423
+ path: "AGENTS.md",
424
+ type: "symlink",
425
+ target: `${UNIFIED_DIR}/AGENTS.md`
426
+ };
427
+ }
428
+ function createSkillSymlinks(state, outputDir) {
429
+ const depth = outputDir.split("/").length + 1;
430
+ const prefix = "../".repeat(depth);
431
+ return state.skills.map((skill) => ({
432
+ path: `${outputDir}/skills/${skill.path}`,
433
+ type: "symlink",
434
+ target: `${prefix}${UNIFIED_DIR}/skills/${skill.path}`
435
+ }));
436
+ }
437
+ function createNoAgentsMdWarning(outputDescription) {
438
+ return {
439
+ path: ["AGENTS.md"],
440
+ message: `No AGENTS.md found - ${outputDescription} will not be created`
441
+ };
442
+ }
443
+ function hasPermissionsConfigured(permissions) {
444
+ if (!permissions) {
445
+ return false;
446
+ }
447
+ return (permissions.allow?.length ?? 0) > 0 || (permissions.ask?.length ?? 0) > 0 || (permissions.deny?.length ?? 0) > 0;
448
+ }
405
449
  async function scanOverrideDirectory(rootDir, toolId) {
406
450
  const overrideDir = path.join(rootDir, UNIFIED_DIR, OVERRIDE_DIRS[toolId]);
407
451
  try {
408
- await fs3.access(overrideDir);
452
+ await fs4.access(overrideDir);
409
453
  } catch {
410
454
  return [];
411
455
  }
@@ -414,7 +458,7 @@ async function scanOverrideDirectory(rootDir, toolId) {
414
458
  return files;
415
459
  }
416
460
  async function scanDir(baseDir, currentDir, files) {
417
- const entries = await fs3.readdir(currentDir, { withFileTypes: true });
461
+ const entries = await fs4.readdir(currentDir, { withFileTypes: true });
418
462
  for (const entry of entries) {
419
463
  const absolutePath = path.join(currentDir, entry.name);
420
464
  if (entry.isDirectory()) {
@@ -446,6 +490,7 @@ async function applyFileOverrides(files, rootDir, toolId) {
446
490
  }
447
491
 
448
492
  // src/plugins/claude-code/index.ts
493
+ var OUTPUT_DIR = TOOL_OUTPUT_DIRS.claudeCode;
449
494
  var claudeCodePlugin = {
450
495
  id: "claudeCode",
451
496
  name: "Claude Code",
@@ -457,28 +502,21 @@ var claudeCodePlugin = {
457
502
  },
458
503
  async export(state, rootDir) {
459
504
  const files = [];
460
- const outputDir = TOOL_OUTPUT_DIRS.claudeCode;
461
505
  if (state.agents) {
462
506
  files.push({
463
- path: `${outputDir}/CLAUDE.md`,
507
+ path: `${OUTPUT_DIR}/CLAUDE.md`,
464
508
  type: "symlink",
465
509
  target: `../${UNIFIED_DIR}/AGENTS.md`
466
510
  });
467
511
  }
468
512
  if (state.rules.length > 0) {
469
513
  files.push({
470
- path: `${outputDir}/rules`,
514
+ path: `${OUTPUT_DIR}/rules`,
471
515
  type: "symlink",
472
516
  target: `../${UNIFIED_DIR}/rules`
473
517
  });
474
518
  }
475
- for (const skill of state.skills) {
476
- files.push({
477
- path: `${outputDir}/skills/${skill.path}`,
478
- type: "symlink",
479
- target: `../../${UNIFIED_DIR}/skills/${skill.path}`
480
- });
481
- }
519
+ files.push(...createSkillSymlinks(state, OUTPUT_DIR));
482
520
  const settings = {};
483
521
  if (state.settings?.permissions) {
484
522
  settings["permissions"] = state.settings.permissions;
@@ -488,7 +526,7 @@ var claudeCodePlugin = {
488
526
  }
489
527
  if (Object.keys(settings).length > 0) {
490
528
  files.push({
491
- path: `${outputDir}/settings.json`,
529
+ path: `${OUTPUT_DIR}/settings.json`,
492
530
  type: "json",
493
531
  content: settings
494
532
  });
@@ -498,10 +536,7 @@ var claudeCodePlugin = {
498
536
  validate(state) {
499
537
  const warnings = [];
500
538
  if (!state.agents) {
501
- warnings.push({
502
- path: ["AGENTS.md"],
503
- message: "No AGENTS.md found - .claude/CLAUDE.md will not be created"
504
- });
539
+ warnings.push(createNoAgentsMdWarning(".claude/CLAUDE.md"));
505
540
  }
506
541
  return { valid: true, errors: [], warnings, skipped: [] };
507
542
  }
@@ -510,8 +545,8 @@ function getDirFromGlob(glob) {
510
545
  const cleanPath = glob.replace(/(\*\*|\*|\{.*,.*\}).*$/, "");
511
546
  const dir = cleanPath.replace(/\/$/, "");
512
547
  if (dir === glob) {
513
- const dirname4 = path.dirname(dir);
514
- return dirname4 === "." && !dir.includes("/") ? "." : dirname4;
548
+ const dirname5 = path.dirname(dir);
549
+ return dirname5 === "." && !dir.includes("/") ? "." : dirname5;
515
550
  }
516
551
  if (!dir) {
517
552
  return ".";
@@ -543,6 +578,8 @@ ${rule.content}
543
578
  }
544
579
 
545
580
  // src/plugins/codex/index.ts
581
+ var OUTPUT_DIR2 = TOOL_OUTPUT_DIRS.codex;
582
+ var SKILLS_DIR = ".agents";
546
583
  var codexPlugin = {
547
584
  id: "codex",
548
585
  name: "Codex",
@@ -554,13 +591,9 @@ var codexPlugin = {
554
591
  },
555
592
  async export(state, rootDir) {
556
593
  const files = [];
557
- const outputDir = TOOL_OUTPUT_DIRS.codex;
558
- if (state.agents) {
559
- files.push({
560
- path: "AGENTS.md",
561
- type: "symlink",
562
- target: `${UNIFIED_DIR}/AGENTS.md`
563
- });
594
+ const agentsSymlink = createRootAgentsMdSymlink(state);
595
+ if (agentsSymlink) {
596
+ files.push(agentsSymlink);
564
597
  }
565
598
  const rulesMap = groupRulesByDirectory(state.rules);
566
599
  for (const [dir, contents] of rulesMap.entries()) {
@@ -574,17 +607,11 @@ var codexPlugin = {
574
607
  content: combinedContent
575
608
  });
576
609
  }
577
- for (const skill of state.skills) {
578
- files.push({
579
- path: `${outputDir}/skills/${skill.path}`,
580
- type: "symlink",
581
- target: `../../${UNIFIED_DIR}/skills/${skill.path}`
582
- });
583
- }
610
+ files.push(...createSkillSymlinks(state, SKILLS_DIR));
584
611
  const configToml = buildCodexConfigToml(state.settings?.mcpServers);
585
612
  if (configToml) {
586
613
  files.push({
587
- path: `${outputDir}/config.toml`,
614
+ path: `${OUTPUT_DIR2}/config.toml`,
588
615
  type: "text",
589
616
  content: configToml
590
617
  });
@@ -595,10 +622,7 @@ var codexPlugin = {
595
622
  const warnings = [];
596
623
  const skipped = [];
597
624
  if (!state.agents) {
598
- warnings.push({
599
- path: ["AGENTS.md"],
600
- message: "No AGENTS.md found - root AGENTS.md will not be created"
601
- });
625
+ warnings.push(createNoAgentsMdWarning("root AGENTS.md"));
602
626
  }
603
627
  const rulesMap = groupRulesByDirectory(state.rules);
604
628
  if (rulesMap.has(".")) {
@@ -618,14 +642,11 @@ var codexPlugin = {
618
642
  }
619
643
  }
620
644
  }
621
- if (state.settings?.permissions) {
622
- const hasPermissions = (state.settings.permissions.allow?.length ?? 0) > 0 || (state.settings.permissions.ask?.length ?? 0) > 0 || (state.settings.permissions.deny?.length ?? 0) > 0;
623
- if (hasPermissions) {
624
- skipped.push({
625
- feature: "permissions",
626
- reason: "Codex rules are not generated from LNAI permissions"
627
- });
628
- }
645
+ if (hasPermissionsConfigured(state.settings?.permissions)) {
646
+ skipped.push({
647
+ feature: "permissions",
648
+ reason: "Codex rules are not generated from LNAI permissions"
649
+ });
629
650
  }
630
651
  return { valid: true, errors: [], warnings, skipped };
631
652
  }
@@ -684,6 +705,32 @@ function formatTomlInlineTable(values) {
684
705
  return `{ ${entries.join(", ")} }`;
685
706
  }
686
707
 
708
+ // src/utils/mcp.ts
709
+ function validateMcpServers(mcpServers, pathPrefix) {
710
+ const warnings = [];
711
+ if (!mcpServers) {
712
+ return warnings;
713
+ }
714
+ for (const [name, server] of Object.entries(mcpServers)) {
715
+ const isRemote = server.type === "http" || server.type === "sse";
716
+ const hasCommand = !!server.command;
717
+ const hasUrl = !!server.url;
718
+ if (!isRemote && !hasCommand) {
719
+ warnings.push({
720
+ path: [...pathPrefix, name],
721
+ message: `MCP server "${name}" has no command or type - it will be skipped`
722
+ });
723
+ }
724
+ if (isRemote && !hasUrl) {
725
+ warnings.push({
726
+ path: [...pathPrefix, name],
727
+ message: `MCP server "${name}" is remote but has no URL - it will be skipped`
728
+ });
729
+ }
730
+ }
731
+ return warnings;
732
+ }
733
+
687
734
  // src/utils/transforms.ts
688
735
  var ENV_VAR_PATTERN = /\$\{([^}:]+)(:-[^}]*)?\}/g;
689
736
  function transformEnvVar(value, format) {
@@ -789,6 +836,7 @@ function transformMcpToCopilot(servers) {
789
836
  }
790
837
 
791
838
  // src/plugins/copilot/index.ts
839
+ var OUTPUT_DIR3 = TOOL_OUTPUT_DIRS.copilot;
792
840
  var copilotPlugin = {
793
841
  id: "copilot",
794
842
  name: "GitHub Copilot",
@@ -800,12 +848,9 @@ var copilotPlugin = {
800
848
  },
801
849
  async export(state, rootDir) {
802
850
  const files = [];
803
- if (state.agents) {
804
- files.push({
805
- path: ".github/copilot-instructions.md",
806
- type: "symlink",
807
- target: `../${UNIFIED_DIR}/AGENTS.md`
808
- });
851
+ const agentsSymlink = createRootAgentsMdSymlink(state);
852
+ if (agentsSymlink) {
853
+ files.push(agentsSymlink);
809
854
  }
810
855
  for (const rule of state.rules) {
811
856
  const transformed = transformRuleToCopilot(rule);
@@ -815,18 +860,12 @@ var copilotPlugin = {
815
860
  );
816
861
  const outputFilename = rule.path.replace(/\.md$/, ".instructions.md");
817
862
  files.push({
818
- path: `.github/instructions/${outputFilename}`,
863
+ path: `${OUTPUT_DIR3}/instructions/${outputFilename}`,
819
864
  type: "text",
820
865
  content: ruleContent
821
866
  });
822
867
  }
823
- for (const skill of state.skills) {
824
- files.push({
825
- path: `.github/skills/${skill.path}`,
826
- type: "symlink",
827
- target: `../../${UNIFIED_DIR}/skills/${skill.path}`
828
- });
829
- }
868
+ files.push(...createSkillSymlinks(state, OUTPUT_DIR3));
830
869
  const mcpConfig = transformMcpToCopilot(state.settings?.mcpServers);
831
870
  if (mcpConfig) {
832
871
  files.push({
@@ -841,37 +880,20 @@ var copilotPlugin = {
841
880
  const warnings = [];
842
881
  const skipped = [];
843
882
  if (!state.agents) {
844
- warnings.push({
845
- path: ["AGENTS.md"],
846
- message: "No AGENTS.md found - .github/copilot-instructions.md will not be created"
847
- });
883
+ warnings.push(createNoAgentsMdWarning("root AGENTS.md"));
848
884
  }
849
- const permissions = state.settings?.permissions;
850
- const hasPermissions = permissions && (permissions.allow && permissions.allow.length > 0 || permissions.ask && permissions.ask.length > 0 || permissions.deny && permissions.deny.length > 0);
851
- if (hasPermissions) {
885
+ if (hasPermissionsConfigured(state.settings?.permissions)) {
852
886
  skipped.push({
853
887
  feature: "permissions",
854
888
  reason: "GitHub Copilot does not support declarative permissions"
855
889
  });
856
890
  }
857
- const mcpServers = state.settings?.mcpServers;
858
- if (mcpServers) {
859
- for (const [name, server] of Object.entries(mcpServers)) {
860
- const isRemote = server.type === "http" || server.type === "sse";
861
- const hasCommand = !!server.command;
862
- if (isRemote && !server.url) {
863
- warnings.push({
864
- path: ["settings", "mcpServers", name],
865
- message: `MCP server "${name}" is type "${server.type}" but has no url - it will be skipped`
866
- });
867
- } else if (!isRemote && !hasCommand) {
868
- warnings.push({
869
- path: ["settings", "mcpServers", name],
870
- message: `MCP server "${name}" has no command or type - it will be skipped`
871
- });
872
- }
873
- }
874
- }
891
+ warnings.push(
892
+ ...validateMcpServers(state.settings?.mcpServers, [
893
+ "settings",
894
+ "mcpServers"
895
+ ])
896
+ );
875
897
  return { valid: true, errors: [], warnings, skipped };
876
898
  }
877
899
  };
@@ -992,6 +1014,7 @@ function transformPermissionRule(rule) {
992
1014
  }
993
1015
 
994
1016
  // src/plugins/cursor/index.ts
1017
+ var OUTPUT_DIR4 = TOOL_OUTPUT_DIRS.cursor;
995
1018
  var cursorPlugin = {
996
1019
  id: "cursor",
997
1020
  name: "Cursor",
@@ -1003,13 +1026,9 @@ var cursorPlugin = {
1003
1026
  },
1004
1027
  async export(state, rootDir) {
1005
1028
  const files = [];
1006
- const outputDir = TOOL_OUTPUT_DIRS.cursor;
1007
- if (state.agents) {
1008
- files.push({
1009
- path: "AGENTS.md",
1010
- type: "symlink",
1011
- target: `${UNIFIED_DIR}/AGENTS.md`
1012
- });
1029
+ const agentsSymlink = createRootAgentsMdSymlink(state);
1030
+ if (agentsSymlink) {
1031
+ files.push(agentsSymlink);
1013
1032
  }
1014
1033
  for (const rule of state.rules) {
1015
1034
  const transformed = transformRuleToCursor(rule);
@@ -1019,22 +1038,16 @@ var cursorPlugin = {
1019
1038
  );
1020
1039
  const outputFilename = rule.path.replace(/\.md$/, ".mdc");
1021
1040
  files.push({
1022
- path: `${outputDir}/rules/${outputFilename}`,
1041
+ path: `${OUTPUT_DIR4}/rules/${outputFilename}`,
1023
1042
  type: "text",
1024
1043
  content: ruleContent
1025
1044
  });
1026
1045
  }
1027
- for (const skill of state.skills) {
1028
- files.push({
1029
- path: `${outputDir}/skills/${skill.path}`,
1030
- type: "symlink",
1031
- target: `../../${UNIFIED_DIR}/skills/${skill.path}`
1032
- });
1033
- }
1046
+ files.push(...createSkillSymlinks(state, OUTPUT_DIR4));
1034
1047
  const mcpServers = transformMcpToCursor(state.settings?.mcpServers);
1035
1048
  if (mcpServers) {
1036
1049
  files.push({
1037
- path: `${outputDir}/mcp.json`,
1050
+ path: `${OUTPUT_DIR4}/mcp.json`,
1038
1051
  type: "json",
1039
1052
  content: { mcpServers }
1040
1053
  });
@@ -1042,7 +1055,7 @@ var cursorPlugin = {
1042
1055
  const cliContent = buildCliContent(state.settings?.permissions);
1043
1056
  if (cliContent) {
1044
1057
  files.push({
1045
- path: `${outputDir}/cli.json`,
1058
+ path: `${OUTPUT_DIR4}/cli.json`,
1046
1059
  type: "json",
1047
1060
  content: cliContent
1048
1061
  });
@@ -1052,10 +1065,7 @@ var cursorPlugin = {
1052
1065
  validate(state) {
1053
1066
  const warnings = [];
1054
1067
  if (!state.agents) {
1055
- warnings.push({
1056
- path: ["AGENTS.md"],
1057
- message: "No AGENTS.md found - root AGENTS.md will not be created"
1058
- });
1068
+ warnings.push(createNoAgentsMdWarning("root AGENTS.md"));
1059
1069
  }
1060
1070
  const permissionsResult = transformPermissionsToCursor(
1061
1071
  state.settings?.permissions
@@ -1066,19 +1076,12 @@ var cursorPlugin = {
1066
1076
  message: 'Cursor does not support "ask" permission level - these rules will be treated as "allow"'
1067
1077
  });
1068
1078
  }
1069
- const mcpServers = state.settings?.mcpServers;
1070
- if (mcpServers) {
1071
- for (const [name, server] of Object.entries(mcpServers)) {
1072
- const isRemote = server.type === "http" || server.type === "sse";
1073
- const hasCommand = !!server.command;
1074
- if (!isRemote && !hasCommand) {
1075
- warnings.push({
1076
- path: ["settings", "mcpServers", name],
1077
- message: `MCP server "${name}" has no command or type - it will be skipped`
1078
- });
1079
- }
1080
- }
1081
- }
1079
+ warnings.push(
1080
+ ...validateMcpServers(state.settings?.mcpServers, [
1081
+ "settings",
1082
+ "mcpServers"
1083
+ ])
1084
+ );
1082
1085
  return { valid: true, errors: [], warnings, skipped: [] };
1083
1086
  }
1084
1087
  };
@@ -1113,6 +1116,7 @@ function transformMcpToGemini(mcpServers) {
1113
1116
  }
1114
1117
 
1115
1118
  // src/plugins/gemini/index.ts
1119
+ var OUTPUT_DIR5 = TOOL_OUTPUT_DIRS.gemini;
1116
1120
  var geminiPlugin = {
1117
1121
  id: "gemini",
1118
1122
  name: "Gemini CLI",
@@ -1124,13 +1128,9 @@ var geminiPlugin = {
1124
1128
  },
1125
1129
  async export(state, rootDir) {
1126
1130
  const files = [];
1127
- const outputDir = TOOL_OUTPUT_DIRS.gemini;
1128
- if (state.agents) {
1129
- files.push({
1130
- path: `${outputDir}/GEMINI.md`,
1131
- type: "symlink",
1132
- target: `../${UNIFIED_DIR}/AGENTS.md`
1133
- });
1131
+ const agentsSymlink = createRootAgentsMdSymlink(state);
1132
+ if (agentsSymlink) {
1133
+ files.push(agentsSymlink);
1134
1134
  }
1135
1135
  const rulesMap = groupRulesByDirectory(state.rules);
1136
1136
  for (const [dir, contents] of rulesMap.entries()) {
@@ -1142,19 +1142,21 @@ var geminiPlugin = {
1142
1142
  content: combinedContent
1143
1143
  });
1144
1144
  }
1145
- for (const skill of state.skills) {
1146
- files.push({
1147
- path: `${outputDir}/skills/${skill.path}`,
1148
- type: "symlink",
1149
- target: `../../${UNIFIED_DIR}/skills/${skill.path}`
1150
- });
1151
- }
1145
+ files.push(...createSkillSymlinks(state, OUTPUT_DIR5));
1152
1146
  const mcpServers = transformMcpToGemini(state.settings?.mcpServers);
1153
- if (mcpServers) {
1147
+ const hasAgents = !!state.agents;
1148
+ if (mcpServers || hasAgents) {
1149
+ const settingsContent = {};
1150
+ if (hasAgents) {
1151
+ settingsContent["context"] = { fileName: ["AGENTS.md"] };
1152
+ }
1153
+ if (mcpServers) {
1154
+ settingsContent["mcpServers"] = mcpServers;
1155
+ }
1154
1156
  files.push({
1155
- path: `${outputDir}/settings.json`,
1157
+ path: `${OUTPUT_DIR5}/settings.json`,
1156
1158
  type: "json",
1157
- content: { mcpServers }
1159
+ content: settingsContent
1158
1160
  });
1159
1161
  }
1160
1162
  return applyFileOverrides(files, rootDir, "gemini");
@@ -1163,19 +1165,13 @@ var geminiPlugin = {
1163
1165
  const warnings = [];
1164
1166
  const skipped = [];
1165
1167
  if (!state.agents) {
1166
- warnings.push({
1167
- path: ["AGENTS.md"],
1168
- message: "No AGENTS.md found - GEMINI.md will not be created"
1169
- });
1168
+ warnings.push(createNoAgentsMdWarning("root AGENTS.md"));
1170
1169
  }
1171
- if (state.settings?.permissions) {
1172
- const hasPermissions = (state.settings.permissions.allow?.length ?? 0) > 0 || (state.settings.permissions.ask?.length ?? 0) > 0 || (state.settings.permissions.deny?.length ?? 0) > 0;
1173
- if (hasPermissions) {
1174
- skipped.push({
1175
- feature: "permissions",
1176
- reason: "Gemini CLI does not support declarative permissions - permissions must be granted interactively"
1177
- });
1178
- }
1170
+ if (hasPermissionsConfigured(state.settings?.permissions)) {
1171
+ skipped.push({
1172
+ feature: "permissions",
1173
+ reason: "Gemini CLI does not support declarative permissions - permissions must be granted interactively"
1174
+ });
1179
1175
  }
1180
1176
  if (state.rules.length > 0) {
1181
1177
  warnings.push({
@@ -1260,6 +1256,8 @@ function parsePermissionRuleForOpenCode(rule) {
1260
1256
  }
1261
1257
 
1262
1258
  // src/plugins/opencode/index.ts
1259
+ var OUTPUT_DIR6 = TOOL_OUTPUT_DIRS.opencode;
1260
+ var SKILLS_DIR2 = ".agents";
1263
1261
  var opencodePlugin = {
1264
1262
  id: "opencode",
1265
1263
  name: "OpenCode",
@@ -1271,33 +1269,23 @@ var opencodePlugin = {
1271
1269
  },
1272
1270
  async export(state, rootDir) {
1273
1271
  const files = [];
1274
- const outputDir = TOOL_OUTPUT_DIRS.opencode;
1275
- if (state.agents) {
1276
- files.push({
1277
- path: "AGENTS.md",
1278
- type: "symlink",
1279
- target: `${UNIFIED_DIR}/AGENTS.md`
1280
- });
1272
+ const agentsSymlink = createRootAgentsMdSymlink(state);
1273
+ if (agentsSymlink) {
1274
+ files.push(agentsSymlink);
1281
1275
  }
1282
1276
  if (state.rules.length > 0) {
1283
1277
  files.push({
1284
- path: `${outputDir}/rules`,
1278
+ path: `${OUTPUT_DIR6}/rules`,
1285
1279
  type: "symlink",
1286
1280
  target: `../${UNIFIED_DIR}/rules`
1287
1281
  });
1288
1282
  }
1289
- for (const skill of state.skills) {
1290
- files.push({
1291
- path: `${outputDir}/skills/${skill.path}`,
1292
- type: "symlink",
1293
- target: `../../${UNIFIED_DIR}/skills/${skill.path}`
1294
- });
1295
- }
1283
+ files.push(...createSkillSymlinks(state, SKILLS_DIR2));
1296
1284
  const config = {
1297
1285
  $schema: "https://opencode.ai/config.json"
1298
1286
  };
1299
1287
  if (state.rules.length > 0) {
1300
- config["instructions"] = [`${outputDir}/rules/*.md`];
1288
+ config["instructions"] = [`${OUTPUT_DIR6}/rules/*.md`];
1301
1289
  }
1302
1290
  const mcp = transformMcpToOpenCode(state.settings?.mcpServers);
1303
1291
  if (mcp) {
@@ -1319,11 +1307,14 @@ var opencodePlugin = {
1319
1307
  validate(state) {
1320
1308
  const warnings = [];
1321
1309
  if (!state.agents) {
1322
- warnings.push({
1323
- path: ["AGENTS.md"],
1324
- message: "No AGENTS.md found - root AGENTS.md will not be created"
1325
- });
1310
+ warnings.push(createNoAgentsMdWarning("root AGENTS.md"));
1326
1311
  }
1312
+ warnings.push(
1313
+ ...validateMcpServers(state.settings?.mcpServers, [
1314
+ "settings",
1315
+ "mcpServers"
1316
+ ])
1317
+ );
1327
1318
  return { valid: true, errors: [], warnings, skipped: [] };
1328
1319
  }
1329
1320
  };
@@ -1379,6 +1370,7 @@ function serializeWindsurfRule(frontmatter, content) {
1379
1370
  }
1380
1371
 
1381
1372
  // src/plugins/windsurf/index.ts
1373
+ var OUTPUT_DIR7 = TOOL_OUTPUT_DIRS.windsurf;
1382
1374
  var windsurfPlugin = {
1383
1375
  id: "windsurf",
1384
1376
  name: "Windsurf",
@@ -1390,13 +1382,9 @@ var windsurfPlugin = {
1390
1382
  },
1391
1383
  async export(state, rootDir) {
1392
1384
  const files = [];
1393
- const outputDir = TOOL_OUTPUT_DIRS.windsurf;
1394
- if (state.agents) {
1395
- files.push({
1396
- path: "AGENTS.md",
1397
- type: "symlink",
1398
- target: `${UNIFIED_DIR}/AGENTS.md`
1399
- });
1385
+ const agentsSymlink = createRootAgentsMdSymlink(state);
1386
+ if (agentsSymlink) {
1387
+ files.push(agentsSymlink);
1400
1388
  }
1401
1389
  for (const rule of state.rules) {
1402
1390
  const transformed = transformRuleToWindsurf(rule);
@@ -1405,28 +1393,19 @@ var windsurfPlugin = {
1405
1393
  transformed.content
1406
1394
  );
1407
1395
  files.push({
1408
- path: `${outputDir}/rules/${rule.path}`,
1396
+ path: `${OUTPUT_DIR7}/rules/${rule.path}`,
1409
1397
  type: "text",
1410
1398
  content: ruleContent
1411
1399
  });
1412
1400
  }
1413
- for (const skill of state.skills) {
1414
- files.push({
1415
- path: `${outputDir}/skills/${skill.path}`,
1416
- type: "symlink",
1417
- target: `../../${UNIFIED_DIR}/skills/${skill.path}`
1418
- });
1419
- }
1401
+ files.push(...createSkillSymlinks(state, OUTPUT_DIR7));
1420
1402
  return applyFileOverrides(files, rootDir, "windsurf");
1421
1403
  },
1422
1404
  validate(state) {
1423
1405
  const warnings = [];
1424
1406
  const skipped = [];
1425
1407
  if (!state.agents) {
1426
- warnings.push({
1427
- path: ["AGENTS.md"],
1428
- message: "No AGENTS.md found - root AGENTS.md will not be created"
1429
- });
1408
+ warnings.push(createNoAgentsMdWarning("root AGENTS.md"));
1430
1409
  }
1431
1410
  if (state.rules.length > 0) {
1432
1411
  warnings.push({
@@ -1440,14 +1419,11 @@ var windsurfPlugin = {
1440
1419
  reason: "Windsurf uses global MCP config at ~/.codeium/windsurf/mcp_config.json - project-level MCP servers are not exported"
1441
1420
  });
1442
1421
  }
1443
- if (state.settings?.permissions) {
1444
- const hasPermissions = (state.settings.permissions.allow?.length ?? 0) > 0 || (state.settings.permissions.ask?.length ?? 0) > 0 || (state.settings.permissions.deny?.length ?? 0) > 0;
1445
- if (hasPermissions) {
1446
- skipped.push({
1447
- feature: "permissions",
1448
- reason: "Windsurf does not support declarative permissions"
1449
- });
1450
- }
1422
+ if (hasPermissionsConfigured(state.settings?.permissions)) {
1423
+ skipped.push({
1424
+ feature: "permissions",
1425
+ reason: "Windsurf does not support declarative permissions"
1426
+ });
1451
1427
  }
1452
1428
  return { valid: true, errors: [], warnings, skipped };
1453
1429
  }
@@ -1461,21 +1437,65 @@ pluginRegistry.register(codexPlugin);
1461
1437
  pluginRegistry.register(opencodePlugin);
1462
1438
  pluginRegistry.register(windsurfPlugin);
1463
1439
  pluginRegistry.register(geminiPlugin);
1440
+ function computeFilesToDelete(previousFiles, currentFiles) {
1441
+ const currentPaths = new Set(currentFiles.map((f) => f.path));
1442
+ return previousFiles.map((f) => f.path).filter((p) => !currentPaths.has(p));
1443
+ }
1444
+ async function deleteFiles(paths, rootDir, dryRun) {
1445
+ const results = [];
1446
+ for (const relativePath of paths) {
1447
+ const fullPath = path.join(rootDir, relativePath);
1448
+ try {
1449
+ await fs4.lstat(fullPath);
1450
+ } catch (error) {
1451
+ if (error.code === "ENOENT") {
1452
+ continue;
1453
+ }
1454
+ throw error;
1455
+ }
1456
+ if (!dryRun) {
1457
+ await fs4.unlink(fullPath);
1458
+ await cleanupEmptyParentDirs(fullPath, rootDir);
1459
+ }
1460
+ results.push({
1461
+ path: relativePath,
1462
+ action: "delete"
1463
+ });
1464
+ }
1465
+ return results;
1466
+ }
1467
+ async function cleanupEmptyParentDirs(filePath, rootDir) {
1468
+ let dir = path.dirname(filePath);
1469
+ const normalizedRoot = path.normalize(rootDir);
1470
+ while (dir !== normalizedRoot && dir.startsWith(normalizedRoot + path.sep)) {
1471
+ try {
1472
+ const entries = await fs4.readdir(dir);
1473
+ if (entries.length === 0) {
1474
+ await fs4.rmdir(dir);
1475
+ dir = path.dirname(dir);
1476
+ } else {
1477
+ break;
1478
+ }
1479
+ } catch {
1480
+ break;
1481
+ }
1482
+ }
1483
+ }
1464
1484
  function computeHash(content) {
1465
1485
  return crypto.createHash("sha256").update(content, "utf-8").digest("hex");
1466
1486
  }
1467
1487
  async function readExistingFile(filePath) {
1468
1488
  try {
1469
- return await fs3.readFile(filePath, "utf-8");
1489
+ return await fs4.readFile(filePath, "utf-8");
1470
1490
  } catch {
1471
1491
  return null;
1472
1492
  }
1473
1493
  }
1474
1494
  async function getSymlinkTarget(filePath) {
1475
1495
  try {
1476
- const stats = await fs3.lstat(filePath);
1496
+ const stats = await fs4.lstat(filePath);
1477
1497
  if (stats.isSymbolicLink()) {
1478
- return await fs3.readlink(filePath);
1498
+ return await fs4.readlink(filePath);
1479
1499
  }
1480
1500
  return null;
1481
1501
  } catch {
@@ -1483,15 +1503,15 @@ async function getSymlinkTarget(filePath) {
1483
1503
  }
1484
1504
  }
1485
1505
  async function ensureDir(dirPath) {
1486
- await fs3.mkdir(dirPath, { recursive: true });
1506
+ await fs4.mkdir(dirPath, { recursive: true });
1487
1507
  }
1488
1508
  async function removeIfExists(filePath) {
1489
1509
  try {
1490
- const stats = await fs3.lstat(filePath);
1510
+ const stats = await fs4.lstat(filePath);
1491
1511
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1492
- await fs3.rm(filePath, { recursive: true, force: true });
1512
+ await fs4.rm(filePath, { recursive: true, force: true });
1493
1513
  } else {
1494
- await fs3.unlink(filePath);
1514
+ await fs4.unlink(filePath);
1495
1515
  }
1496
1516
  } catch (error) {
1497
1517
  if (error.code !== "ENOENT") {
@@ -1503,6 +1523,12 @@ async function writeSingleFile(file, rootDir, dryRun) {
1503
1523
  const fullPath = path.join(rootDir, file.path);
1504
1524
  const dirPath = path.dirname(fullPath);
1505
1525
  if (file.type === "symlink") {
1526
+ if (!file.target) {
1527
+ throw new WriteError(
1528
+ `Symlink file missing target: ${file.path}`,
1529
+ file.path
1530
+ );
1531
+ }
1506
1532
  const target = file.target;
1507
1533
  const existingTarget = await getSymlinkTarget(fullPath);
1508
1534
  if (existingTarget === target) {
@@ -1514,7 +1540,7 @@ async function writeSingleFile(file, rootDir, dryRun) {
1514
1540
  if (!dryRun) {
1515
1541
  await ensureDir(dirPath);
1516
1542
  await removeIfExists(fullPath);
1517
- await fs3.symlink(target, fullPath);
1543
+ await fs4.symlink(target, fullPath);
1518
1544
  }
1519
1545
  return {
1520
1546
  path: file.path,
@@ -1535,7 +1561,11 @@ async function writeSingleFile(file, rootDir, dryRun) {
1535
1561
  }
1536
1562
  if (!dryRun) {
1537
1563
  await ensureDir(dirPath);
1538
- await fs3.writeFile(fullPath, content, "utf-8");
1564
+ const existingSymlink = await getSymlinkTarget(fullPath);
1565
+ if (existingSymlink !== null) {
1566
+ await removeIfExists(fullPath);
1567
+ }
1568
+ await fs4.writeFile(fullPath, content, "utf-8");
1539
1569
  }
1540
1570
  return {
1541
1571
  path: file.path,
@@ -1564,19 +1594,101 @@ async function writeFiles(files, options) {
1564
1594
  async function updateGitignore(rootDir, paths) {
1565
1595
  const gitignorePath = path.join(rootDir, ".gitignore");
1566
1596
  let content = "";
1597
+ let hasExistingFile = true;
1567
1598
  try {
1568
- content = await fs3.readFile(gitignorePath, "utf-8");
1599
+ content = await fs4.readFile(gitignorePath, "utf-8");
1569
1600
  } catch {
1601
+ hasExistingFile = false;
1570
1602
  }
1571
1603
  const marker = "# lnai-generated";
1572
1604
  const endMarker = "# end lnai-generated";
1573
1605
  const markerRegex = new RegExp(`${marker}[\\s\\S]*?${endMarker}\\n?`, "g");
1606
+ const hasManagedSection = new RegExp(`${marker}[\\s\\S]*?${endMarker}`).test(
1607
+ content
1608
+ );
1574
1609
  content = content.replace(markerRegex, "");
1575
- content = content.trimEnd();
1576
- const uniquePaths = [...new Set(paths)];
1577
- const newSection = ["", marker, ...uniquePaths, endMarker, ""].join("\n");
1578
- content = content + newSection;
1579
- await fs3.writeFile(gitignorePath, content, "utf-8");
1610
+ const baseContent = content.trimEnd();
1611
+ const uniquePaths = [...new Set(paths)].sort();
1612
+ if (uniquePaths.length === 0) {
1613
+ if (!hasManagedSection && !hasExistingFile) {
1614
+ return;
1615
+ }
1616
+ const cleanedContent = baseContent.length > 0 ? `${baseContent}
1617
+ ` : baseContent;
1618
+ await fs4.writeFile(gitignorePath, cleanedContent, "utf-8");
1619
+ return;
1620
+ }
1621
+ const managedSection = [marker, ...uniquePaths, endMarker].join("\n");
1622
+ const nextContent = baseContent.length > 0 ? `${baseContent}
1623
+
1624
+ ${managedSection}
1625
+ ` : `${managedSection}
1626
+ `;
1627
+ await fs4.writeFile(gitignorePath, nextContent, "utf-8");
1628
+ }
1629
+
1630
+ // src/manifest/index.ts
1631
+ var MANIFEST_FILENAME = ".lnai-manifest.json";
1632
+ async function readManifest(rootDir) {
1633
+ const manifestPath = path.join(rootDir, UNIFIED_DIR, MANIFEST_FILENAME);
1634
+ try {
1635
+ const content = await fs4.readFile(manifestPath, "utf-8");
1636
+ const manifest = JSON.parse(content);
1637
+ if (manifest.version !== 1) {
1638
+ console.warn(
1639
+ `[lnai] Unknown manifest version ${manifest.version}, skipping cleanup`
1640
+ );
1641
+ return null;
1642
+ }
1643
+ return manifest;
1644
+ } catch (error) {
1645
+ if (error.code === "ENOENT") {
1646
+ return null;
1647
+ }
1648
+ console.warn(`[lnai] Failed to read manifest: ${error.message}`);
1649
+ return null;
1650
+ }
1651
+ }
1652
+ async function writeManifest(rootDir, manifest) {
1653
+ const manifestPath = path.join(rootDir, UNIFIED_DIR, MANIFEST_FILENAME);
1654
+ const content = JSON.stringify(manifest, null, 2) + "\n";
1655
+ await fs4.writeFile(manifestPath, content, "utf-8");
1656
+ }
1657
+ function buildToolManifest(toolId, files) {
1658
+ const entries = files.map((file) => {
1659
+ const entry = {
1660
+ path: file.path,
1661
+ type: file.type
1662
+ };
1663
+ if (file.type === "symlink") {
1664
+ entry.target = file.target;
1665
+ } else {
1666
+ const content = file.type === "json" ? JSON.stringify(file.content, null, 2) + "\n" : String(file.content);
1667
+ entry.hash = computeHash(content);
1668
+ }
1669
+ return entry;
1670
+ });
1671
+ return {
1672
+ version: 1,
1673
+ tool: toolId,
1674
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1675
+ files: entries
1676
+ };
1677
+ }
1678
+ function updateToolManifest(manifest, toolId, files) {
1679
+ return {
1680
+ ...manifest,
1681
+ tools: {
1682
+ ...manifest.tools,
1683
+ [toolId]: buildToolManifest(toolId, files)
1684
+ }
1685
+ };
1686
+ }
1687
+ function createEmptyManifest() {
1688
+ return {
1689
+ version: 1,
1690
+ tools: {}
1691
+ };
1580
1692
  }
1581
1693
 
1582
1694
  // src/pipeline/index.ts
@@ -1598,7 +1710,12 @@ function getToolsToSync(config, requestedTools) {
1598
1710
  return enabledTools;
1599
1711
  }
1600
1712
  async function runSyncPipeline(options) {
1601
- const { rootDir, dryRun = false, tools: requestedTools } = options;
1713
+ const {
1714
+ rootDir,
1715
+ dryRun = false,
1716
+ skipCleanup = false,
1717
+ tools: requestedTools
1718
+ } = options;
1602
1719
  if (requestedTools && requestedTools.length > 0) {
1603
1720
  const toolValidation = validateToolIds(requestedTools);
1604
1721
  if (!toolValidation.valid) {
@@ -1624,8 +1741,8 @@ async function runSyncPipeline(options) {
1624
1741
  if (toolsToSync.length === 0) {
1625
1742
  return [];
1626
1743
  }
1744
+ let manifest = await readManifest(rootDir) ?? createEmptyManifest();
1627
1745
  const results = [];
1628
- const pathsToIgnore = [];
1629
1746
  for (const toolId of toolsToSync) {
1630
1747
  const plugin = pluginRegistry.get(toolId);
1631
1748
  if (!plugin) {
@@ -1633,18 +1750,30 @@ async function runSyncPipeline(options) {
1633
1750
  }
1634
1751
  const validation = plugin.validate(state);
1635
1752
  const outputFiles = await plugin.export(state, rootDir);
1636
- const changes = await writeFiles(outputFiles, { rootDir, dryRun });
1753
+ let deleteChanges = [];
1754
+ if (!skipCleanup && manifest.tools[toolId]) {
1755
+ const toDelete = computeFilesToDelete(
1756
+ manifest.tools[toolId].files,
1757
+ outputFiles
1758
+ );
1759
+ deleteChanges = await deleteFiles(toDelete, rootDir, dryRun);
1760
+ }
1761
+ const writeChanges = await writeFiles(outputFiles, { rootDir, dryRun });
1762
+ const changes = [...deleteChanges, ...writeChanges];
1637
1763
  results.push({
1638
1764
  tool: toolId,
1639
1765
  changes,
1640
1766
  validation
1641
1767
  });
1642
- const toolConfig = state.config.tools?.[toolId];
1643
- if (!toolConfig?.versionControl) {
1644
- pathsToIgnore.push(...outputFiles.map((f) => f.path));
1768
+ if (!dryRun) {
1769
+ manifest = updateToolManifest(manifest, toolId, outputFiles);
1645
1770
  }
1646
1771
  }
1647
- if (pathsToIgnore.length > 0 && !dryRun) {
1772
+ if (!dryRun) {
1773
+ await writeManifest(rootDir, manifest);
1774
+ const pathsToIgnore = Object.entries(manifest.tools).flatMap(
1775
+ ([toolId, toolManifest]) => !state.config.tools?.[toolId]?.versionControl && toolManifest?.files ? toolManifest.files.map((file) => file.path) : []
1776
+ );
1648
1777
  await updateGitignore(rootDir, pathsToIgnore);
1649
1778
  }
1650
1779
  return results;
@@ -1669,12 +1798,12 @@ async function initUnifiedConfig(options) {
1669
1798
  );
1670
1799
  }
1671
1800
  if (exists) {
1672
- await fs3.rm(aiDir, { recursive: true, force: true });
1801
+ await fs4.rm(aiDir, { recursive: true, force: true });
1673
1802
  }
1674
- await fs3.mkdir(aiDir, { recursive: true });
1803
+ await fs4.mkdir(aiDir, { recursive: true });
1675
1804
  created.push(UNIFIED_DIR);
1676
1805
  const configPath = path.join(aiDir, CONFIG_FILES.config);
1677
- await fs3.writeFile(
1806
+ await fs4.writeFile(
1678
1807
  configPath,
1679
1808
  JSON.stringify(config, null, 2) + "\n",
1680
1809
  "utf-8"
@@ -1683,10 +1812,10 @@ async function initUnifiedConfig(options) {
1683
1812
  if (!minimal) {
1684
1813
  for (const dir of [CONFIG_DIRS.rules, CONFIG_DIRS.skills]) {
1685
1814
  const dirPath = path.join(aiDir, dir);
1686
- await fs3.mkdir(dirPath, { recursive: true });
1815
+ await fs4.mkdir(dirPath, { recursive: true });
1687
1816
  created.push(path.join(UNIFIED_DIR, dir));
1688
1817
  const gitkeepPath = path.join(dirPath, ".gitkeep");
1689
- await fs3.writeFile(gitkeepPath, "", "utf-8");
1818
+ await fs4.writeFile(gitkeepPath, "", "utf-8");
1690
1819
  created.push(path.join(UNIFIED_DIR, dir, ".gitkeep"));
1691
1820
  }
1692
1821
  }
@@ -1695,7 +1824,7 @@ async function initUnifiedConfig(options) {
1695
1824
  async function hasUnifiedConfig(rootDir) {
1696
1825
  const aiDir = path.join(rootDir, UNIFIED_DIR);
1697
1826
  try {
1698
- const stats = await fs3.stat(aiDir);
1827
+ const stats = await fs4.stat(aiDir);
1699
1828
  return stats.isDirectory();
1700
1829
  } catch {
1701
1830
  return false;
@@ -1724,4 +1853,4 @@ function generateDefaultConfig(tools, versionControl) {
1724
1853
  return { tools: toolsConfig };
1725
1854
  }
1726
1855
 
1727
- export { CONFIG_DIRS, CONFIG_FILES, FileNotFoundError, LnaiError, ParseError, PluginError, TOOL_IDS, TOOL_OUTPUT_DIRS, UNIFIED_DIR, ValidationError, WriteError, claudeCodePlugin, codexPlugin, computeHash, configSchema, generateDefaultConfig, hasUnifiedConfig, initUnifiedConfig, mcpServerSchema, opencodePlugin, parseFrontmatter, parseUnifiedConfig, permissionsSchema, pluginRegistry, ruleFrontmatterSchema, runSyncPipeline, settingsSchema, skillFrontmatterSchema, toolConfigSchema, toolIdSchema, updateGitignore, validateConfig, validateSettings, validateUnifiedState, writeFiles };
1856
+ 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.6.0",
3
+ "version": "0.6.6",
4
4
  "description": "Core library for LNAI - unified AI config management",
5
5
  "type": "module",
6
6
  "license": "MIT",