From e5e0b89e1322e16c3944fdbe75bbf25a082abe6c Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:52:43 +0400 Subject: [PATCH 1/3] skill list/info: provenance transparency surface + README interop notes --- README.md | 20 ++- cmd/dotagents/main.go | 11 +- cmd/dotagents/skill_list.go | 286 +++++++++++++++++++++++++++++++ cmd/dotagents/skill_list_test.go | 130 ++++++++++++++ 4 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 cmd/dotagents/skill_list.go create mode 100644 cmd/dotagents/skill_list_test.go diff --git a/README.md b/README.md index 99e4411..0791199 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,30 @@ 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 ` 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: + +```bash +npx skills add yourconscience/myagents -s dotagents # verified: discovers and copies cleanly +``` + +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 ` → `$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. diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 95ea75b..c72c794 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -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": @@ -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.") @@ -494,7 +498,8 @@ func printAllUsage() { fmt.Println(" dotagents sync [--pull] [--agents ...]") fmt.Println(" dotagents doctor [--e2e] [--agents ...]") fmt.Println(" dotagents view [hk serve flags: --port N, --host ADDR, --no-token]") - fmt.Println(" dotagents skill new [--description ...]") + fmt.Println(" dotagents skill list [--agents ...]") + fmt.Println(" dotagents skill info ") fmt.Println(" dotagents skill update [name ...]") fmt.Println(" dotagents skill promote [--dry-run]") fmt.Println(" dotagents mcp [options]") diff --git a/cmd/dotagents/skill_list.go b/cmd/dotagents/skill_list.go new file mode 100644 index 0000000..8323c8b --- /dev/null +++ b/cmd/dotagents/skill_list.go @@ -0,0 +1,286 @@ +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) + } + } + if len(directSources) > 0 { + set, err := discoverExternalSkillSet(directSources, home) + if err == nil { + for name, skill := range set { + if _, ok := expected[name]; ok { + origins[name] = skill.Origin + } + } + } + // An uncloned cache only degrades the label; sync and doctor report + // the missing clone authoritatively, so this stays best-effort. + } + 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 +} + +// 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-pinned)\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 h.IntegrationNote != "" { + fmt.Printf(" integration: %s\n", h.IntegrationNote) + } + fmt.Printf(" managed (%d): %s\n", len(report.Managed), displayList(report.Managed)) + } 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") + } + 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 + } + 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 +} diff --git a/cmd/dotagents/skill_list_test.go b/cmd/dotagents/skill_list_test.go new file mode 100644 index 0000000..518c38c --- /dev/null +++ b/cmd/dotagents/skill_list_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOwnerRepo(t *testing.T) { + cases := []struct { + url string + want string + }{ + {"https://example.invalid/solo", "example.invalid/solo"}, + {"https://github.com/mattpocock/skills/", "mattpocock/skills"}, + {"https://github.com/mattpocock/skills.git", "mattpocock/skills"}, + {"git@github.com:yourconscience/myagents.git", "yourconscience/myagents"}, + } + for _, tc := range cases { + if got := ownerRepo(tc.url); got != tc.want { + t.Errorf("ownerRepo(%q) = %q, want %q", tc.url, got, tc.want) + } + } +} + +func TestSkillOriginsFromLockAndConfig(t *testing.T) { + repoRoot := t.TempDir() + home := t.TempDir() + + agentsDir := filepath.Join(repoRoot, "skills", "grilling") + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + t.Fatal(err) + } + expected := map[string]string{ + "grilling": agentsDir, + "local-skill": filepath.Join(repoRoot, "skills", "local-skill"), + "direct-skill": filepath.Join(home, ".agents", "external", "vercel", "skills", "direct-skill"), + } + + lock := lockFile{Version: 1, ExternalSkills: []externalLockEntry{{ + Name: "skills", + URL: "https://github.com/mattpocock/skills", + Branch: "main", + Commit: "9603c1cc8118d08bc1b3bf34cf714f62178dea3b", + Materialized: newMaterializedSkillNames([]string{"grilling"}), + }}} + if err := writeLockFile(repoRoot, lock); err != nil { + t.Fatal(err) + } + + cfg := config{ + ExternalSkills: []externalSkillSource{{ + URL: "https://github.com/vercel-labs/agent-skills", + Branch: "main", + Skills: []string{"direct-skill"}, + }}, + } + origins, err := skillOrigins(cfg, repoRoot, home, expected) + if err != nil { + t.Fatal(err) + } + if got := origins["grilling"]; got != "mattpocock/skills@9603c1c" { + t.Fatalf("grilling origin = %q, want mattpocock/skills@9603c1c", got) + } + if got := origins["local-skill"]; got != "" { + t.Fatalf("local-skill origin = %q, want empty", got) + } +} + +func TestSkillProvenanceClassification(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "harness-skills") + canonical := filepath.Join(home, "canonical", "real-skill") + other := filepath.Join(home, "elsewhere", "gone-skill") + for _, dir := range []string{root, canonical, other} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + managedLink := filepath.Join(root, "managed") + if err := os.Symlink(canonical, managedLink); err != nil { + t.Fatal(err) + } + foreignLink := filepath.Join(root, "foreign") + if err := os.Symlink(other, foreignLink); err != nil { + t.Fatal(err) + } + brokenLink := filepath.Join(root, "broken") + if err := os.Symlink(filepath.Join(home, "does-not-exist"), brokenLink); err != nil { + t.Fatal(err) + } + unmanaged := filepath.Join(root, "unmanaged") + if err := os.MkdirAll(unmanaged, 0o755); err != nil { + t.Fatal(err) + } + + report := agentReport{ + Name: "test-agent", + SkillRoot: root, + Managed: []string{"managed"}, + External: []string{"foreign", "broken", "unmanaged"}, + Missing: []string{"absent"}, + Drifted: []string{"drifted"}, + } + driftedLink := filepath.Join(root, "drifted") + if err := os.Symlink(other, driftedLink); err != nil { + t.Fatal(err) + } + + origins := map[string]string{"managed": "owner/repo@abc1234"} + cases := map[string]string{ + "managed": "managed (external: owner/repo@abc1234)", + "foreign": "foreign symlink -> " + other, + "broken": "broken symlink -> " + filepath.Join(home, "does-not-exist"), + "unmanaged": "unmanaged dir", + "absent": "missing (not linked)", + "drifted": "drifted symlink -> " + other, + } + for name, want := range cases { + if got := skillProvenance(name, report, origins, home); got != want { + t.Errorf("skillProvenance(%q) = %q, want %q", name, got, want) + } + } +} + +func TestSkillInfoRejectsMissingName(t *testing.T) { + if err := runSkillInfo(nil); err == nil || err.Error() != "skill info requires a skill name" { + t.Fatalf("runSkillInfo(nil) = %v, want name error", err) + } +} From 877efb02adfd6874ddedccbcfde348823759f0a6 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:18:47 +0400 Subject: [PATCH 2/3] README: use --copy in skills.sh interop command --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 0791199..eab81b0 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,7 @@ dotagents mcp list|add|import|remove ## 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: - -```bash -npx skills add yourconscience/myagents -s dotagents # verified: discovers and copies cleanly +npx skills add yourconscience/myagents -s dotagents --copy # verified: copies cleanly, no symlinks ``` 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. From 32d190bcd4a9302f8f9a7c9cc5df2610af2dcfe2 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:24:59 +0400 Subject: [PATCH 3/3] address review: restore skill new in help, pin direct-source provenance, surface missing config-driven integration --- cmd/dotagents/main.go | 1 + cmd/dotagents/skill_list.go | 51 +++++++++++++++++++++++++++++-------- skills/dotagents/SKILL.md | 2 ++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index c72c794..f3bd61b 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -498,6 +498,7 @@ func printAllUsage() { fmt.Println(" dotagents sync [--pull] [--agents ...]") fmt.Println(" dotagents doctor [--e2e] [--agents ...]") fmt.Println(" dotagents view [hk serve flags: --port N, --host ADDR, --no-token]") + fmt.Println(" dotagents skill new [--description ...]") fmt.Println(" dotagents skill list [--agents ...]") fmt.Println(" dotagents skill info ") fmt.Println(" dotagents skill update [name ...]") diff --git a/cmd/dotagents/skill_list.go b/cmd/dotagents/skill_list.go index 8323c8b..77c5aa1 100644 --- a/cmd/dotagents/skill_list.go +++ b/cmd/dotagents/skill_list.go @@ -32,17 +32,23 @@ func skillOrigins(cfg config, repoRoot string, home string, expected map[string] directSources = append(directSources, src) } } - if len(directSources) > 0 { - set, err := discoverExternalSkillSet(directSources, home) - if err == nil { - for name, skill := range set { - if _, ok := expected[name]; ok { - origins[name] = skill.Origin - } + 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 } } - // An uncloned cache only degrades the label; sync and doctor report - // the missing clone authoritatively, so this stays best-effort. } return origins, nil } @@ -73,6 +79,24 @@ func ownerRepo(url string) string { 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 { @@ -177,7 +201,7 @@ func runSkillList(args []string) error { } } fmt.Printf("dotagents skill list\n") - fmt.Printf("repo: %s (%d canonical skills: %d local, %d external-pinned)\n", repoRoot, len(expected), localCount, len(expected)-localCount) + 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() @@ -188,6 +212,9 @@ func runSkillList(args []string) error { } 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) } @@ -267,6 +294,10 @@ func runSkillInfo(args []string) error { 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 diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 1442356..880c0bc 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -24,6 +24,8 @@ dotagents sync [--pull] [--agents ...] dotagents doctor [--e2e] [--agents ...] dotagents view [--port N] [--host ADDR] dotagents skill new [--description ...] +dotagents skill list [--agents ...] +dotagents skill info dotagents skill update [name ...] dotagents skill promote [--dry-run] dotagents mcp [options]