@omnidev-ai/core 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnidev-ai/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -71,17 +71,35 @@ export async function loadCapabilityConfig(capabilityPath: string): Promise<Capa
71
71
  }
72
72
 
73
73
  /**
74
- * Dynamically imports capability exports from index.ts.
75
- * Returns an empty object if index.ts doesn't exist.
74
+ * Dynamically imports capability exports.
75
+ * Checks for built output (dist/index.js) first, then falls back to index.js or index.ts.
76
+ * Returns an empty object if no entry point exists.
76
77
  *
77
78
  * @param capabilityPath - Path to the capability directory
78
79
  * @returns Exported module or empty object
79
80
  * @throws Error if import fails
80
81
  */
81
82
  async function importCapabilityExports(capabilityPath: string): Promise<Record<string, unknown>> {
82
- const indexPath = join(capabilityPath, "index.ts");
83
+ // Check for entry points in order of preference:
84
+ // 1. Built output (dist/index.js) - compiled TypeScript
85
+ // 2. Plain JavaScript (index.js)
86
+ // 3. TypeScript source (index.ts) - only works with TypeScript-aware runtimes
87
+ const builtIndexPath = join(capabilityPath, "dist", "index.js");
88
+ const jsIndexPath = join(capabilityPath, "index.js");
89
+ const tsIndexPath = join(capabilityPath, "index.ts");
90
+
91
+ let indexPath: string | null = null;
92
+
93
+ if (existsSync(builtIndexPath)) {
94
+ indexPath = builtIndexPath;
95
+ } else if (existsSync(jsIndexPath)) {
96
+ indexPath = jsIndexPath;
97
+ } else if (existsSync(tsIndexPath)) {
98
+ // TypeScript file exists but no built output - this will likely fail with Node.js
99
+ indexPath = tsIndexPath;
100
+ }
83
101
 
84
- if (!existsSync(indexPath)) {
102
+ if (!indexPath) {
85
103
  return {};
86
104
  }
87
105
 
@@ -95,6 +113,16 @@ async function importCapabilityExports(capabilityPath: string): Promise<Record<s
95
113
  if (errorMessage.includes("Cannot find module")) {
96
114
  const match = errorMessage.match(/Cannot find module '([^']+)'/);
97
115
  const missingModule = match ? match[1] : "unknown";
116
+
117
+ // Check if this is a TypeScript resolution issue
118
+ if (indexPath === tsIndexPath && missingModule?.endsWith(".js")) {
119
+ throw new Error(
120
+ `Capability at ${capabilityPath} has TypeScript files but no built output.\n` +
121
+ `Add a "build" script to package.json (e.g., "build": "tsc") and run it to compile TypeScript.\n` +
122
+ `The build output should be in dist/index.js.`,
123
+ );
124
+ }
125
+
98
126
  throw new Error(
99
127
  `Missing dependency '${missingModule}' for capability at ${capabilityPath}.\n` +
100
128
  `If this is a project-specific capability, install dependencies or remove it from .omni/capabilities/`,
@@ -20,8 +20,10 @@ import type {
20
20
  FileCapabilitySourceConfig,
21
21
  GitCapabilitySourceConfig,
22
22
  OmniConfig,
23
+ VersionSource,
23
24
  } from "../types/index.js";
24
25
  import { isFileSourceConfig } from "../types/index.js";
26
+ import { createHash } from "node:crypto";
25
27
 
26
28
  // Local path for .omni directory
27
29
  const OMNI_LOCAL = ".omni";
@@ -42,8 +44,12 @@ export interface FetchResult {
42
44
  id: string;
43
45
  path: string;
44
46
  version: string;
47
+ /** Source where version was detected from */
48
+ versionSource: VersionSource;
45
49
  /** Git commit hash */
46
50
  commit?: string;
51
+ /** Content hash for file sources (SHA-256) */
52
+ contentHash?: string;
47
53
  updated: boolean;
48
54
  wrapped: boolean;
49
55
  }
@@ -243,12 +249,18 @@ function stringifyLockFile(lockFile: CapabilitiesLockFile): string {
243
249
  lines.push(`[capabilities.${id}]`);
244
250
  lines.push(`source = "${entry.source}"`);
245
251
  lines.push(`version = "${entry.version}"`);
252
+ if (entry.version_source) {
253
+ lines.push(`version_source = "${entry.version_source}"`);
254
+ }
246
255
  if (entry.commit) {
247
256
  lines.push(`commit = "${entry.commit}"`);
248
257
  }
249
258
  if (entry.ref) {
250
259
  lines.push(`ref = "${entry.ref}"`);
251
260
  }
261
+ if (entry.content_hash) {
262
+ lines.push(`content_hash = "${entry.content_hash}"`);
263
+ }
252
264
  lines.push(`updated_at = "${entry.updated_at}"`);
253
265
  lines.push("");
254
266
  }
@@ -294,6 +306,150 @@ function shortCommit(commit: string): string {
294
306
  return commit.substring(0, 7);
295
307
  }
296
308
 
309
+ /**
310
+ * Get short content hash (12 chars)
311
+ */
312
+ function shortContentHash(hash: string): string {
313
+ return hash.substring(0, 12);
314
+ }
315
+
316
+ /**
317
+ * Default patterns to exclude from content hashing
318
+ */
319
+ const CONTENT_HASH_EXCLUDES = [
320
+ ".git",
321
+ "node_modules",
322
+ ".omni",
323
+ "__pycache__",
324
+ ".pytest_cache",
325
+ ".mypy_cache",
326
+ "dist",
327
+ "build",
328
+ ".DS_Store",
329
+ "Thumbs.db",
330
+ ];
331
+
332
+ /**
333
+ * Compute a stable SHA-256 content hash for a directory.
334
+ * Files are processed in sorted order to ensure deterministic output.
335
+ * Excludes common non-semantic artifacts (.git, node_modules, etc.)
336
+ */
337
+ export async function computeContentHash(
338
+ dirPath: string,
339
+ excludePatterns: string[] = CONTENT_HASH_EXCLUDES,
340
+ ): Promise<string> {
341
+ const hash = createHash("sha256");
342
+ const files: Array<{ relativePath: string; content: Buffer }> = [];
343
+
344
+ async function collectFiles(currentPath: string, relativeTo: string): Promise<void> {
345
+ const entries = await readdir(currentPath, { withFileTypes: true });
346
+ // Sort entries for deterministic ordering
347
+ entries.sort((a, b) => a.name.localeCompare(b.name));
348
+
349
+ for (const entry of entries) {
350
+ const fullPath = join(currentPath, entry.name);
351
+ const relativePath = fullPath.slice(relativeTo.length + 1);
352
+
353
+ // Skip excluded patterns
354
+ if (
355
+ excludePatterns.some(
356
+ (pattern) => entry.name === pattern || relativePath.startsWith(`${pattern}/`),
357
+ )
358
+ ) {
359
+ continue;
360
+ }
361
+
362
+ if (entry.isDirectory()) {
363
+ await collectFiles(fullPath, relativeTo);
364
+ } else if (entry.isFile()) {
365
+ const content = await readFile(fullPath);
366
+ files.push({ relativePath, content });
367
+ }
368
+ // Skip symlinks for security
369
+ }
370
+ }
371
+
372
+ await collectFiles(dirPath, dirPath);
373
+
374
+ // Sort files by path and hash them
375
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
376
+ for (const file of files) {
377
+ // Include both path and content in hash for integrity
378
+ hash.update(file.relativePath);
379
+ hash.update(file.content);
380
+ }
381
+
382
+ return hash.digest("hex");
383
+ }
384
+
385
+ /**
386
+ * Result of version detection
387
+ */
388
+ export interface VersionDetectionResult {
389
+ version: string;
390
+ source: VersionSource;
391
+ }
392
+
393
+ /**
394
+ * Detect the display version for a capability directory.
395
+ * Checks in order: capability.toml > plugin.json > package.json > fallback
396
+ *
397
+ * @param dirPath - Path to the capability directory
398
+ * @param fallback - Fallback version if no source is found (e.g., commit hash or content hash)
399
+ * @param fallbackSource - Source type for the fallback
400
+ */
401
+ export async function detectDisplayVersion(
402
+ dirPath: string,
403
+ fallback: string,
404
+ fallbackSource: VersionSource,
405
+ ): Promise<VersionDetectionResult> {
406
+ // 1. Check capability.toml
407
+ const capTomlPath = join(dirPath, "capability.toml");
408
+ if (existsSync(capTomlPath)) {
409
+ try {
410
+ const content = await readFile(capTomlPath, "utf-8");
411
+ const parsed = parseToml(content) as Record<string, unknown>;
412
+ const capability = parsed["capability"] as Record<string, unknown> | undefined;
413
+ if (capability?.["version"] && typeof capability["version"] === "string") {
414
+ return { version: capability["version"], source: "capability.toml" };
415
+ }
416
+ } catch {
417
+ // Continue to next source
418
+ }
419
+ }
420
+
421
+ // 2. Check .claude-plugin/plugin.json
422
+ const pluginJsonPath = join(dirPath, ".claude-plugin", "plugin.json");
423
+ if (existsSync(pluginJsonPath)) {
424
+ try {
425
+ const content = await readFile(pluginJsonPath, "utf-8");
426
+ const parsed = JSON.parse(content);
427
+ if (parsed.version && typeof parsed.version === "string") {
428
+ return { version: parsed.version, source: "plugin.json" };
429
+ }
430
+ } catch {
431
+ // Continue to next source
432
+ }
433
+ }
434
+
435
+ // 3. Check package.json
436
+ const pkgJsonPath = join(dirPath, "package.json");
437
+ if (existsSync(pkgJsonPath)) {
438
+ try {
439
+ const content = await readFile(pkgJsonPath, "utf-8");
440
+ const parsed = JSON.parse(content);
441
+ if (parsed.version && typeof parsed.version === "string") {
442
+ return { version: parsed.version, source: "package.json" };
443
+ }
444
+ } catch {
445
+ // Continue to fallback
446
+ }
447
+ }
448
+
449
+ // 4. Fallback to commit hash (git) or content hash (file)
450
+ return { version: fallback, source: fallbackSource };
451
+ }
452
+
297
453
  /**
298
454
  * Clone a git repository
299
455
  */
@@ -728,15 +884,9 @@ async function fetchGitCapabilitySource(
728
884
 
729
885
  // Check if already cloned to temp
730
886
  if (existsSync(join(tempPath, ".git"))) {
731
- if (!options?.silent) {
732
- console.log(` Checking ${id}...`);
733
- }
734
887
  updated = await fetchRepo(tempPath, config.ref);
735
888
  commit = await getRepoCommit(tempPath);
736
889
  } else {
737
- if (!options?.silent) {
738
- console.log(` Cloning ${id} from ${config.source}...`);
739
- }
740
890
  await mkdir(join(tempPath, ".."), { recursive: true });
741
891
  await cloneRepo(gitUrl, tempPath, config.ref);
742
892
  commit = await getRepoCommit(tempPath);
@@ -756,6 +906,9 @@ async function fetchGitCapabilitySource(
756
906
  await mkdir(join(targetPath, ".."), { recursive: true });
757
907
  await cp(sourcePath, targetPath, { recursive: true });
758
908
 
909
+ // Clean up temp directory after successful copy
910
+ await rm(tempPath, { recursive: true });
911
+
759
912
  repoPath = targetPath;
760
913
  } else {
761
914
  // Clone directly to target (no subdirectory)
@@ -802,24 +955,15 @@ async function fetchGitCapabilitySource(
802
955
  }
803
956
  }
804
957
 
805
- // Get version from capability.toml or package.json
806
- let version = shortCommit(commit);
807
- const pkgJsonPath = join(repoPath, "package.json");
808
- if (existsSync(pkgJsonPath)) {
809
- try {
810
- const pkgJson = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
811
- if (pkgJson.version) {
812
- version = pkgJson.version;
813
- }
814
- } catch {
815
- // Ignore parse errors
816
- }
817
- }
958
+ // Detect version using unified version detection
959
+ // Fallback to short commit hash for git sources
960
+ const versionResult = await detectDisplayVersion(repoPath, shortCommit(commit), "commit");
818
961
 
819
962
  return {
820
963
  id,
821
964
  path: targetPath,
822
- version,
965
+ version: versionResult.version,
966
+ versionSource: versionResult.source,
823
967
  commit,
824
968
  updated,
825
969
  wrapped: needsWrap,
@@ -828,6 +972,7 @@ async function fetchGitCapabilitySource(
828
972
 
829
973
  /**
830
974
  * Fetch a file-sourced capability (copy from local path)
975
+ * Supports wrapping directories without capability.toml if they have skills/agents/etc.
831
976
  */
832
977
  async function fetchFileCapabilitySource(
833
978
  id: string,
@@ -848,9 +993,20 @@ async function fetchFileCapabilitySource(
848
993
  throw new Error(`File source must be a directory: ${sourcePath}`);
849
994
  }
850
995
 
851
- // Check if capability.toml exists in source
852
- if (!existsSync(join(sourcePath, "capability.toml"))) {
853
- throw new Error(`No capability.toml found in: ${sourcePath}`);
996
+ // Compute content hash for the source (before copy, for reproducibility)
997
+ const contentHash = await computeContentHash(sourcePath);
998
+
999
+ // Check if we need to wrap (no capability.toml but has content)
1000
+ const hasCapToml = existsSync(join(sourcePath, "capability.toml"));
1001
+ let needsWrap = false;
1002
+
1003
+ if (!hasCapToml) {
1004
+ needsWrap = await shouldWrapDirectory(sourcePath);
1005
+ if (!needsWrap) {
1006
+ throw new Error(
1007
+ `No capability.toml found in: ${sourcePath} (and no wrappable content detected)`,
1008
+ );
1009
+ }
854
1010
  }
855
1011
 
856
1012
  if (!options?.silent) {
@@ -868,31 +1024,124 @@ async function fetchFileCapabilitySource(
868
1024
  // Copy directory contents
869
1025
  await cp(sourcePath, targetPath, { recursive: true });
870
1026
 
871
- // Read version from capability.toml
872
- let version = "local";
873
- const capTomlPath = join(targetPath, "capability.toml");
874
- if (existsSync(capTomlPath)) {
875
- try {
876
- const content = await readFile(capTomlPath, "utf-8");
877
- const parsed = parseToml(content) as Record<string, unknown>;
878
- const capability = parsed["capability"] as Record<string, unknown> | undefined;
879
- if (capability?.["version"] && typeof capability["version"] === "string") {
880
- version = capability["version"];
1027
+ // If needs wrapping, generate capability.toml in target (not source)
1028
+ if (needsWrap) {
1029
+ // Normalize folder names (singular -> plural)
1030
+ await normalizeFolderNames(targetPath);
1031
+
1032
+ // Discover content and generate capability.toml
1033
+ const content = await discoverContent(targetPath);
1034
+ await generateFileSourceCapabilityToml(
1035
+ id,
1036
+ config.source,
1037
+ shortContentHash(contentHash),
1038
+ content,
1039
+ targetPath,
1040
+ );
1041
+
1042
+ if (!options?.silent) {
1043
+ const parts: string[] = [];
1044
+ if (content.skills.length > 0) parts.push(`${content.skills.length} skills`);
1045
+ if (content.agents.length > 0) parts.push(`${content.agents.length} agents`);
1046
+ if (content.commands.length > 0) parts.push(`${content.commands.length} commands`);
1047
+ if (parts.length > 0) {
1048
+ console.log(` Wrapped: ${parts.join(", ")}`);
881
1049
  }
882
- } catch {
883
- // Ignore parse errors
884
1050
  }
885
1051
  }
886
1052
 
1053
+ // Detect version using unified version detection
1054
+ // Fallback to short content hash for file sources
1055
+ const versionResult = await detectDisplayVersion(
1056
+ targetPath,
1057
+ shortContentHash(contentHash),
1058
+ "content_hash",
1059
+ );
1060
+
887
1061
  return {
888
1062
  id,
889
1063
  path: targetPath,
890
- version,
1064
+ version: versionResult.version,
1065
+ versionSource: versionResult.source,
1066
+ contentHash,
891
1067
  updated: true,
892
- wrapped: false,
1068
+ wrapped: needsWrap,
893
1069
  };
894
1070
  }
895
1071
 
1072
+ /**
1073
+ * Generate a capability.toml for a wrapped file source
1074
+ */
1075
+ async function generateFileSourceCapabilityToml(
1076
+ id: string,
1077
+ source: string,
1078
+ hashVersion: string,
1079
+ content: DiscoveredContent,
1080
+ targetPath: string,
1081
+ ): Promise<void> {
1082
+ // Try to get metadata from plugin.json
1083
+ const pluginMeta = await parsePluginJson(targetPath);
1084
+
1085
+ // Try to get description from README
1086
+ const readmeDesc = await readReadmeDescription(targetPath);
1087
+
1088
+ // Build description based on available sources
1089
+ let description: string;
1090
+ if (pluginMeta?.description) {
1091
+ description = pluginMeta.description;
1092
+ } else if (readmeDesc) {
1093
+ description = readmeDesc;
1094
+ } else {
1095
+ // Fallback: build from discovered content
1096
+ const parts: string[] = [];
1097
+ if (content.skills.length > 0) {
1098
+ parts.push(`${content.skills.length} skill${content.skills.length > 1 ? "s" : ""}`);
1099
+ }
1100
+ if (content.agents.length > 0) {
1101
+ parts.push(`${content.agents.length} agent${content.agents.length > 1 ? "s" : ""}`);
1102
+ }
1103
+ if (content.commands.length > 0) {
1104
+ parts.push(`${content.commands.length} command${content.commands.length > 1 ? "s" : ""}`);
1105
+ }
1106
+ description = parts.length > 0 ? `${parts.join(", ")}` : `Wrapped from ${source}`;
1107
+ }
1108
+
1109
+ // Use plugin metadata for name and version if available
1110
+ const name = pluginMeta?.name || `${id} (wrapped)`;
1111
+ const version = pluginMeta?.version || hashVersion;
1112
+
1113
+ // Build TOML content
1114
+ let tomlContent = `# Auto-generated by OmniDev - DO NOT EDIT
1115
+ # This capability was wrapped from a local directory
1116
+
1117
+ [capability]
1118
+ id = "${id}"
1119
+ name = "${name}"
1120
+ version = "${version}"
1121
+ description = "${description}"
1122
+ `;
1123
+
1124
+ // Add author if available from plugin.json
1125
+ if (pluginMeta?.author?.name || pluginMeta?.author?.email) {
1126
+ tomlContent += "\n[capability.author]\n";
1127
+ if (pluginMeta.author.name) {
1128
+ tomlContent += `name = "${pluginMeta.author.name}"\n`;
1129
+ }
1130
+ if (pluginMeta.author.email) {
1131
+ tomlContent += `email = "${pluginMeta.author.email}"\n`;
1132
+ }
1133
+ }
1134
+
1135
+ // Add metadata section
1136
+ tomlContent += `
1137
+ [capability.metadata]
1138
+ wrapped = true
1139
+ source = "${source}"
1140
+ `;
1141
+
1142
+ await writeFile(join(targetPath, "capability.toml"), tomlContent, "utf-8");
1143
+ }
1144
+
896
1145
  /**
897
1146
  * Fetch a single capability source (git or file)
898
1147
  */
@@ -1074,15 +1323,17 @@ export async function fetchAllCapabilitySources(
1074
1323
  // Generate MCP capabilities FIRST
1075
1324
  await generateMcpCapabilities(config);
1076
1325
 
1326
+ // Clean up any stale temp directories from previous syncs
1327
+ const tempDir = join(OMNI_LOCAL, "_temp");
1328
+ if (existsSync(tempDir)) {
1329
+ await rm(tempDir, { recursive: true });
1330
+ }
1331
+
1077
1332
  const sources = config.capabilities?.sources;
1078
1333
  if (!sources || Object.keys(sources).length === 0) {
1079
1334
  return [];
1080
1335
  }
1081
1336
 
1082
- if (!options?.silent) {
1083
- console.log("Fetching capability sources...");
1084
- }
1085
-
1086
1337
  const results: FetchResult[] = [];
1087
1338
  const lockFile = await loadLockFile();
1088
1339
  let lockUpdated = false;
@@ -1096,6 +1347,7 @@ export async function fetchAllCapabilitySources(
1096
1347
  const lockEntry: CapabilityLockEntry = {
1097
1348
  source: typeof source === "string" ? source : source.source,
1098
1349
  version: result.version,
1350
+ version_source: result.versionSource,
1099
1351
  updated_at: new Date().toISOString(),
1100
1352
  };
1101
1353
 
@@ -1103,6 +1355,10 @@ export async function fetchAllCapabilitySources(
1103
1355
  if (result.commit) {
1104
1356
  lockEntry.commit = result.commit;
1105
1357
  }
1358
+ // File source: use content hash
1359
+ if (result.contentHash) {
1360
+ lockEntry.content_hash = result.contentHash;
1361
+ }
1106
1362
  // Only access ref if it's a git source
1107
1363
  if (!isFileSourceConfig(source)) {
1108
1364
  const gitConfig = parseSourceConfig(source) as GitCapabilitySourceConfig;
@@ -1112,17 +1368,17 @@ export async function fetchAllCapabilitySources(
1112
1368
  }
1113
1369
 
1114
1370
  // Check if lock entry changed
1371
+ // For git sources: compare commit hash
1372
+ // For file sources: compare content hash
1115
1373
  const existing = lockFile.capabilities[id];
1116
- const hasChanged = !existing || existing.commit !== result.commit;
1374
+ const hasChanged =
1375
+ !existing ||
1376
+ (result.commit && existing.commit !== result.commit) ||
1377
+ (result.contentHash && existing.content_hash !== result.contentHash);
1117
1378
 
1118
1379
  if (hasChanged) {
1119
1380
  lockFile.capabilities[id] = lockEntry;
1120
1381
  lockUpdated = true;
1121
-
1122
- if (!options?.silent && result.updated) {
1123
- const oldVersion = existing?.version || "new";
1124
- console.log(` ${result.wrapped ? "+" : "~"} ${id}: ${oldVersion} -> ${result.version}`);
1125
- }
1126
1382
  }
1127
1383
  } catch (error) {
1128
1384
  console.error(` Failed to fetch ${id}: ${error}`);
@@ -1134,15 +1390,6 @@ export async function fetchAllCapabilitySources(
1134
1390
  await saveLockFile(lockFile);
1135
1391
  }
1136
1392
 
1137
- if (!options?.silent && results.length > 0) {
1138
- const updated = results.filter((r) => r.updated).length;
1139
- if (updated > 0) {
1140
- console.log(` Updated ${updated} capability source(s)`);
1141
- } else {
1142
- console.log(` All ${results.length} source(s) up to date`);
1143
- }
1144
- }
1145
-
1146
1393
  return results;
1147
1394
  }
1148
1395
 
package/src/index.ts CHANGED
@@ -30,6 +30,9 @@ export * from "./state";
30
30
  // Export sync functionality
31
31
  export * from "./sync";
32
32
 
33
+ // Export security scanning
34
+ export * from "./security";
35
+
33
36
  // Export templates
34
37
  export * from "./templates/agents";
35
38
  export * from "./templates/capability";
@@ -132,7 +132,6 @@ function buildMcpServerConfig(mcp: McpConfig): McpServerConfig {
132
132
  export async function syncMcpJson(
133
133
  capabilities: LoadedCapability[],
134
134
  previousManifest: ResourceManifest,
135
- options: { silent?: boolean } = {},
136
135
  ): Promise<void> {
137
136
  const mcpJson = await readMcpJson();
138
137
 
@@ -150,17 +149,11 @@ export async function syncMcpJson(
150
149
  }
151
150
 
152
151
  // Add MCPs from all enabled capabilities
153
- let addedCount = 0;
154
152
  for (const cap of capabilities) {
155
153
  if (cap.config.mcp) {
156
154
  mcpJson.mcpServers[cap.id] = buildMcpServerConfig(cap.config.mcp);
157
- addedCount++;
158
155
  }
159
156
  }
160
157
 
161
158
  await writeMcpJson(mcpJson);
162
-
163
- if (!options.silent) {
164
- console.log(` - .mcp.json (${addedCount} MCP server(s))`);
165
- }
166
159
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Security scanning module for capability supply-chain safety
3
+ *
4
+ * Provides opt-in scanning for:
5
+ * - Suspicious Unicode characters (bidi overrides, zero-width, control chars)
6
+ * - Symlinks that escape capability directories
7
+ * - Suspicious script patterns in hooks
8
+ */
9
+
10
+ export * from "./scanner.js";
11
+ export * from "./types.js";