@lnai/core 0.6.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
@@ -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>;
@@ -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
+ * Merges new paths with existing ones to support partial syncs (e.g., syncing one tool).
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");
@@ -405,7 +416,7 @@ function validateToolIds(tools) {
405
416
  async function scanOverrideDirectory(rootDir, toolId) {
406
417
  const overrideDir = path.join(rootDir, UNIFIED_DIR, OVERRIDE_DIRS[toolId]);
407
418
  try {
408
- await fs3.access(overrideDir);
419
+ await fs4.access(overrideDir);
409
420
  } catch {
410
421
  return [];
411
422
  }
@@ -414,7 +425,7 @@ async function scanOverrideDirectory(rootDir, toolId) {
414
425
  return files;
415
426
  }
416
427
  async function scanDir(baseDir, currentDir, files) {
417
- const entries = await fs3.readdir(currentDir, { withFileTypes: true });
428
+ const entries = await fs4.readdir(currentDir, { withFileTypes: true });
418
429
  for (const entry of entries) {
419
430
  const absolutePath = path.join(currentDir, entry.name);
420
431
  if (entry.isDirectory()) {
@@ -510,8 +521,8 @@ function getDirFromGlob(glob) {
510
521
  const cleanPath = glob.replace(/(\*\*|\*|\{.*,.*\}).*$/, "");
511
522
  const dir = cleanPath.replace(/\/$/, "");
512
523
  if (dir === glob) {
513
- const dirname4 = path.dirname(dir);
514
- return dirname4 === "." && !dir.includes("/") ? "." : dirname4;
524
+ const dirname5 = path.dirname(dir);
525
+ return dirname5 === "." && !dir.includes("/") ? "." : dirname5;
515
526
  }
516
527
  if (!dir) {
517
528
  return ".";
@@ -684,6 +695,32 @@ function formatTomlInlineTable(values) {
684
695
  return `{ ${entries.join(", ")} }`;
685
696
  }
686
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
+ }
723
+
687
724
  // src/utils/transforms.ts
688
725
  var ENV_VAR_PATTERN = /\$\{([^}:]+)(:-[^}]*)?\}/g;
689
726
  function transformEnvVar(value, format) {
@@ -854,24 +891,12 @@ var copilotPlugin = {
854
891
  reason: "GitHub Copilot does not support declarative permissions"
855
892
  });
856
893
  }
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
- }
894
+ warnings.push(
895
+ ...validateMcpServers(state.settings?.mcpServers, [
896
+ "settings",
897
+ "mcpServers"
898
+ ])
899
+ );
875
900
  return { valid: true, errors: [], warnings, skipped };
876
901
  }
877
902
  };
@@ -1066,19 +1091,12 @@ var cursorPlugin = {
1066
1091
  message: 'Cursor does not support "ask" permission level - these rules will be treated as "allow"'
1067
1092
  });
1068
1093
  }
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
- }
1094
+ warnings.push(
1095
+ ...validateMcpServers(state.settings?.mcpServers, [
1096
+ "settings",
1097
+ "mcpServers"
1098
+ ])
1099
+ );
1082
1100
  return { valid: true, errors: [], warnings, skipped: [] };
1083
1101
  }
1084
1102
  };
@@ -1324,6 +1342,12 @@ var opencodePlugin = {
1324
1342
  message: "No AGENTS.md found - root AGENTS.md will not be created"
1325
1343
  });
1326
1344
  }
1345
+ warnings.push(
1346
+ ...validateMcpServers(state.settings?.mcpServers, [
1347
+ "settings",
1348
+ "mcpServers"
1349
+ ])
1350
+ );
1327
1351
  return { valid: true, errors: [], warnings, skipped: [] };
1328
1352
  }
1329
1353
  };
@@ -1461,21 +1485,65 @@ pluginRegistry.register(codexPlugin);
1461
1485
  pluginRegistry.register(opencodePlugin);
1462
1486
  pluginRegistry.register(windsurfPlugin);
1463
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
+ }
1464
1532
  function computeHash(content) {
1465
1533
  return crypto.createHash("sha256").update(content, "utf-8").digest("hex");
1466
1534
  }
