@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 +400 -0
- package/package.json +31 -0
- package/src/detectors/format-detector.ts +35 -0
- package/src/filters/pattern-filter.ts +47 -0
- package/src/index.ts +4 -0
- package/src/scanners/local-scanner.ts +102 -0
- package/src/types.ts +65 -0
- package/test/format-detector.test.ts +45 -0
- package/test/local-scanner.test.ts +109 -0
- package/test/pattern-filter.test.ts +74 -0
- package/tsconfig.json +12 -0
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,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
|
+
});
|