@vibe-agent-toolkit/discovery 0.1.0-rc.7

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/README.md ADDED
@@ -0,0 +1,400 @@
1
+ # @vibe-agent-toolkit/discovery
2
+
3
+ Intelligent file discovery for VAT agents, skills, and resources.
4
+
5
+ ## Overview
6
+
7
+ The discovery package provides tools for finding and identifying agent-related files in local directories. It detects file formats, respects gitignore rules, and enables pattern-based filtering for intelligent file discovery.
8
+
9
+ ## Features
10
+
11
+ - **Format Detection** - Automatically identify Claude Skills, VAT agents, and markdown resources
12
+ - **Gitignore Awareness** - Skip build outputs and ignored files by default
13
+ - **Pattern Filtering** - Include/exclude files using glob patterns
14
+ - **Recursive Scanning** - Deep directory traversal with symlink control
15
+ - **Type-Safe Results** - Full TypeScript types for all operations
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @vibe-agent-toolkit/discovery
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ### Detect File Format
26
+
27
+ ```typescript
28
+ import { detectFormat } from '@vibe-agent-toolkit/discovery';
29
+
30
+ const format = detectFormat('/path/to/SKILL.md');
31
+ // Returns: 'claude-skill' | 'vat-agent' | 'markdown' | 'unknown'
32
+ ```
33
+
34
+ ### Scan Directory
35
+
36
+ ```typescript
37
+ import { scan } from '@vibe-agent-toolkit/discovery';
38
+
39
+ const summary = await scan({
40
+ path: './agents',
41
+ recursive: true,
42
+ });
43
+
44
+ console.log(`Found ${summary.totalScanned} files`);
45
+ console.log(`Claude Skills: ${summary.byFormat['claude-skill']}`);
46
+ console.log(`VAT Agents: ${summary.byFormat['vat-agent']}`);
47
+ ```
48
+
49
+ ### Filter by Pattern
50
+
51
+ ```typescript
52
+ import { scan, createPatternFilter } from '@vibe-agent-toolkit/discovery';
53
+
54
+ const summary = await scan({
55
+ path: './docs',
56
+ include: ['**/*.md'],
57
+ exclude: ['**/node_modules/**', '**/dist/**'],
58
+ recursive: true,
59
+ });
60
+
61
+ // Filter results manually
62
+ const filter = createPatternFilter({
63
+ include: ['**/*.md'],
64
+ exclude: ['**/test/**'],
65
+ });
66
+
67
+ const filtered = summary.results.filter(result =>
68
+ filter.matches(result.relativePath)
69
+ );
70
+ ```
71
+
72
+ ## API Reference
73
+
74
+ ### detectFormat(path: string): DetectedFormat
75
+
76
+ Detect the format of a file based on its name and location.
77
+
78
+ **Returns:**
79
+ - `'claude-skill'` - SKILL.md file
80
+ - `'vat-agent'` - Directory containing agent.yaml
81
+ - `'markdown'` - .md file (resource)
82
+ - `'unknown'` - Other file types
83
+
84
+ **Example:**
85
+ ```typescript
86
+ detectFormat('/path/to/SKILL.md') // 'claude-skill'
87
+ detectFormat('/path/to/agent.yaml') // 'vat-agent'
88
+ detectFormat('/path/to/guide.md') // 'markdown'
89
+ detectFormat('/path/to/script.js') // 'unknown'
90
+ ```
91
+
92
+ ### scan(options: ScanOptions): Promise<ScanSummary>
93
+
94
+ Scan a directory for agent-related files.
95
+
96
+ **Options:**
97
+ ```typescript
98
+ interface ScanOptions {
99
+ path: string; // Directory or file to scan
100
+ recursive?: boolean; // Scan subdirectories (default: false)
101
+ include?: string[]; // Glob patterns to include
102
+ exclude?: string[]; // Glob patterns to exclude
103
+ followSymlinks?: boolean; // Follow symbolic links (default: false)
104
+ }
105
+ ```
106
+
107
+ **Returns:**
108
+ ```typescript
109
+ interface ScanSummary {
110
+ results: ScanResult[]; // All discovered files
111
+ totalScanned: number; // Total files found
112
+ byFormat: Record<DetectedFormat, number>; // Count by format
113
+ sourceFiles: ScanResult[]; // Non-gitignored files
114
+ buildOutputs: ScanResult[]; // Gitignored files
115
+ }
116
+
117
+ interface ScanResult {
118
+ path: string; // Absolute path
119
+ format: DetectedFormat; // Detected format
120
+ isGitIgnored: boolean; // Is file gitignored
121
+ relativePath: string; // Relative path from scan root
122
+ }
123
+ ```
124
+
125
+ **Example:**
126
+ ```typescript
127
+ const summary = await scan({
128
+ path: './agents',
129
+ recursive: true,
130
+ include: ['**/*.md'],
131
+ exclude: ['**/node_modules/**'],
132
+ });
133
+
134
+ // Access results
135
+ for (const result of summary.sourceFiles) {
136
+ console.log(`${result.relativePath}: ${result.format}`);
137
+ }
138
+ ```
139
+
140
+ ### createPatternFilter(options): PatternFilter
141
+
142
+ Create a reusable pattern filter for matching file paths.
143
+
144
+ **Options:**
145
+ ```typescript
146
+ interface PatternFilterOptions {
147
+ include?: string[]; // Glob patterns to include
148
+ exclude?: string[]; // Glob patterns to exclude
149
+ }
150
+ ```
151
+
152
+ **Returns:**
153
+ ```typescript
154
+ interface PatternFilter {
155
+ matches(path: string): boolean;
156
+ }
157
+ ```
158
+
159
+ **Example:**
160
+ ```typescript
161
+ const filter = createPatternFilter({
162
+ include: ['**/*.md'],
163
+ exclude: ['**/test/**', '**/node_modules/**'],
164
+ });
165
+
166
+ if (filter.matches('docs/guide.md')) {
167
+ console.log('File matches filter');
168
+ }
169
+ ```
170
+
171
+ ## Usage Examples
172
+
173
+ ### Find All Claude Skills
174
+
175
+ ```typescript
176
+ import { scan } from '@vibe-agent-toolkit/discovery';
177
+
178
+ const summary = await scan({
179
+ path: './skills',
180
+ recursive: true,
181
+ });
182
+
183
+ const skills = summary.results.filter(
184
+ result => result.format === 'claude-skill'
185
+ );
186
+
187
+ console.log(`Found ${skills.length} Claude Skills:`);
188
+ for (const skill of skills) {
189
+ console.log(` - ${skill.relativePath}`);
190
+ }
191
+ ```
192
+
193
+ ### Scan with Custom Patterns
194
+
195
+ ```typescript
196
+ import { scan } from '@vibe-agent-toolkit/discovery';
197
+
198
+ const summary = await scan({
199
+ path: './docs',
200
+ recursive: true,
201
+ include: ['**/*.md'],
202
+ exclude: [
203
+ '**/node_modules/**',
204
+ '**/dist/**',
205
+ '**/__tests__/**',
206
+ ],
207
+ });
208
+
209
+ console.log(`Found ${summary.sourceFiles.length} markdown files`);
210
+ ```
211
+
212
+ ### Separate Source Files from Build Outputs
213
+
214
+ ```typescript
215
+ import { scan } from '@vibe-agent-toolkit/discovery';
216
+
217
+ const summary = await scan({
218
+ path: './project',
219
+ recursive: true,
220
+ });
221
+
222
+ console.log('Source files:');
223
+ for (const file of summary.sourceFiles) {
224
+ console.log(` ${file.relativePath}`);
225
+ }
226
+
227
+ console.log('\nBuild outputs (ignored):');
228
+ for (const file of summary.buildOutputs) {
229
+ console.log(` ${file.relativePath}`);
230
+ }
231
+ ```
232
+
233
+ ### Filter After Scanning
234
+
235
+ ```typescript
236
+ import { scan, createPatternFilter } from '@vibe-agent-toolkit/discovery';
237
+
238
+ // Scan everything
239
+ const summary = await scan({
240
+ path: './agents',
241
+ recursive: true,
242
+ });
243
+
244
+ // Create multiple filters for different purposes
245
+ const skillsFilter = createPatternFilter({
246
+ include: ['**/SKILL.md'],
247
+ });
248
+
249
+ const docsFilter = createPatternFilter({
250
+ include: ['**/*.md'],
251
+ exclude: ['**/SKILL.md'],
252
+ });
253
+
254
+ const skills = summary.results.filter(r =>
255
+ skillsFilter.matches(r.relativePath)
256
+ );
257
+
258
+ const docs = summary.results.filter(r =>
259
+ docsFilter.matches(r.relativePath)
260
+ );
261
+
262
+ console.log(`Skills: ${skills.length}, Docs: ${docs.length}`);
263
+ ```
264
+
265
+ ## Architecture
266
+
267
+ The discovery package is organized into three main components:
268
+
269
+ ### Detectors
270
+
271
+ **Format Detector** (`detectors/format-detector.ts`)
272
+ - Identifies file formats based on filenames and paths
273
+ - Used by scanner to classify discovered files
274
+ - Stateless, pure function for easy testing
275
+
276
+ ### Scanners
277
+
278
+ **Local Scanner** (`scanners/local-scanner.ts`)
279
+ - Scans local file system for agent-related files
280
+ - Respects gitignore rules automatically
281
+ - Supports recursive traversal and symlink handling
282
+ - Returns structured scan summaries
283
+
284
+ ### Filters
285
+
286
+ **Pattern Filter** (`filters/pattern-filter.ts`)
287
+ - Matches file paths against glob patterns
288
+ - Supports include/exclude logic
289
+ - Reusable for manual filtering after scanning
290
+
291
+ ## Design Principles
292
+
293
+ ### 1. Gitignore Awareness
294
+
295
+ The scanner automatically detects gitignored files using the `isGitIgnored` utility from `@vibe-agent-toolkit/utils`. This prevents processing build outputs, dependencies, or temporary files.
296
+
297
+ ### 2. Format Detection
298
+
299
+ Format detection is based on conventions:
300
+ - **Claude Skills** - Files named SKILL.md
301
+ - **VAT Agents** - Directories containing agent.yaml
302
+ - **Markdown** - Files with .md extension
303
+ - **Unknown** - Everything else
304
+
305
+ ### 3. Pattern Flexibility
306
+
307
+ Both include and exclude patterns are supported:
308
+ - **Include** - Only process matching files
309
+ - **Exclude** - Skip matching files
310
+ - **Combined** - Apply includes first, then excludes
311
+
312
+ ### 4. Separation of Concerns
313
+
314
+ - **Detection** - What is this file?
315
+ - **Scanning** - Find all files
316
+ - **Filtering** - Which files should I process?
317
+
318
+ Each component is independent and testable.
319
+
320
+ ## Integration
321
+
322
+ ### Used by CLI Commands
323
+
324
+ The discovery package powers these CLI commands:
325
+
326
+ - `vat agent audit` - Finds SKILL.md files to validate
327
+ - `vat resources scan` - Discovers markdown resources
328
+ - `vat resources validate` - Finds files to check for broken links
329
+
330
+ ### Used by Runtime Packages
331
+
332
+ Runtime packages use discovery for:
333
+ - Finding skill dependencies
334
+ - Locating reference files
335
+ - Building resource inventories
336
+
337
+ ## Error Handling
338
+
339
+ The discovery package uses standard error handling:
340
+
341
+ ```typescript
342
+ try {
343
+ const summary = await scan({ path: './invalid-path' });
344
+ } catch (error) {
345
+ if (error instanceof Error) {
346
+ console.error(`Scan failed: ${error.message}`);
347
+ }
348
+ }
349
+ ```
350
+
351
+ Common errors:
352
+ - **ENOENT** - Path does not exist
353
+ - **EACCES** - Permission denied
354
+ - **ENOTDIR** - Path is not a directory (when recursive)
355
+
356
+ ## Performance Considerations
357
+
358
+ ### Scan Optimization
359
+
360
+ - **Gitignore check** - Cached per directory to avoid repeated git calls
361
+ - **Pattern matching** - Uses efficient glob libraries (minimatch)
362
+ - **Symlink handling** - Optional to prevent cycles
363
+
364
+ ### Large Repositories
365
+
366
+ For large repositories:
367
+ 1. Use specific include patterns to narrow scope
368
+ 2. Exclude large directories (node_modules, dist, etc.)
369
+ 3. Disable recursive scanning when appropriate
370
+ 4. Consider scanning in batches
371
+
372
+ ## Cross-Platform Compatibility
373
+
374
+ The discovery package is fully cross-platform:
375
+ - Uses `node:path` for path operations
376
+ - Handles Windows and Unix path separators
377
+ - Tests run on Windows, macOS, and Linux
378
+
379
+ ## Testing
380
+
381
+ The package includes comprehensive tests:
382
+ - **Unit tests** - Format detection, pattern matching
383
+ - **Integration tests** - Scanning with real file system
384
+ - **System tests** - End-to-end workflows
385
+
386
+ Run tests:
387
+ ```bash
388
+ bun test
389
+ bun test:integration
390
+ ```
391
+
392
+ ## Related Packages
393
+
394
+ - [`@vibe-agent-toolkit/utils`](../utils/README.md) - Provides `isGitIgnored` utility
395
+ - [`@vibe-agent-toolkit/runtime-claude-skills`](../runtime-claude-skills/README.md) - Uses discovery for skill validation
396
+ - [`@vibe-agent-toolkit/cli`](../cli/README.md) - CLI commands built on discovery
397
+
398
+ ## License
399
+
400
+ MIT
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@vibe-agent-toolkit/discovery",
3
+ "version": "0.1.0-rc.7",
4
+ "description": "Intelligent file discovery for VAT agents and Claude Skills",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@vibe-agent-toolkit/utils": "workspace:*",
22
+ "picomatch": "^4.0.2"
23
+ },
24
+ "devDependencies": {
25
+ "@types/picomatch": "^4.0.2"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/jdutton/vibe-agent-toolkit.git"
30
+ }
31
+ }
@@ -0,0 +1,35 @@
1
+ import * as path from 'node:path';
2
+
3
+ import type { DetectedFormat } from '../types.js';
4
+
5
+ /**
6
+ * Detect file format based on filename
7
+ *
8
+ * Detection rules:
9
+ * - Exact match "SKILL.md" → claude-skill
10
+ * - Exact match "agent.yaml" or "agent.yml" → vat-agent
11
+ * - Extension ".md" → markdown
12
+ * - Everything else → unknown
13
+ *
14
+ * @param filePath - Path to file (can be relative or absolute)
15
+ * @returns Detected format
16
+ */
17
+ export function detectFormat(filePath: string): DetectedFormat {
18
+ const basename = path.basename(filePath);
19
+
20
+ // Exact matches (case-sensitive)
21
+ if (basename === 'SKILL.md') {
22
+ return 'claude-skill';
23
+ }
24
+
25
+ if (basename === 'agent.yaml' || basename === 'agent.yml') {
26
+ return 'vat-agent';
27
+ }
28
+
29
+ // Extension-based detection (case-insensitive)
30
+ if (basename.toLowerCase().endsWith('.md')) {
31
+ return 'markdown';
32
+ }
33
+
34
+ return 'unknown';
35
+ }
@@ -0,0 +1,47 @@
1
+ import picomatch from 'picomatch';
2
+
3
+ export interface PatternFilterOptions {
4
+ /** Include patterns (if specified, only matching files included) */
5
+ include?: string[];
6
+
7
+ /** Exclude patterns (applied after include) */
8
+ exclude?: string[];
9
+ }
10
+
11
+ /**
12
+ * Create a filter function for include/exclude patterns
13
+ *
14
+ * Uses picomatch for fast glob matching. Exclude patterns are applied after include.
15
+ *
16
+ * @param options - Pattern filter options
17
+ * @returns Filter function (returns true if file should be included)
18
+ */
19
+ export function createPatternFilter(
20
+ options: PatternFilterOptions
21
+ ): (filePath: string) => boolean {
22
+ const { include, exclude } = options;
23
+
24
+ // Compile patterns once for performance
25
+ // Use { contains: true } to match paths containing patterns (e.g., 'node_modules' matches 'path/node_modules/file')
26
+ const includeMatcher = include?.length
27
+ ? picomatch(include, { contains: true })
28
+ : null;
29
+
30
+ const excludeMatcher = exclude?.length
31
+ ? picomatch(exclude, { contains: true })
32
+ : null;
33
+
34
+ return (filePath: string): boolean => {
35
+ // If include patterns specified, file must match at least one
36
+ if (includeMatcher && !includeMatcher(filePath)) {
37
+ return false;
38
+ }
39
+
40
+ // If exclude patterns specified, file must not match any
41
+ if (excludeMatcher?.(filePath)) {
42
+ return false;
43
+ }
44
+
45
+ return true;
46
+ };
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { detectFormat } from './detectors/format-detector.js';
3
+ export { createPatternFilter } from './filters/pattern-filter.js';
4
+ export { scan } from './scanners/local-scanner.js';
@@ -0,0 +1,102 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ import { crawlDirectory , isGitIgnored } from '@vibe-agent-toolkit/utils';
5
+
6
+ import { detectFormat } from '../detectors/format-detector.js';
7
+ import { createPatternFilter } from '../filters/pattern-filter.js';
8
+ import type { ScanOptions, ScanResult, ScanSummary } from '../types.js';
9
+
10
+ /**
11
+ * Scan local filesystem for VAT agents and Claude Skills
12
+ *
13
+ * @param options - Scan options
14
+ * @returns Scan summary with all discovered files
15
+ */
16
+ export async function scan(options: ScanOptions): Promise<ScanSummary> {
17
+ const { path: targetPath, recursive = false, include, exclude } = options;
18
+
19
+ // Resolve to absolute path
20
+ const absolutePath = path.resolve(targetPath);
21
+
22
+ // Check if target exists
23
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- absolutePath is validated user input
24
+ if (!fs.existsSync(absolutePath)) {
25
+ throw new Error(`Path does not exist: ${absolutePath}`);
26
+ }
27
+
28
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- absolutePath validated above
29
+ const stat = fs.statSync(absolutePath);
30
+
31
+ // Determine scan root for relative paths
32
+ const scanRoot = stat.isDirectory() ? absolutePath : path.dirname(absolutePath);
33
+
34
+ // Get file list
35
+ let filePaths: string[];
36
+
37
+ if (stat.isFile()) {
38
+ filePaths = [absolutePath];
39
+ } else if (stat.isDirectory()) {
40
+ if (recursive) {
41
+ filePaths = await crawlDirectory({
42
+ baseDir: absolutePath,
43
+ respectGitignore: false, // We handle gitignore separately
44
+ exclude: [], // Don't exclude anything - we handle filtering ourselves
45
+ });
46
+ } else {
47
+ // Non-recursive: only immediate children
48
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- absolutePath validated above
49
+ filePaths = fs.readdirSync(absolutePath)
50
+ .map(name => path.join(absolutePath, name))
51
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- paths are from validated directory
52
+ .filter(p => fs.statSync(p).isFile());
53
+ }
54
+ } else {
55
+ throw new Error(`Path is neither file nor directory: ${absolutePath}`);
56
+ }
57
+
58
+ // Create pattern filter
59
+ const patternFilter = createPatternFilter({
60
+ ...(include && { include }),
61
+ ...(exclude && { exclude }),
62
+ });
63
+
64
+ // Process each file
65
+ const results: ScanResult[] = [];
66
+
67
+ for (const filePath of filePaths) {
68
+ const relativePath = path.relative(scanRoot, filePath);
69
+
70
+ // Apply pattern filter
71
+ if (!patternFilter(relativePath)) {
72
+ continue;
73
+ }
74
+
75
+ const format = detectFormat(filePath);
76
+ const gitIgnored = isGitIgnored(filePath, scanRoot);
77
+
78
+ results.push({
79
+ path: filePath,
80
+ format,
81
+ isGitIgnored: gitIgnored,
82
+ relativePath,
83
+ });
84
+ }
85
+
86
+ // Build summary
87
+ const byFormat = results.reduce((acc, r) => {
88
+ acc[r.format] = (acc[r.format] ?? 0) + 1;
89
+ return acc;
90
+ }, {} as Record<string, number>);
91
+
92
+ const sourceFiles = results.filter(r => !r.isGitIgnored);
93
+ const buildOutputs = results.filter(r => r.isGitIgnored);
94
+
95
+ return {
96
+ results,
97
+ totalScanned: results.length,
98
+ byFormat: byFormat as ScanSummary['byFormat'],
99
+ sourceFiles,
100
+ buildOutputs,
101
+ };
102
+ }
package/src/types.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Format types that discovery can detect
3
+ */
4
+ export type DetectedFormat =
5
+ | 'claude-skill' // SKILL.md
6
+ | 'vat-agent' // agent.yaml
7
+ | 'markdown' // *.md (resource file)
8
+ | 'unknown'; // Other files
9
+
10
+ /**
11
+ * Options for scanning/discovery operations
12
+ */
13
+ export interface ScanOptions {
14
+ /** Path to scan (file or directory) */
15
+ path: string;
16
+
17
+ /** Recursive scan (search subdirectories) */
18
+ recursive?: boolean;
19
+
20
+ /** Include patterns (glob) */
21
+ include?: string[];
22
+
23
+ /** Exclude patterns (glob) */
24
+ exclude?: string[];
25
+
26
+ /** Follow symbolic links */
27
+ followSymlinks?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Result of scanning a single file
32
+ */
33
+ export interface ScanResult {
34
+ /** Absolute path to file */
35
+ path: string;
36
+
37
+ /** Detected format */
38
+ format: DetectedFormat;
39
+
40
+ /** Is this file gitignored (likely build output) */
41
+ isGitIgnored: boolean;
42
+
43
+ /** Relative path from scan root */
44
+ relativePath: string;
45
+ }
46
+
47
+ /**
48
+ * Summary of scan operation
49
+ */
50
+ export interface ScanSummary {
51
+ /** All discovered files */
52
+ results: ScanResult[];
53
+
54
+ /** Total files scanned */
55
+ totalScanned: number;
56
+
57
+ /** Files by format */
58
+ byFormat: Record<DetectedFormat, number>;
59
+
60
+ /** Source files (not gitignored) */
61
+ sourceFiles: ScanResult[];
62
+
63
+ /** Build outputs (gitignored) */
64
+ buildOutputs: ScanResult[];
65
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { detectFormat } from '../src/detectors/format-detector.js';
4
+
5
+ const CLAUDE_SKILL_FORMAT = 'claude-skill';
6
+
7
+ describe('detectFormat', () => {
8
+ it('should detect SKILL.md as claude-skill', () => {
9
+ expect(detectFormat('SKILL.md')).toBe(CLAUDE_SKILL_FORMAT);
10
+ expect(detectFormat('/path/to/SKILL.md')).toBe(CLAUDE_SKILL_FORMAT);
11
+ expect(detectFormat('./my-skill/SKILL.md')).toBe(CLAUDE_SKILL_FORMAT);
12
+ });
13
+
14
+ it('should detect agent.yaml as vat-agent', () => {
15
+ expect(detectFormat('agent.yaml')).toBe('vat-agent');
16
+ expect(detectFormat('/path/to/agent.yaml')).toBe('vat-agent');
17
+ });
18
+
19
+ it('should detect agent.yml as vat-agent', () => {
20
+ expect(detectFormat('agent.yml')).toBe('vat-agent');
21
+ });
22
+
23
+ it('should detect .md files as markdown', () => {
24
+ expect(detectFormat('README.md')).toBe('markdown');
25
+ expect(detectFormat('docs/guide.md')).toBe('markdown');
26
+ expect(detectFormat('reference/api.md')).toBe('markdown');
27
+ });
28
+
29
+ it('should not detect SKILL.md as markdown', () => {
30
+ expect(detectFormat('SKILL.md')).not.toBe('markdown');
31
+ expect(detectFormat('SKILL.md')).toBe(CLAUDE_SKILL_FORMAT);
32
+ });
33
+
34
+ it('should return unknown for other files', () => {
35
+ expect(detectFormat('package.json')).toBe('unknown');
36
+ expect(detectFormat('index.ts')).toBe('unknown');
37
+ expect(detectFormat('test.txt')).toBe('unknown');
38
+ });
39
+
40
+ it('should be case-sensitive for SKILL.md', () => {
41
+ expect(detectFormat('skill.md')).toBe('markdown');
42
+ expect(detectFormat('Skill.md')).toBe('markdown');
43
+ expect(detectFormat('SKILL.MD')).toBe('markdown');
44
+ });
45
+ });
@@ -0,0 +1,109 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- test file uses controlled temp directory */
2
+ import { spawnSync } from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+
9
+ import { scan } from '../src/scanners/local-scanner.js';
10
+
11
+ describe('scan', () => {
12
+ let tempDir: string;
13
+
14
+ beforeEach(() => {
15
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discovery-test-'));
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ it('should scan single SKILL.md file', async () => {
23
+ const skillPath = path.join(tempDir, 'SKILL.md');
24
+ fs.writeFileSync(skillPath, '# Test Skill');
25
+
26
+ const result = await scan({ path: skillPath });
27
+
28
+ expect(result.totalScanned).toBe(1);
29
+ expect(result.results).toHaveLength(1);
30
+ expect(result.results[0]?.format).toBe('claude-skill');
31
+ expect(result.results[0]?.path).toBe(skillPath);
32
+ });
33
+
34
+ it('should scan directory non-recursively', async () => {
35
+ fs.writeFileSync(path.join(tempDir, 'SKILL.md'), '# Skill');
36
+ fs.writeFileSync(path.join(tempDir, 'README.md'), '# Readme');
37
+ fs.mkdirSync(path.join(tempDir, 'sub'));
38
+ fs.writeFileSync(path.join(tempDir, 'sub', 'agent.yaml'), 'name: test');
39
+
40
+ const result = await scan({ path: tempDir, recursive: false });
41
+
42
+ expect(result.totalScanned).toBe(2);
43
+ expect(result.byFormat['claude-skill']).toBe(1);
44
+ expect(result.byFormat['markdown']).toBe(1);
45
+ });
46
+
47
+ it('should scan directory recursively', async () => {
48
+ fs.writeFileSync(path.join(tempDir, 'SKILL.md'), '# Skill');
49
+ fs.mkdirSync(path.join(tempDir, 'sub'));
50
+ fs.writeFileSync(path.join(tempDir, 'sub', 'agent.yaml'), 'name: test');
51
+
52
+ const result = await scan({ path: tempDir, recursive: true });
53
+
54
+ expect(result.totalScanned).toBe(2);
55
+ expect(result.byFormat['claude-skill']).toBe(1);
56
+ expect(result.byFormat['vat-agent']).toBe(1);
57
+ });
58
+
59
+ it('should respect include patterns', async () => {
60
+ fs.writeFileSync(path.join(tempDir, 'test.md'), '# Test');
61
+ fs.writeFileSync(path.join(tempDir, 'test.ts'), 'code');
62
+
63
+ const result = await scan({
64
+ path: tempDir,
65
+ include: ['*.md']
66
+ });
67
+
68
+ expect(result.totalScanned).toBe(1);
69
+ expect(result.results[0]?.format).toBe('markdown');
70
+ });
71
+
72
+ it('should respect exclude patterns', async () => {
73
+ fs.mkdirSync(path.join(tempDir, 'node_modules'));
74
+ fs.writeFileSync(path.join(tempDir, 'README.md'), '# Readme');
75
+ fs.writeFileSync(path.join(tempDir, 'node_modules', 'pkg.md'), '# Pkg');
76
+
77
+ const result = await scan({
78
+ path: tempDir,
79
+ recursive: true,
80
+ exclude: ['**/node_modules/**']
81
+ });
82
+
83
+ expect(result.totalScanned).toBe(1);
84
+ expect(result.results[0]?.relativePath).toBe('README.md');
85
+ });
86
+
87
+ it('should detect gitignored files', async () => {
88
+ // Initialize git repo for git check-ignore to work
89
+ const gitPath = 'git';
90
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- test setup uses git from PATH
91
+ spawnSync(gitPath, ['init'], { cwd: tempDir, stdio: 'pipe' });
92
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- test setup uses git from PATH
93
+ spawnSync(gitPath, ['config', 'user.email', 'test@example.com'], { cwd: tempDir, stdio: 'pipe' });
94
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- test setup uses git from PATH
95
+ spawnSync(gitPath, ['config', 'user.name', 'Test User'], { cwd: tempDir, stdio: 'pipe' });
96
+
97
+ fs.writeFileSync(path.join(tempDir, '.gitignore'), 'dist/\n');
98
+ fs.mkdirSync(path.join(tempDir, 'dist'));
99
+ fs.writeFileSync(path.join(tempDir, 'dist', 'SKILL.md'), '# Built');
100
+ fs.writeFileSync(path.join(tempDir, 'SKILL.md'), '# Source');
101
+
102
+ const result = await scan({ path: tempDir, recursive: true });
103
+
104
+ expect(result.totalScanned).toBe(2);
105
+ expect(result.sourceFiles).toHaveLength(1);
106
+ expect(result.buildOutputs).toHaveLength(1);
107
+ expect(result.sourceFiles[0]?.relativePath).toBe('SKILL.md');
108
+ });
109
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { createPatternFilter } from '../src/filters/pattern-filter.js';
4
+
5
+ describe('createPatternFilter', () => {
6
+ describe('include patterns', () => {
7
+ it('should include files matching pattern', () => {
8
+ const filter = createPatternFilter({ include: ['*.md'] });
9
+
10
+ expect(filter('test.md')).toBe(true);
11
+ expect(filter('docs/guide.md')).toBe(true);
12
+ expect(filter('test.ts')).toBe(false);
13
+ });
14
+
15
+ it('should support glob patterns', () => {
16
+ const filter = createPatternFilter({ include: ['**/*.test.ts'] });
17
+
18
+ expect(filter('src/utils.test.ts')).toBe(true);
19
+ expect(filter('test/unit/parser.test.ts')).toBe(true);
20
+ expect(filter('src/utils.ts')).toBe(false);
21
+ });
22
+
23
+ it('should support multiple include patterns', () => {
24
+ const filter = createPatternFilter({
25
+ include: ['*.md', '*.yaml']
26
+ });
27
+
28
+ expect(filter('README.md')).toBe(true);
29
+ expect(filter('config.yaml')).toBe(true);
30
+ expect(filter('index.ts')).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('exclude patterns', () => {
35
+ it('should exclude files matching pattern', () => {
36
+ const filter = createPatternFilter({ exclude: ['node_modules'] });
37
+
38
+ expect(filter('node_modules/pkg/index.js')).toBe(false);
39
+ expect(filter('src/index.ts')).toBe(true);
40
+ });
41
+
42
+ it('should support glob patterns', () => {
43
+ const filter = createPatternFilter({
44
+ exclude: ['**/dist/**', '**/*.test.ts']
45
+ });
46
+
47
+ expect(filter('packages/cli/dist/index.js')).toBe(false);
48
+ expect(filter('src/utils.test.ts')).toBe(false);
49
+ expect(filter('src/utils.ts')).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe('include + exclude patterns', () => {
54
+ it('should apply exclude after include', () => {
55
+ const filter = createPatternFilter({
56
+ include: ['**/*.md'],
57
+ exclude: ['**/node_modules/**'],
58
+ });
59
+
60
+ expect(filter('README.md')).toBe(true);
61
+ expect(filter('docs/guide.md')).toBe(true);
62
+ expect(filter('node_modules/pkg/README.md')).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('no patterns', () => {
67
+ it('should include all files when no patterns specified', () => {
68
+ const filter = createPatternFilter({});
69
+
70
+ expect(filter('any-file.txt')).toBe(true);
71
+ expect(filter('path/to/file.js')).toBe(true);
72
+ });
73
+ });
74
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "outDir": "./dist",
6
+ "rootDir": "./src"
7
+ },
8
+ "include": ["src/**/*"],
9
+ "references": [
10
+ { "path": "../utils" }
11
+ ]
12
+ }