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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions cmd/dotagents/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
5 changes: 4 additions & 1 deletion cmd/dotagents/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions cmd/dotagents/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -114,8 +117,10 @@ type agentReport struct {
UpdatesAgent []string
UpdatesMCP []string
UpdatesHook []string
UpdatesPackage []string
Removes []string
RemovesAgent []string
RemovesPackage []string
Synced bool
}

Expand Down
134 changes: 134 additions & 0 deletions cmd/dotagents/pi_packages.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading