Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,28 @@ dotagents status [--agents ...]
dotagents sync [--pull] [--agents ...]
dotagents doctor [--e2e] [--agents ...]
dotagents view [--port N] [--host ADDR] # launch HarnessKit (inspection UI)
dotagents skill new|update|promote
dotagents skill new|list|info|update|promote
dotagents mcp list|add|import|remove
```

## Inspecting your skill roots

`dotagents skill list` shows, per detected harness, every entry in its skill root with provenance: managed links (with the external source and pinned commit when applicable), foreign symlinks (other tools' plugins), unmanaged directories, drifted and broken links — plus the estimated context cost of each harness's skill listing. `dotagents skill info <name>` answers "where does this skill come from and who sees it".

`dotagents view` shells out to [HarnessKit](https://github.com/RealZST/HarnessKit) (`hk serve`) for an inspection UI over every detected harness — skills, MCP servers, hooks, and configs in one place. HarnessKit does its own harness discovery and can also enable/disable/deploy; those writes bypass dotagents, so use `view` to inspect and reconcile any changes with `dotagents sync`. Install HarnessKit separately.

## Installing skills without dotagents

A dotagents-format repo also works as a plain skills source. Anyone can copy individual skills into their harness of choice with the skills.sh installer, no dotagents install needed:
npx skills add yourconscience/myagents -s dotagents --copy # verified: copies cleanly, no symlinks
```
Comment on lines +98 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the opening fence for the installer command

The installer command has a closing code fence but no opening fence. Markdown therefore treats this fence as the start of a code block and renders the following “Not to be confused with,” “Configuration,” and “Releases” sections as code until a later fence closes it. Add an opening fenced-code marker before the npx command so the remainder of the README renders normally.

Useful? React with 👍 / 👎.


That path copies editable files (the "fork" model); dotagents users get the symlink-to-canonical model with lock-pinned updates. Pick one per machine — installing both leaves you with every skill twice.

## Not to be confused with