1467
1535
  async function readExistingFile(filePath) {
1468
1536
  try {
1469
- return await fs3.readFile(filePath, "utf-8");
1537
+ return await fs4.readFile(filePath, "utf-8");
1470
1538
  } catch {
1471
1539
  return null;
1472
1540
  }
1473
1541
  }
1474
1542
  async function getSymlinkTarget(filePath) {
1475
1543
  try {
1476
- const stats = await fs3.lstat(filePath);
1544
+ const stats = await fs4.lstat(filePath);
1477
1545
  if (stats.isSymbolicLink()) {
1478
- return await fs3.readlink(filePath);
1546
+ return await fs4.readlink(filePath);
1479
1547
  }
1480
1548
  return null;
1481
1549
  } catch {
@@ -1483,15 +1551,15 @@ async function getSymlinkTarget(filePath) {
1483
1551
  }
1484
1552
  }
1485
1553
  async function ensureDir(dirPath) {
1486
- await fs3.mkdir(dirPath, { recursive: true });
1554
+ await fs4.mkdir(dirPath, { recursive: true });
1487
1555
  }
1488
1556
  async function removeIfExists(filePath) {
1489
1557
  try {
1490
- const stats = await fs3.lstat(filePath);
1558
+ const stats = await fs4.lstat(filePath);
1491
1559
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1492
- await fs3.rm(filePath, { recursive: true, force: true });
1560
+ await fs4.rm(filePath, { recursive: true, force: true });
1493
1561
  } else {
1494
- await fs3.unlink(filePath);
1562
+ await fs4.unlink(filePath);
1495
1563
  }
1496
1564
  } catch (error) {
1497
1565
  if (error.code !== "ENOENT") {
@@ -1503,6 +1571,12 @@ async function writeSingleFile(file, rootDir, dryRun) {
1503
1571
  const fullPath = path.join(rootDir, file.path);
1504
1572
  const dirPath = path.dirname(fullPath);
1505
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
+ }
1506
1580
  const target = file.target;
1507
1581
  const existingTarget = await getSymlinkTarget(fullPath);
1508
1582
  if (existingTarget === target) {
@@ -1514,7 +1588,7 @@ async function writeSingleFile(file, rootDir, dryRun) {
1514
1588
  if (!dryRun) {
1515
1589
  await ensureDir(dirPath);
1516
1590
  await removeIfExists(fullPath);
1517
- await fs3.symlink(target, fullPath);
1591
+ await fs4.symlink(target, fullPath);
1518
1592
  }
1519
1593
  return {
1520
1594
  path: file.path,
@@ -1535,7 +1609,11 @@ async function writeSingleFile(file, rootDir, dryRun) {
1535
1609
  }
1536
1610
  if (!dryRun) {
1537
1611
  await ensureDir(dirPath);
1538
- 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");
1539
1617
  }
1540
1618
  return {
1541
1619
  path: file.path,
@@ -1565,18 +1643,86 @@ async function updateGitignore(rootDir, paths) {
1565
1643
  const gitignorePath = path.join(rootDir, ".gitignore");
1566
1644
  let content = "";
1567
1645
  try {
1568
- content = await fs3.readFile(gitignorePath, "utf-8");
1646
+ content = await fs4.readFile(gitignorePath, "utf-8");
1569
1647
  } catch {
1570
1648
  }
1571
1649
  const marker = "# lnai-generated";
1572
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("#"));
1573
1655
  const markerRegex = new RegExp(`${marker}[\\s\\S]*?${endMarker}\\n?`, "g");
1574
1656
  content = content.replace(markerRegex, "");
1575
1657
  content = content.trimEnd();
1576
- const uniquePaths = [...new Set(paths)];
1658
+ const uniquePaths = [.../* @__PURE__ */ new Set([...existingPaths, ...paths])].sort();
1577
1659
  const newSection = ["", marker, ...uniquePaths, endMarker, ""].join("\n");
1578
1660
  content = content + newSection;
1579
- 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
+ };
1580
1726
  }
1581
1727
 
1582
1728
  // src/pipeline/index.ts
@@ -1598,7 +1744,12 @@ function getToolsToSync(config, requestedTools) {
1598
1744
  return enabledTools;
1599
1745
  }
1600
1746
  async function runSyncPipeline(options) {
1601
- const { rootDir, dryRun = false, tools: requestedTools } = options;
1747
+ const {
1748
+ rootDir,
1749
+ dryRun = false,
1750
+ skipCleanup = false,
1751
+ tools: requestedTools
1752
+ } = options;
1602
1753
  if (requestedTools && requestedTools.length > 0) {
1603
1754
  const toolValidation = validateToolIds(requestedTools);
1604
1755
  if (!toolValidation.valid) {
@@ -1624,6 +1775,7 @@ async function runSyncPipeline(options) {
1624
1775
  if (toolsToSync.length === 0) {
1625
1776
  return [];
1626
1777
  }
1778
+ let manifest = await readManifest(rootDir) ?? createEmptyManifest();
1627
1779
  const results = [];
1628
1780
  const pathsToIgnore = [];
1629
1781
  for (const toolId of toolsToSync) {
@@ -1633,17 +1785,32 @@ async function runSyncPipeline(options) {
1633
1785
  }
1634
1786
  const validation = plugin.validate(state);
1635
1787
  const outputFiles = await plugin.export(state, rootDir);
1636
- 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];
1637
1798
  results.push({
1638
1799
  tool: toolId,
1639
1800
  changes,
1640
1801
  validation
1641
1802
  });
1803
+ if (!dryRun) {
1804
+ manifest = updateToolManifest(manifest, toolId, outputFiles);
1805
+ }
1642
1806
  const toolConfig = state.config.tools?.[toolId];
1643
1807
  if (!toolConfig?.versionControl) {
1644
1808
  pathsToIgnore.push(...outputFiles.map((f) => f.path));
1645
1809
  }
1646
1810
  }
1811
+ if (!dryRun) {
1812
+ await writeManifest(rootDir, manifest);
1813
+ }
1647
1814
  if (pathsToIgnore.length > 0 && !dryRun) {
1648
1815
  await updateGitignore(rootDir, pathsToIgnore);
1649
1816
  }
@@ -1669,12 +1836,12 @@ async function initUnifiedConfig(options) {
1669
1836
  );
1670
1837
  }
1671
1838
  if (exists) {
1672
- await fs3.rm(aiDir, { recursive: true, force: true });
1839
+ await fs4.rm(aiDir, { recursive: true, force: true });
1673
1840
  }
1674
- await fs3.mkdir(aiDir, { recursive: true });
1841
+ await fs4.mkdir(aiDir, { recursive: true });
1675
1842
  created.push(UNIFIED_DIR);
1676
1843
  const configPath = path.join(aiDir, CONFIG_FILES.config);
1677
- await fs3.writeFile(
1844
+ await fs4.writeFile(
1678
1845
  configPath,
1679
1846
  JSON.stringify(config, null, 2) + "\n",
1680
1847
  "utf-8"
@@ -1683,10 +1850,10 @@ async function initUnifiedConfig(options) {
1683
1850
  if (!minimal) {
1684
1851
  for (const dir of [CONFIG_DIRS.rules, CONFIG_DIRS.skills]) {
1685
1852
  const dirPath = path.join(aiDir, dir);
1686
- await fs3.mkdir(dirPath, { recursive: true });
1853
+ await fs4.mkdir(dirPath, { recursive: true });
1687
1854
  created.push(path.join(UNIFIED_DIR, dir));
1688
1855
  const gitkeepPath = path.join(dirPath, ".gitkeep");
1689
- await fs3.writeFile(gitkeepPath, "", "utf-8");
1856
+ await fs4.writeFile(gitkeepPath, "", "utf-8");
1690
1857
  created.push(path.join(UNIFIED_DIR, dir, ".gitkeep"));
1691
1858
  }
1692
1859
  }
@@ -1695,7 +1862,7 @@ async function initUnifiedConfig(options) {
1695
1862
  async function hasUnifiedConfig(rootDir) {
1696
1863
  const aiDir = path.join(rootDir, UNIFIED_DIR);
1697
1864
  try {
1698
- const stats = await fs3.stat(aiDir);
1865
+ const stats = await fs4.stat(aiDir);
1699
1866
  return stats.isDirectory();
1700
1867
  } catch {
1701
1868
  return false;
@@ -1724,4 +1891,4 @@ function generateDefaultConfig(tools, versionControl) {
1724
1891
  return { tools: toolsConfig };
1725
1892
  }
1726
1893
 
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 };
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.6.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",