Skip to content
Open
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ dotagents setup [--memory off|basic|memsearch] [--yes] [--dry-run] [--json]
dotagents status [--verbose] [--agents ...]
dotagents sync [--pull] [--agents ...]
dotagents doctor [--e2e] [--agents ...]
dotagents config # Bubble Tea canonical YAML editor
dotagents config serve # loopback web editor
dotagents config validate|print
dotagents view [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] # launch HarnessKit (inspection UI)
dotagents skill new|list|info|update|promote
dotagents mcp list|add|import|remove
Expand Down Expand Up @@ -111,6 +114,29 @@ Other tools share the name: npm's [`dotagents`](https://www.npmjs.com/package/do

`~/.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.

### Canonical config authoring

`dotagents config` edits the resolved canonical YAML through a review-first
flow. Shared and `dotagents.local.yaml` are separate editable layers; the
effective view is read-only. Structured edits preserve comments and unknown
fields, and a save never runs `sync` implicitly.

```bash
dotagents config
dotagents config serve --no-open --addr 127.0.0.1:8765
dotagents config validate
dotagents config print
```

The web server is loopback-only, session-cookie authenticated, and uses a
separate sync preview/apply step. For deliberate HTTPS tailnet access, expose
the loopback listener yourself:

```bash
dotagents config serve --no-open --secure-cookie --addr 127.0.0.1:8765
tailscale serve --bg --set-path /dotagents http://127.0.0.1:8765
```

## Releases

```bash
Expand Down
313 changes: 313 additions & 0 deletions SPEC.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/dotagents/cli_launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ func TestRootHelpAdvertisesExactlySixDescriptiveFamilies(t *testing.T) {
}
families = append(families, fields[0])
}
if got, want := strings.Join(families, ","), "setup,status,sync,doctor,skill,mcp"; got != want {
if got, want := strings.Join(families, ","), "setup,status,sync,doctor,config,skill,mcp"; got != want {
t.Fatalf("short-help families = %q, want %q:\n%s", got, want, stdout)
}
if !strings.Contains(stdout, `Run "dotagents help --all" for flags, maintenance commands, and compatibility aliases.`) {
Expand Down
71 changes: 31 additions & 40 deletions cmd/dotagents/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ package main
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"
)

func loadContext(opts runOptions) (string, string, config, []agentConfig, error) {
Expand Down Expand Up @@ -37,52 +36,16 @@ func loadContext(opts runOptions) (string, string, config, []agentConfig, error)

return repoRoot, home, cfg, selected, nil
}

func loadConfig(repoRoot string, home string, overridePath string) (config, error) {
configPath := overridePath
if strings.TrimSpace(configPath) == "" {
configPath = defaultConfigPath(repoRoot)
}
configPath = expandPath(configPath, home)

data, err := os.ReadFile(configPath)
doc, err := newConfigDocument(configPath, home)
if err != nil {
return config{}, fmt.Errorf("read config %s: %w", configPath, err)
}

var cfg config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return config{}, fmt.Errorf("yaml decode: %w", err)
}
if err := applyLocalOverlay(&cfg, configPath); err != nil {
return config{}, err
}
if err := validateConfig(&cfg, home, true); err != nil {
return config{}, err
}

return cfg, nil
}

// applyLocalOverlay merges a gitignored dotagents.local.yaml (next to the main
// config) into cfg. Entries match by name (agents, mcp_servers, hooks) or repo
// name (external_skills): a match replaces the base entry wholesale, anything
// else is appended. This keeps personal additions out of public git.
func applyLocalOverlay(cfg *config, configPath string) error {
localPath := filepath.Join(filepath.Dir(configPath), "dotagents.local.yaml")
data, err := os.ReadFile(localPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read local config %s: %w", localPath, err)
}
var local config
if err := yaml.Unmarshal(data, &local); err != nil {
return fmt.Errorf("yaml decode %s: %w", localPath, err)
}
mergeConfig(cfg, local)
return nil
return doc.effective, nil
}

func mergeConfig(base *config, overlay config) {
Expand All @@ -93,6 +56,11 @@ func mergeConfig(base *config, overlay config) {
if overlay.ContextNoteTokens != nil {
base.ContextNoteTokens = overlay.ContextNoteTokens
}
if overlay.UI != nil {
copyUI := *overlay.UI
copyUI.Links = append([]uiLink(nil), overlay.UI.Links...)
base.UI = &copyUI
}
}

func mergeByKey[T any](base []T, overlay []T, key func(T) string) []T {
Expand Down Expand Up @@ -278,6 +246,29 @@ func validateConfig(cfg *config, home string, expand bool) error {
}
}

if cfg.UI != nil {
seenLinks := make(map[string]struct{}, len(cfg.UI.Links))
for i := range cfg.UI.Links {
link := &cfg.UI.Links[i]
link.Name = strings.TrimSpace(link.Name)
link.URL = strings.TrimSpace(link.URL)
if link.Name == "" {
return errors.New("config ui link name cannot be empty")
}
if _, ok := seenLinks[link.Name]; ok {
return fmt.Errorf("config ui link %s is duplicated", link.Name)
}
seenLinks[link.Name] = struct{}{}
if strings.HasPrefix(link.URL, "/") && !strings.HasPrefix(link.URL, "//") {
continue
}
parsed, err := url.Parse(link.URL)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return fmt.Errorf("config ui link %s must be an absolute https URL or origin-relative path", link.Name)
}
}
}

return nil
}

Expand Down
Loading
Loading