Other tools share the name: npm's [`dotagents`](https://www.npmjs.com/package/dotagents) (@iannuttall) and Sentry's [`@sentry/dotagents`](https://www.npmjs.com/package/@sentry/dotagents) skill vendoring CLI. This repo is `yourconscience/dotagents` — install as `brew install yourconscience/tap/dotagents` or `npm i -g @your_conscience/dotagents`.

## Configuration

`~/.agents/dotagents.yaml` is the single source of truth; `setup` fills in detected harnesses. Resolution order: `--config <path>` → `$DOTAGENTS_HOME/dotagents.yaml` → `~/.agents/dotagents.yaml`; never walks the current project. Machine-local entries overlay via `dotagents.local.yaml`. Managed entries are marked in native configs; anything else is left untouched.
Expand Down
10 changes: 8 additions & 2 deletions cmd/dotagents/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,17 @@ func runDoctorCommand(args []string) error {

func runSkillCommand(args []string) error {
if len(args) == 0 {
return errors.New("skill requires subcommand: new, update, promote")
return errors.New("skill requires subcommand: new, list, info, update, promote")
}
switch args[0] {
case "new":
return runSkillify(args[1:])
case "update":
return runExternalUpdate(args[1:])
case "list":
return runSkillList(args[1:])
case "info":
return runSkillInfo(args[1:])
case "promote":
return runPromote(args[1:])
case "external":
Expand Down Expand Up @@ -479,7 +483,7 @@ func printUsage() {
fmt.Println(" doctor Check pins, dependencies, and local health")
fmt.Println()
fmt.Println("Command groups:")
fmt.Println(" skill Create, update, and promote skills")
fmt.Println(" skill Inspect, create, update, and promote skills")
fmt.Println(" mcp Manage MCP servers")
fmt.Println()
fmt.Println("Run \"dotagents help --all\" for flags, maintenance commands, and compatibility aliases.")
Expand All @@ -495,6 +499,8 @@ func printAllUsage() {
fmt.Println(" dotagents doctor [--e2e] [--agents ...]")
fmt.Println(" dotagents view [hk serve flags: --port N, --host ADDR, --no-token]")
fmt.Println(" dotagents skill new <name> [--description ...]")
fmt.Println(" dotagents skill list [--agents ...]")
fmt.Println(" dotagents skill info <name>")
Comment thread
yourconscience marked this conversation as resolved.
fmt.Println(" dotagents skill update [name ...]")
fmt.Println(" dotagents skill promote <name-or-path> [--dry-run]")
fmt.Println(" dotagents mcp <list|add|import|remove> [options]")
Expand Down
317 changes: 317 additions & 0 deletions cmd/dotagents/skill_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
package main

import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)

// skillOrigins maps a canonical skill name to a human-readable provenance
// label ("local" or "owner/repo@commit" for external sources). Computed from
// dotagents.lock plus the configured external sources, never invented.
func skillOrigins(cfg config, repoRoot string, home string, expected map[string]string) (map[string]string, error) {
origins := make(map[string]string)
lock, err := readLockFile(repoRoot)
if err != nil {
return nil, err
}
for _, entry := range lock.ExternalSkills {
label := fmt.Sprintf("%s@%s", ownerRepo(entry.URL), shortSha(entry.Commit))
for _, name := range entry.Materialized.Values() {
if _, ok := expected[name]; ok {
origins[name] = label
}
}
}
directSources := make([]externalSkillSource, 0, len(cfg.ExternalSkills))
for _, src := range cfg.ExternalSkills {
if !src.Materialize {
directSources = append(directSources, src)
}
}
for _, src := range directSources {
set, err := discoverExternalSourceSkills(src, home)
if err != nil {
// An uncloned cache only degrades the label; sync and doctor
// report the missing clone authoritatively, so this stays
// best-effort.
continue
}
label := fmt.Sprintf("%s (unpinned)", ownerRepo(src.URL))
if entry := lockEntryFor(lock, src); entry != nil {
label = fmt.Sprintf("%s@%s", ownerRepo(src.URL), shortSha(entry.Commit))
}
for _, skill := range set {
if _, ok := expected[skill.Name]; ok {
origins[skill.Name] = label
}
}
}
return origins, nil
}

func shortSha(commit string) string {
if len(commit) > 7 {
return commit[:7]
}
return commit
}

// ownerRepo renders "owner/repo" from a git URL; repoName alone is ambiguous
// for personal skill repos whose last path segment is just "skills".
func ownerRepo(url string) string {
trimmed := strings.TrimSpace(url)
if _, after, ok := strings.Cut(trimmed, "://"); ok {
trimmed = after
}
trimmed = strings.TrimPrefix(trimmed, "git@")
if _, after, found := strings.Cut(trimmed, ":"); found {
trimmed = after
}
trimmed = strings.TrimSuffix(strings.TrimSuffix(trimmed, "/"), ".git")
parts := strings.Split(trimmed, "/")
if len(parts) >= 2 {
return strings.Join(parts[len(parts)-2:], "/")
}
return trimmed
}

// integrationMissing returns report.Missing entries that are integration-level
// messages rather than skill names. Config-driven harnesses (Amp, Hermes,
// Qwen) record these when their skills configuration is absent, while still
// listing every expected skill as managed.
func integrationMissing(report agentReport) []string {
expectedSet := make(map[string]bool, len(report.ExpectedSkills))
for name := range report.ExpectedSkills {
expectedSet[name] = true
}
var out []string
for _, item := range report.Missing {
if !expectedSet[item] {
out = append(out, item)
}
}
return out
}

// skillProvenance renders one detail line for a skill in a harness skill root,
// based on the inspect report plus the symlink target where it matters.
func skillProvenance(name string, report agentReport, origins map[string]string, home string) string {
origin := origins[name]
linkPath := filepath.Join(report.SkillRoot, name)
switch {
case containsString(report.Managed, name):
if origin != "" {
return fmt.Sprintf("managed (external: %s)", origin)
}
return "managed (local)"
case containsString(report.Drifted, name):
return "drifted symlink -> " + symlinkTarget(linkPath)
case containsString(report.Missing, name):
return "missing (not linked)"
case containsString(report.StaleManaged, name):
return "stale managed (links into the store but is not expected)"
case containsString(report.External, name):
return describeExternalSkillEntry(linkPath, home)
case conflictMentions(report.Conflicts, linkPath):
return "conflict (real dir, differs from canonical)"
default:
return "unclassified"
}
}

// conflictMentions reports whether any conflict detail names the skill's path
// in this harness root; inspect stores full sentences, not bare names.
func conflictMentions(conflicts []string, linkPath string) bool {
for _, conflict := range conflicts {
if strings.Contains(conflict, linkPath) {
return true
}
}
return false
}

func describeExternalSkillEntry(path string, home string) string {
info, err := os.Lstat(path)
if err != nil {
return "unreadable"
}
if info.Mode()&os.ModeSymlink == 0 {
return "unmanaged dir"
}
target := symlinkTarget(path)
if _, err := os.Stat(path); err != nil {
return "broken symlink -> " + target
}
if isExternalSkillLink(path, target, home) {
return "external cache link -> " + target
}
return "foreign symlink -> " + target
}

func symlinkTarget(linkPath string) string {
raw, err := os.Readlink(linkPath)
if err != nil {
return "?"
}
return raw
}

func containsString(list []string, needle string) bool {
for _, item := range list {
if item == needle {
return true
}
}
return false
}

// runSkillList prints, per detected harness, every entry in its skill root
// with provenance: where dotagents put it, where anything else came from,
// and which links are drifted, stale, or broken. Read-only.
func runSkillList(args []string) error {
opts, err := parseSubcommandFlags("skill list", args)
if err != nil {
return err
}
repoRoot, home, cfg, selected, err := loadContext(opts)
if err != nil {
return err
}
expected, err := expectedSkills(repoRoot, home, cfg)
if err != nil {
return err
}
reports, err := inspectAgents(selected, expected, repoRoot, home, cfg)
if err != nil {
return err
}
origins, err := skillOrigins(cfg, repoRoot, home, expected)
if err != nil {
return err
}

localCount := 0
for name := range expected {
if origins[name] == "" {
localCount++
}
}
fmt.Printf("dotagents skill list\n")
fmt.Printf("repo: %s (%d canonical skills: %d local, %d external)\n", repoRoot, len(expected), localCount, len(expected)-localCount)

for _, report := range reports {
fmt.Println()
fmt.Printf("%s (%s)\n", report.Name, report.SkillRoot)
if !report.Detected {
fmt.Println(" not detected (binary not on PATH)")
continue
}
h := harnessFor(report.Name)
if h != nil && h.Skills == SkillsConfigDriven {
if missing := integrationMissing(report); len(missing) > 0 {
fmt.Printf(" integration missing: %s\n", displayList(missing))
}
if h.IntegrationNote != "" {
fmt.Printf(" integration: %s\n", h.IntegrationNote)
}
fmt.Printf(" managed (%d): %s\n", len(report.Managed), displayList(report.Managed))
Comment thread
yourconscience marked this conversation as resolved.
} else {
names := sortedKeys(report.ExpectedSkills)
names = append(names, report.External...)
names = append(names, report.StaleManaged...)
sort.Strings(names)
names = dedupeStrings(names)
printed := 0
for _, name := range names {
if strings.HasPrefix(name, ".") {
continue
}
fmt.Printf(" %-24s %s\n", name, skillProvenance(name, report, origins, home))
printed++
}
if printed == 0 {
fmt.Println(" (empty skill root)")
}
}
listingBytes := skillListingBytes(report.ExpectedSkills)
fmt.Printf(" skill listing context: %d skills, %d bytes name+desc, %s\n", len(report.ExpectedSkills), listingBytes, formatTokenEstimate(estimateTokens(listingBytes)))
}
return nil
}

// runSkillInfo prints canonical provenance and per-harness state for one
// skill: where the canonical copy lives, which source pinned it, and how
// every detected harness currently sees it.
func runSkillInfo(args []string) error {
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
return errors.New("skill info requires a skill name")
Comment thread
yourconscience marked this conversation as resolved.
}
name := args[0]
opts, err := parseSubcommandFlags("skill info", args[1:])
if err != nil {
return err
}
repoRoot, home, cfg, selected, err := loadContext(opts)
if err != nil {
return err
}
expected, err := expectedSkills(repoRoot, home, cfg)
if err != nil {
return err
}
canonical, ok := expected[name]
if !ok {
return fmt.Errorf("skill %q is not in the canonical skill set", name)
}
origins, err := skillOrigins(cfg, repoRoot, home, expected)
if err != nil {
return err
}

fmt.Printf("dotagents skill info %s\n", name)
origin := origins[name]
if origin != "" {
fmt.Printf("canonical: %s (external: %s)\n", canonical, origin)
} else {
fmt.Printf("canonical: %s (local)\n", canonical)
}

single := map[string]string{name: canonical}
listingBytes := skillListingBytes(single)
fmt.Printf("SKILL.md listing: %d bytes name+desc, %s\n", listingBytes, formatTokenEstimate(estimateTokens(listingBytes)))

reports, err := inspectAgents(selected, single, repoRoot, home, cfg)
if err != nil {
return err
}
for _, report := range reports {
fmt.Printf(" %-14s ", report.Name)
if !report.Detected {
fmt.Println("not detected")
continue
}
if missing := integrationMissing(report); len(missing) > 0 {
fmt.Printf("%s: integration missing: %s\n", report.SkillRoot, displayList(missing))
continue
}
fmt.Printf("%s: %s\n", report.SkillRoot, skillProvenance(name, report, origins, home))
}
return nil
}

func dedupeStrings(items []string) []string {
seen := make(map[string]bool, len(items))
out := items[:0]
for _, item := range items {
if seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
Loading
Loading