Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmd/endpoint/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ type Options struct {
// It is the integration point for cmd/ai/discover; opts.Kinds is
// overwritten.
func RunAITool(ctx context.Context, opts Options) error {
opts.Kinds = []string{scanners.KindAITool}
opts.Kinds = []string{
scanners.KindAITool,
scanners.KindAgentSkill,
}
return runScan(ctx, opts)
}

Expand Down
146 changes: 146 additions & 0 deletions docs/specs/2026-05-08-agent-skills-scanner-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Design: Agent Skills Scanner

**Date:** 2026-05-08
**Status:** Approved

## Problem

`vet endpoint scan` (and its alias `vet ai discover`) discovers installed AI tools, MCP servers, coding agents, and extensions — but not **skills** (agent skill directories). Skills are a first-class artifact of AI coding agents (Claude Code, Cursor, Windsurf, etc.) and should be part of the endpoint inventory synced to SafeDep Cloud.

## Goal

Extend `vet endpoint scan` and `vet ai discover` to also discover and sync agent skills installed on the endpoint. No new commands, no new sinks, no new flags — pure extension of the existing inventory pipeline.

## Scope

- Scan project-local skill directories (e.g., `.claude/skills/`) and global skill directories (e.g., `~/.claude/skills/`)
- Each subdirectory = one skill; directory name = skill name
- Emit one `inventory.Item` per discovered skill directory
- Register as a new scanner kind `"agent-skill"` in the existing registry
- `vet ai discover` pins to both `ai-tool` and `agent-skill` kinds
- Cloud sync, local table, JSON report all work automatically through existing sinks

## Out of Scope

- Parsing skill file contents or frontmatter
- Detecting enabled/disabled state
- Any new CLI commands or flags

## Architecture

Pure extension following the `aitool` scanner pattern:

```
pkg/inventory/scanners/
├── scanners.go ← add KindAgentSkill constant + descriptor
└── skills/
├── adapter.go ← implements inventory.Scanner
└── translate.go ← skill discovery result → *inventory.Item
```

`cmd/endpoint/scan.go`: update `RunAITool` to pin `[]string{KindAITool, KindAgentSkill}`.

No changes to orchestrator, sinks, or any other command.

## Agent Registry

Each agent entry encodes the project-local path (relative to `cfg.ProjectDir`) and the global path (relative to `cfg.HomeDir`). Both paths are checked when the corresponding scope is enabled.

Supported agents (from the canonical table):

| Agent | Kind Flag | Project Path | Global Path |
|---|---|---|---|
| Amp, Kimi CLI, Replit, Universal | `amp`, `kimi-cli`, `replit`, `universal` | `.agents/skills/` | `~/.config/agents/skills/` |
| Antigravity | `antigravity` | `.agents/skills/` | `~/.gemini/antigravity/skills/` |
| Augment | `augment` | `.augment/skills/` | `~/.augment/skills/` |
| IBM Bob | `bob` | `.bob/skills/` | `~/.bob/skills/` |
| Claude Code | `claude-code` | `.claude/skills/` | `~/.claude/skills/` |
| OpenClaw | `openclaw` | `skills/` | `~/.openclaw/skills/` |
| Cline, Warp | `cline`, `warp` | `.agents/skills/` | `~/.agents/skills/` |
| CodeBuddy | `codebuddy` | `.codebuddy/skills/` | `~/.codebuddy/skills/` |
| Codex | `codex` | `.agents/skills/` | `~/.codex/skills/` |
| Command Code | `command-code` | `.commandcode/skills/` | `~/.commandcode/skills/` |
| Continue | `continue` | `.continue/skills/` | `~/.continue/skills/` |
| Cortex Code | `cortex` | `.cortex/skills/` | `~/.snowflake/cortex/skills/` |
| Crush | `crush` | `.crush/skills/` | `~/.config/crush/skills/` |
| Cursor | `cursor` | `.agents/skills/` | `~/.cursor/skills/` |
| Deep Agents | `deepagents` | `.agents/skills/` | `~/.deepagents/agent/skills/` |
| Droid | `droid` | `.factory/skills/` | `~/.factory/skills/` |
| Firebender | `firebender` | `.agents/skills/` | `~/.firebender/skills/` |
| Gemini CLI | `gemini-cli` | `.agents/skills/` | `~/.gemini/skills/` |
| GitHub Copilot | `github-copilot` | `.agents/skills/` | `~/.copilot/skills/` |
| Goose | `goose` | `.goose/skills/` | `~/.config/goose/skills/` |
| Junie | `junie` | `.junie/skills/` | `~/.junie/skills/` |
| iFlow CLI | `iflow-cli` | `.iflow/skills/` | `~/.iflow/skills/` |
| Kilo Code | `kilo` | `.kilocode/skills/` | `~/.kilocode/skills/` |
| Kiro CLI | `kiro-cli` | `.kiro/skills/` | `~/.kiro/skills/` |
| Kode | `kode` | `.kode/skills/` | `~/.kode/skills/` |
| MCPJam | `mcpjam` | `.mcpjam/skills/` | `~/.mcpjam/skills/` |
| Mistral Vibe | `mistral-vibe` | `.vibe/skills/` | `~/.vibe/skills/` |
| Mux | `mux` | `.mux/skills/` | `~/.mux/skills/` |
| OpenCode | `opencode` | `.agents/skills/` | `~/.config/opencode/skills/` |
| OpenHands | `openhands` | `.openhands/skills/` | `~/.openhands/skills/` |
| Pi | `pi` | `.pi/skills/` | `~/.pi/agent/skills/` |
| Qoder | `qoder` | `.qoder/skills/` | `~/.qoder/skills/` |
| Qwen Code | `qwen-code` | `.qwen/skills/` | `~/.qwen/skills/` |
| Roo Code | `roo` | `.roo/skills/` | `~/.roo/skills/` |
| Trae | `trae` | `.trae/skills/` | `~/.trae/skills/` |
| Trae CN | `trae-cn` | `.trae/skills/` | `~/.trae-cn/skills/` |
| Windsurf | `windsurf` | `.windsurf/skills/` | `~/.codeium/windsurf/skills/` |
| Zencoder | `zencoder` | `.zencoder/skills/` | `~/.zencoder/skills/` |
| Neovate | `neovate` | `.neovate/skills/` | `~/.neovate/skills/` |
| Pochi | `pochi` | `.pochi/skills/` | `~/.pochi/skills/` |
| AdaL | `adal` | `.adal/skills/` | `~/.adal/skills/` |

Note: agents sharing the same project path (e.g., `amp`, `kimi-cli`, `replit`, `universal` all use `.agents/skills/`) are treated as separate entries — the `App` field on the emitted `Item` records which agent name the skill belongs to. Skills found in a shared path are emitted once per matching agent.

## Data Model

Each discovered skill directory produces one `*inventory.Item`:

```
Kind = KindAgentSkill (9)
Name = directory name (e.g. "yaad", "stop-slop")
App = agent identifier (e.g. "claude-code")
Scope = ScopeProject | ScopeSystem
ConfigPath = absolute path to skill directory
ItemIdentity = FNV-64a( app + "/" + kind + "/" + scope + "/" + name + "/" + configPath )
Enabled = nil
Metadata = nil
```

## Scanner Logic

```
for each agentEntry in registry:
for each (scope, dir) in [(ScopeProject, projectDir/agentEntry.ProjectPath),
(ScopeSystem, homeDir/agentEntry.GlobalPath)]:
if !cfg.ScopeEnabled(scope): continue
if dir does not exist: continue
for each entry in ReadDir(dir):
if !entry.IsDir(): continue
emit translate(agentEntry.App, scope, entry.Name(), dir/entry.Name())
```

## Item Identity

Follows the same FNV-64a scheme used by the aitool scanner:

```go
ItemIdentity = fmt.Sprintf("%x", fnv64a(app + "/" + kindStr + "/" + scopeStr + "/" + name + "/" + configPath))
```

## Changes Summary

| File | Change |
|---|---|
| `pkg/inventory/scanners/scanners.go` | Add `KindAgentSkill = "agent-skill"` constant; add descriptor to registry |
| `pkg/inventory/scanners/skills/adapter.go` | New — Scanner implementation |
| `pkg/inventory/scanners/skills/translate.go` | New — skill → Item translation |
| `cmd/endpoint/scan.go` | `RunAITool` pins `[]string{KindAITool, KindAgentSkill}` |

## Testing

- Unit tests in `pkg/inventory/scanners/skills/adapter_test.go` using a temp directory tree
- Unit tests in `pkg/inventory/scanners/skills/translate_test.go` for Item field correctness
- Existing scenario tests and orchestrator tests are unaffected
10 changes: 9 additions & 1 deletion pkg/inventory/scanners/scanners.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/safedep/vet/pkg/aitool"
"github.com/safedep/vet/pkg/inventory"
aitoolscanner "github.com/safedep/vet/pkg/inventory/scanners/aitool"
skillsscanner "github.com/safedep/vet/pkg/inventory/scanners/skills"
)

// Descriptor declares one scanner: the Kind token accepted on the
Expand All @@ -31,7 +32,8 @@ type Descriptor struct {
// Kind values accepted on the --kind flag and consumed by callers that
// pin a specific scanner (e.g. cmd/ai/discover).
const (
KindAITool = "ai-tool"
KindAITool = "ai-tool"
KindAgentSkill = "agent-skill"
)

// registry is the shipped set of scanner declarations. Adding a scanner
Expand All @@ -43,6 +45,12 @@ var registry = []Descriptor{
return aitoolscanner.New(aitool.DefaultRegistry())
},
},
{
Kind: KindAgentSkill,
New: func() inventory.Scanner {
return skillsscanner.New()
},
},
}

// Build returns the scanners matching the requested kind allowlist. An
Expand Down
Loading
Loading