From 6ebe32fdf157551b41da5c66ca6796cfaab83d2a Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Mon, 14 Sep 2026 20:56:34 +0400 Subject: [PATCH] Sync pinned Pi packages via settings --- README.md | 14 +- cmd/dotagents/config.go | 20 +++ cmd/dotagents/inspect.go | 5 +- cmd/dotagents/main.go | 17 ++- cmd/dotagents/pi_packages.go | 134 +++++++++++++++++++ cmd/dotagents/pi_packages_test.go | 176 +++++++++++++++++++++++++ cmd/dotagents/report.go | 22 +++- cmd/dotagents/setup_scaffold.go | 7 +- cmd/dotagents/setup_separation_test.go | 15 +++ cmd/dotagents/sync.go | 3 + docs/troubleshooting.md | 3 +- skills/dotagents/SKILL.md | 2 +- 12 files changed, 406 insertions(+), 12 deletions(-) create mode 100644 cmd/dotagents/pi_packages.go create mode 100644 cmd/dotagents/pi_packages_test.go diff --git a/README.md b/README.md index d442991..35ac08f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,19 @@ Five surfaces, each rendered into the harness's own format — dotagents does no | OMP (pi fork) | yes | yes | yes | --‡ | -- | | Pi* | yes | yes* | yes* | -- | skills + MCP* | -\* Vanilla [pi](https://github.com/earendil-works/pi) gains managed roles through `pi-subagents` and managed MCP/Agent Plugin projection through `pi-mcp-adapter`. Install those Pi packages before using the corresponding surfaces. The OMP fork remains a separate target. +\* Vanilla [pi](https://github.com/earendil-works/pi) gains managed roles through `pi-subagents` and managed MCP/Agent Plugin projection through `pi-mcp-adapter`. A Pi target can also declare a pinned `packages` list; `sync` writes that list to `~/.pi/agent/settings.json`, and Pi installs missing packages on its next startup. Dotagents does not install the Pi executable itself. The OMP fork remains a separate target. + +```yaml +agents: + - name: pi + enabled: true + detect: pi + skill_root: ~/.pi/agent/skills + agent_root: ~/.pi/agent/agents + packages: + - npm:pi-mcp-adapter@2.33.0 + - npm:pi-subagents@0.67.0 +``` † OpenCode reads `~/.agents/skills/` natively; its only hook surface is a JS plugin API. ‡ OMP has no managed hook surface yet; register memory hooks manually if needed. § Qwen Code natively loads Agent Plugins v1 skills and MCP servers; dotagents manages those same surfaces without rewriting the plugin. diff --git a/cmd/dotagents/config.go b/cmd/dotagents/config.go index a5cfc75..868ddb6 100644 --- a/cmd/dotagents/config.go +++ b/cmd/dotagents/config.go @@ -113,6 +113,26 @@ func validateConfig(cfg *config, home string, expand bool) error { return fmt.Errorf("config agent %s is duplicated", cfg.Agents[i].Name) } seen[cfg.Agents[i].Name] = struct{}{} + + if cfg.Agents[i].Packages != nil { + packages := *cfg.Agents[i].Packages + seenPackages := make(map[string]struct{}, len(packages)) + for j, pkg := range packages { + pkg = strings.TrimSpace(pkg) + if pkg == "" { + return fmt.Errorf("config agent %s has an empty package", cfg.Agents[i].Name) + } + if _, ok := seenPackages[pkg]; ok { + return fmt.Errorf("config agent %s has duplicate package %q", cfg.Agents[i].Name, pkg) + } + seenPackages[pkg] = struct{}{} + packages[j] = pkg + } + cfg.Agents[i].Packages = &packages + if cfg.Agents[i].Name != agentPi { + return fmt.Errorf("config agent %s does not support packages", cfg.Agents[i].Name) + } + } } seenExt := make(map[string]struct{}) diff --git a/cmd/dotagents/inspect.go b/cmd/dotagents/inspect.go index c032e78..7791fc4 100644 --- a/cmd/dotagents/inspect.go +++ b/cmd/dotagents/inspect.go @@ -301,6 +301,9 @@ func inspectAgent(agent agentConfig, expected map[string]string, repoRoot string return agentReport{}, err } } + if err := augmentPiPackageReport(&report, agent, home); err != nil { + return agentReport{}, err + } sortReportLists(&report) report.Synced = isReportSynced(report) @@ -389,7 +392,7 @@ func isReportSynced(report agentReport) bool { if len(report.MissingMCP) > 0 || len(report.DriftedMCP) > 0 || len(report.MissingAgent) > 0 || len(report.DriftedAgent) > 0 { return false } - if len(report.MissingHook) > 0 || len(report.DriftedHook) > 0 { + if len(report.MissingHook) > 0 || len(report.DriftedHook) > 0 || len(report.DriftedPackage) > 0 { return false } return report.RootState == "" || report.RootState == stateSynced diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index f440a54..21ffd7d 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -44,12 +44,13 @@ type externalSkillSource struct { } type agentConfig struct { - Name string `yaml:"name"` - Enabled bool `yaml:"enabled"` - SkillRoot string `yaml:"skill_root"` - AgentRoot string `yaml:"agent_root,omitempty"` - Detect string `yaml:"detect,omitempty"` - RoleModel string `yaml:"role_model,omitempty"` + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + SkillRoot string `yaml:"skill_root"` + AgentRoot string `yaml:"agent_root,omitempty"` + Detect string `yaml:"detect,omitempty"` + RoleModel string `yaml:"role_model,omitempty"` + Packages *[]string `yaml:"packages,omitempty"` } // publishTarget declares a remote skill registry to push canonical skills to. @@ -94,10 +95,12 @@ type agentReport struct { ManagedAgent []string ManagedMCP []string ManagedHook []string + ManagedPackage []string Drifted []string DriftedAgent []string DriftedMCP []string DriftedHook []string + DriftedPackage []string Missing []string MissingAgent []string MissingMCP []string @@ -114,8 +117,10 @@ type agentReport struct { UpdatesAgent []string UpdatesMCP []string UpdatesHook []string + UpdatesPackage []string Removes []string RemovesAgent []string + RemovesPackage []string Synced bool } diff --git a/cmd/dotagents/pi_packages.go b/cmd/dotagents/pi_packages.go new file mode 100644 index 0000000..e80c161 --- /dev/null +++ b/cmd/dotagents/pi_packages.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" +) + +func piSettingsPath(home string) string { + return filepath.Join(home, ".pi", "agent", "settings.json") +} + +func augmentPiPackageReport(report *agentReport, agent agentConfig, home string) error { + if agent.Name != agentPi || agent.Packages == nil { + return nil + } + packages := *agent.Packages + + actual, exact, err := readPiPackages(home) + if err != nil { + return err + } + if exact && slices.Equal(actual, packages) { + report.ManagedPackage = append(report.ManagedPackage, packages...) + return nil + } + + report.DriftedPackage = append(report.DriftedPackage, packages...) + if len(packages) == 0 { + report.DriftedPackage = append(report.DriftedPackage, "settings.json packages") + } + if !exact { + report.RemovesPackage = append(report.RemovesPackage, "filtered package entries") + } else { + for _, pkg := range actual { + if !slices.Contains(packages, pkg) { + report.RemovesPackage = append(report.RemovesPackage, pkg) + } + } + } + report.UpdatesPackage = append(report.UpdatesPackage, "settings.json packages") + return nil +} + +// readPiPackages returns exact=false when settings contain filtered object-form +// package entries. Dotagents' string-list declaration intentionally replaces +// those entries so the canonical machine setup remains reproducible. +func readPiPackages(home string) ([]string, bool, error) { + path := piSettingsPath(home) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, true, nil + } + if err != nil { + return nil, false, fmt.Errorf("read %s: %w", path, err) + } + + var raw map[string]interface{} + if err := parseJSONConfig(path, data, &raw); err != nil { + return nil, false, fmt.Errorf("parse %s: %w", path, err) + } + if raw == nil { + return nil, false, fmt.Errorf("parse %s: settings must be a JSON object", path) + } + value, ok := raw["packages"] + if !ok { + return nil, true, nil + } + entries, ok := value.([]interface{}) + if !ok { + return nil, false, nil + } + packages := make([]string, 0, len(entries)) + for _, entry := range entries { + pkg, ok := entry.(string) + if !ok { + return nil, false, nil + } + packages = append(packages, pkg) + } + return packages, true, nil +} + +func syncPiPackages(home string, packages []string) error { + path := piSettingsPath(home) + raw := map[string]interface{}{} + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", path, err) + } + if err == nil { + if err := parseJSONConfig(path, data, &raw); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + if raw == nil { + return fmt.Errorf("parse %s: settings must be a JSON object", path) + } + } + + managed := make([]string, len(packages)) + copy(managed, packages) + raw["packages"] = managed + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + out = append(out, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +func applyAgentPackageSync(reports []agentReport, selected []agentConfig, home string) error { + byName := make(map[string]agentConfig, len(selected)) + for _, agent := range selected { + byName[agent.Name] = agent + } + for _, report := range reports { + agent, ok := byName[report.Name] + if !ok || !report.Detected || agent.Name != agentPi || agent.Packages == nil || len(report.UpdatesPackage) == 0 { + continue + } + if err := syncPiPackages(home, *agent.Packages); err != nil { + return err + } + } + return nil +} diff --git a/cmd/dotagents/pi_packages_test.go b/cmd/dotagents/pi_packages_test.go new file mode 100644 index 0000000..6dbf4b3 --- /dev/null +++ b/cmd/dotagents/pi_packages_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPiPackageSyncPreservesOtherSettings(t *testing.T) { + home := t.TempDir() + path := piSettingsPath(home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{ + "defaultProvider": "openai-codex", + "defaultModel": "gpt-5.6-sol", + "packages": ["npm:old@1.0.0"] +} +`), 0o644); err != nil { + t.Fatal(err) + } + + want := []string{"npm:pi-mcp-adapter@2.33.0", "npm:pi-subagents@0.67.0"} + report := agentReport{Name: agentPi, Detected: true} + agent := agentConfig{Name: agentPi, Packages: &want} + if err := augmentPiPackageReport(&report, agent, home); err != nil { + t.Fatal(err) + } + if len(report.DriftedPackage) != 2 || len(report.UpdatesPackage) != 1 { + t.Fatalf("unexpected drift report: %#v", report) + } + if err := applyAgentPackageSync([]agentReport{report}, []agentConfig{agent}, home); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var settings struct { + DefaultProvider string `json:"defaultProvider"` + DefaultModel string `json:"defaultModel"` + Packages []string `json:"packages"` + } + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatal(err) + } + if settings.DefaultProvider != "openai-codex" || settings.DefaultModel != "gpt-5.6-sol" { + t.Fatalf("unrelated settings changed: %#v", settings) + } + if !reflect.DeepEqual(settings.Packages, want) { + t.Fatalf("packages = %#v, want %#v", settings.Packages, want) + } + + var synced agentReport + if err := augmentPiPackageReport(&synced, agent, home); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(synced.ManagedPackage, want) || len(synced.DriftedPackage) != 0 { + t.Fatalf("unexpected synced report: %#v", synced) + } +} + +func TestPiPackageSyncReplacesFilteredEntries(t *testing.T) { + home := t.TempDir() + path := piSettingsPath(home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"packages":[{"source":"npm:old","skills":[]}]}`), 0o644); err != nil { + t.Fatal(err) + } + + report := agentReport{Name: agentPi, Detected: true} + packages := []string{"npm:new@1.0.0"} + agent := agentConfig{Name: agentPi, Packages: &packages} + if err := augmentPiPackageReport(&report, agent, home); err != nil { + t.Fatal(err) + } + if len(report.DriftedPackage) != 1 || !reflect.DeepEqual(report.RemovesPackage, []string{"filtered package entries"}) { + t.Fatalf("expected filtered entry replacement to be destructive drift: %#v", report) + } +} + +func TestPiPackageSyncReportsExplicitEmptyListDrift(t *testing.T) { + home := t.TempDir() + path := piSettingsPath(home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"packages":["npm:old@1.0.0"]}`), 0o644); err != nil { + t.Fatal(err) + } + + packages := []string{} + report := agentReport{Name: agentPi, Detected: true} + if err := augmentPiPackageReport(&report, agentConfig{Name: agentPi, Packages: &packages}, home); err != nil { + t.Fatal(err) + } + if len(report.DriftedPackage) != 1 || !reflect.DeepEqual(report.RemovesPackage, []string{"npm:old@1.0.0"}) || isReportSynced(report) { + t.Fatalf("explicit empty list must report destructive drift: %#v", report) + } +} + +func TestPiPackageConfigPreservesAbsentVersusEmpty(t *testing.T) { + type wrapper struct { + Agent agentConfig `yaml:"agent"` + } + cases := []struct { + name string + packages *[]string + contains string + }{ + {name: "absent", packages: nil, contains: "skill_root: \"\"\n"}, + {name: "empty", packages: &[]string{}, contains: "packages: []"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data, err := yaml.Marshal(wrapper{Agent: agentConfig{Name: agentPi, Packages: tc.packages}}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), tc.contains) { + t.Fatalf("YAML %q does not contain %q", data, tc.contains) + } + var decoded wrapper + if err := yaml.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if (decoded.Agent.Packages == nil) != (tc.packages == nil) { + t.Fatalf("round trip changed package presence: %q", data) + } + }) + } +} + +func TestPiPackageSyncRejectsNullSettings(t *testing.T) { + home := t.TempDir() + path := piSettingsPath(home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("null\n"), 0o644); err != nil { + t.Fatal(err) + } + packages := []string{"npm:new@1.0.0"} + if err := augmentPiPackageReport(&agentReport{}, agentConfig{Name: agentPi, Packages: &packages}, home); err == nil { + t.Fatal("expected null settings to be rejected") + } + if err := syncPiPackages(home, packages); err == nil { + t.Fatal("expected null settings to be rejected without panic") + } +} + +func TestPackagesArePiOnly(t *testing.T) { + home := t.TempDir() + cfg := config{Agents: []agentConfig{{ + Name: agentCodex, + Enabled: true, + SkillRoot: filepath.Join(home, ".codex", "skills"), + Packages: packageList("npm:example@1.0.0"), + }}} + if err := validateConfig(&cfg, home, false); err == nil { + t.Fatal("expected non-Pi packages to be rejected") + } +} + +func packageList(values ...string) *[]string { + return &values +} diff --git a/cmd/dotagents/report.go b/cmd/dotagents/report.go index b4d6516..1c2828a 100644 --- a/cmd/dotagents/report.go +++ b/cmd/dotagents/report.go @@ -85,6 +85,9 @@ func printReport(mode string, repoRoot string, repoReport repoLinkReport, report if len(report.ManagedHook)+len(report.MissingHook)+len(report.DriftedHook)+len(report.UnsupportedHook) > 0 { fmt.Printf(" hook managed (%d): %s\n", len(report.ManagedHook), displayList(report.ManagedHook)) } + if len(report.ManagedPackage)+len(report.DriftedPackage) > 0 { + fmt.Printf(" package managed (%d): %s\n", len(report.ManagedPackage), displayList(report.ManagedPackage)) + } if len(report.Missing) > 0 { fmt.Printf(" missing (%d): %s\n", len(report.Missing), displayList(report.Missing)) } @@ -109,6 +112,9 @@ func printReport(mode string, repoRoot string, repoReport repoLinkReport, report if len(report.DriftedHook) > 0 { fmt.Printf(" hook drifted (%d): %s\n", len(report.DriftedHook), displayList(report.DriftedHook)) } + if len(report.DriftedPackage) > 0 { + fmt.Printf(" package drifted (%d): %s\n", len(report.DriftedPackage), displayList(report.DriftedPackage)) + } if len(report.UnsupportedHook) > 0 { fmt.Printf(" hook unsupported (%d): %s\n", len(report.UnsupportedHook), displayList(report.UnsupportedHook)) } @@ -119,7 +125,7 @@ func printReport(mode string, repoRoot string, repoReport repoLinkReport, report fmt.Printf(" conflicts (%d): %s\n", len(report.Conflicts), displayList(report.Conflicts)) } if mode == "sync" { - fmt.Printf(" sync actions: add=%d update=%d remove=%d agent-add=%d agent-update=%d agent-remove=%d mcp-add=%d mcp-update=%d hook-add=%d hook-update=%d\n", len(report.Adds), len(report.Updates), len(report.Removes), len(report.AddsAgent), len(report.UpdatesAgent), len(report.RemovesAgent), len(report.AddsMCP), len(report.UpdatesMCP), len(report.AddsHook), len(report.UpdatesHook)) + fmt.Printf(" sync actions: add=%d update=%d remove=%d agent-add=%d agent-update=%d agent-remove=%d mcp-add=%d mcp-update=%d hook-add=%d hook-update=%d package-update=%d package-remove=%d\n", len(report.Adds), len(report.Updates), len(report.Removes), len(report.AddsAgent), len(report.UpdatesAgent), len(report.RemovesAgent), len(report.AddsMCP), len(report.UpdatesMCP), len(report.AddsHook), len(report.UpdatesHook), len(report.UpdatesPackage), len(report.RemovesPackage)) } fmt.Println() } @@ -137,15 +143,19 @@ func sortReportLists(report *agentReport) { sort.Strings(report.ManagedAgent) sort.Strings(report.ManagedMCP) sort.Strings(report.ManagedHook) + sort.Strings(report.ManagedPackage) sort.Strings(report.Drifted) sort.Strings(report.DriftedAgent) sort.Strings(report.DriftedMCP) sort.Strings(report.DriftedHook) + sort.Strings(report.DriftedPackage) sort.Strings(report.Missing) sort.Strings(report.MissingAgent) sort.Strings(report.MissingMCP) sort.Strings(report.MissingHook) sort.Strings(report.UnsupportedHook) + sort.Strings(report.UpdatesPackage) + sort.Strings(report.RemovesPackage) sort.Strings(report.Conflicts) sort.Strings(report.StaleManaged) sort.Strings(report.External) @@ -183,6 +193,8 @@ func restoreSyncActions(current []agentReport, preflight []agentReport) { current[i].RemovesAgent = append([]string{}, original.RemovesAgent...) current[i].UpdatesMCP = append([]string{}, original.UpdatesMCP...) current[i].UpdatesHook = append([]string{}, original.UpdatesHook...) + current[i].UpdatesPackage = append([]string{}, original.UpdatesPackage...) + current[i].RemovesPackage = append([]string{}, original.RemovesPackage...) current[i].Removes = append([]string{}, original.Removes...) } } @@ -395,6 +407,9 @@ func surfaceCounts(r agentReport) string { if n := len(r.ManagedHook); n > 0 { parts = append(parts, fmt.Sprintf("%d hooks", n)) } + if n := len(r.ManagedPackage); n > 0 { + parts = append(parts, fmt.Sprintf("%d packages", n)) + } out := strings.Join(parts, " · ") if n := len(r.External); n > 0 { out += fmt.Sprintf(" (+%d external)", n) @@ -421,6 +436,8 @@ func driftBuckets(r agentReport) []driftBucket { {"hooks drifted", r.DriftedHook}, {"hooks missing", r.MissingHook}, {"hooks unsupported", r.UnsupportedHook}, + {"packages drifted", r.DriftedPackage}, + {"packages removed", r.RemovesPackage}, {"conflicts", r.Conflicts}, } } @@ -438,6 +455,9 @@ func printVerboseSurfaceLists(report agentReport) { if len(report.ManagedHook) > 0 { fmt.Printf(" hooks (%d): %s\n", len(report.ManagedHook), displayList(report.ManagedHook)) } + if len(report.ManagedPackage) > 0 { + fmt.Printf(" packages (%d): %s\n", len(report.ManagedPackage), displayList(report.ManagedPackage)) + } if len(report.External) > 0 { fmt.Printf(" external (%d): %s\n", len(report.External), displayList(report.External)) } diff --git a/cmd/dotagents/setup_scaffold.go b/cmd/dotagents/setup_scaffold.go index b381458..37f513c 100644 --- a/cmd/dotagents/setup_scaffold.go +++ b/cmd/dotagents/setup_scaffold.go @@ -712,7 +712,7 @@ func confirmDestructiveSyncActions(reports []agentReport, streams setupIO) { if !r.Detected { continue } - if len(r.Removes)+len(r.RemovesAgent)+len(r.UpdatesAgent) == 0 { + if len(r.Removes)+len(r.RemovesAgent)+len(r.UpdatesAgent)+len(r.RemovesPackage) == 0 { continue } fmt.Fprintf(streams.out, "\n%s has existing content this sync would change:\n", r.Name) @@ -725,11 +725,16 @@ func confirmDestructiveSyncActions(reports []agentReport, streams setupIO) { if len(r.UpdatesAgent) > 0 { fmt.Fprintf(streams.out, " overwrite %d agent role(s) in %s: %s\n", len(r.UpdatesAgent), r.AgentRoot, strings.Join(r.UpdatesAgent, ", ")) } + if len(r.RemovesPackage) > 0 { + fmt.Fprintf(streams.out, " remove %d Pi package declaration(s): %s\n", len(r.RemovesPackage), strings.Join(r.RemovesPackage, ", ")) + } if !promptYesNoDefaultNo(streams, fmt.Sprintf("Apply these changes to %s?", r.Name)) { fmt.Fprintf(streams.out, "%s: keeping existing content; removals and overwrites skipped this run\n", r.Name) r.Removes = nil r.RemovesAgent = nil r.UpdatesAgent = nil + r.RemovesPackage = nil + r.UpdatesPackage = nil } } } diff --git a/cmd/dotagents/setup_separation_test.go b/cmd/dotagents/setup_separation_test.go index 938c5e9..68e6e8d 100644 --- a/cmd/dotagents/setup_separation_test.go +++ b/cmd/dotagents/setup_separation_test.go @@ -620,6 +620,21 @@ func TestConfirmDestructiveSyncActionsDeclineClearsPlan(t *testing.T) { } } +func TestConfirmDestructiveSyncActionsDeclinePackageRemoval(t *testing.T) { + reports := []agentReport{{ + Name: "pi", Detected: true, + RemovesPackage: []string{"npm:old@1.0.0"}, UpdatesPackage: []string{"settings.json packages"}, + }} + var out bytes.Buffer + confirmDestructiveSyncActions(reports, setupIO{in: strings.NewReader("n\n"), out: &out}) + if len(reports[0].RemovesPackage) != 0 || len(reports[0].UpdatesPackage) != 0 { + t.Fatalf("declined package removal kept replacement plan: %#v", reports[0]) + } + if !strings.Contains(out.String(), "remove 1 Pi package declaration(s): npm:old@1.0.0") { + t.Fatalf("missing package removal preview:\n%s", out.String()) + } +} + func TestConfirmDestructiveSyncActionsAcceptKeepsPlan(t *testing.T) { reports := []agentReport{{ Name: "codex", Detected: true, SkillRoot: "/tmp/skills", diff --git a/cmd/dotagents/sync.go b/cmd/dotagents/sync.go index 18db54d..33c91ee 100644 --- a/cmd/dotagents/sync.go +++ b/cmd/dotagents/sync.go @@ -116,6 +116,9 @@ func runSync(opts runOptions) error { if err := applyAgentRootInstructionSync(reports); err != nil { return err } + if err := applyAgentPackageSync(reports, selected, home); err != nil { + return err + } repoReport, err = inspectRepoLink(repoRoot, home) if err != nil { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b727ce3..d6fb135 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,7 +1,8 @@ # Troubleshooting - **`dotagents doctor`** is the first stop: it validates skill frontmatter, role definitions, lock pins, materialized copies, hook registration, and audits external sources. -- **Pi MCP entries do not appear:** install `pi-mcp-adapter`, keep the canonical server targeted at `pi`, run `dotagents sync`, then restart Pi or run `/reload`. dotagents writes only its named entries under `~/.pi/agent/mcp.json` and preserves adapter-specific settings. +- **Pi MCP entries do not appear:** install `pi-mcp-adapter` or declare its pinned source under the Pi target's `packages`, keep the canonical server targeted at `pi`, run `dotagents sync`, then restart Pi or run `/reload`. Dotagents writes only its named entries under `~/.pi/agent/mcp.json` and preserves adapter-specific settings. +- **Pi packages are listed but not installed:** dotagents manages the `packages` declaration in `~/.pi/agent/settings.json`, not the Pi executable or npm runtime. Install Pi first, run `dotagents sync`, then start Pi once so its package manager installs missing declarations. - **A sync proposed removals you didn't expect:** setup-driven syncs always preview removals per harness and default to keeping your files; answer `n` and inspect with `dotagents status`. - **"exists but is not a symlink" conflict on a root instruction file:** a real file occupies the harness's memory path (e.g. `~/.claude/CLAUDE.md`). dotagents never overwrites it silently — migrate the content into `~/.agents/AGENTS.md`, delete the real file, and rerun `dotagents sync`. - **Memory tools skipped during sync:** no Go toolchain on PATH. Install Go, or copy prebuilt binaries to `$GOBIN`/`~/.local/bin` manually; sync will manage them from then on (rebuilds only when sources change). diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 40a7336..ebf9d44 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -106,7 +106,7 @@ Agent roles are canonical Markdown files under `~/.agents/agents/` and render to - OMP: `~/.omp/agent/agents/.md` - Qwen Code: `~/.qwen/agents/.md` -Pi always has managed skills. With `pi-subagents` installed, dotagents renders canonical roles into Pi's user agent directory. With `pi-mcp-adapter` installed, it patches canonical and Agent Plugin MCP entries into `~/.pi/agent/mcp.json`. The files remain inert when those packages are absent. OMP is a separate target. +Pi always has managed skills. With `pi-subagents` installed, dotagents renders canonical roles into Pi's user agent directory. With `pi-mcp-adapter` installed, it patches canonical and Agent Plugin MCP entries into `~/.pi/agent/mcp.json`. Put pinned package sources under the Pi target's `packages` list to make `sync` reconcile `~/.pi/agent/settings.json`; Pi installs missing declared packages at startup. Dotagents does not install the Pi executable. The role and MCP files remain inert when their packages are absent. OMP is a separate target. For MCP servers, sync patches only named canonical entries and preserves unrelated native servers. Import redacts literal environment values to `${KEY}` references; list output never prints values.