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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Run without `--apply` first to see what a command would do.
| `reconcile` | act on that drift, gated on what agentsync itself recorded |
| `inventory` | regenerate `inventory.json`, a record of every agent on the machine |
| `optimize` | ask an OpenRouter advisor which model and effort each agent should use; report only |
| `override` | set one checkout's model and effort for a Claude agent without forking it |

`agentsync help` lists every flag.

Expand All @@ -82,6 +83,7 @@ Run without `--apply` first to see what a command would do.
- [reconcile](docs/reconcile.md)
- [inventory](docs/inventory.md)
- [optimize](docs/optimize.md)
- [override](docs/override.md)
- [State file](docs/state-file.md)
- [Exit codes](docs/exit-codes.md)
- [Development](docs/development.md): toolchain, CI, releases
Expand Down
30 changes: 20 additions & 10 deletions cmd/agentsync/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ func runDoctor(args []string, stdout, stderr io.Writer) int {
check: repo.CheckHome(root.Path, res.Dirs),
report: doctor.Diagnose(repo, res),
}
findings, problems := overrideFindings(repo.Home, res.Dirs)
run.report.Findings = append(run.report.Findings, findings...)
run.report.Problems = append(run.report.Problems, problems...)

// Both home diagnostics, after the diagnosis and before the report, so a
// terminal user reads them before the numbers they apply to.
Expand Down Expand Up @@ -220,22 +223,25 @@ var doctorKindOrder = []doctor.Kind{
doctor.KindStaleCapture,
doctor.KindOrphanedCaptures,
doctor.KindForeignLink,
doctor.KindOverrideCannotRun,
}

var doctorKindMarker = map[doctor.Kind]string{
doctor.KindBrokenLink: "!!",
doctor.KindMismatch: "!!",
doctor.KindStaleCapture: "!!",
doctor.KindOrphanedCaptures: "!!",
doctor.KindForeignLink: " ",
doctor.KindBrokenLink: "!!",
doctor.KindMismatch: "!!",
doctor.KindStaleCapture: "!!",
doctor.KindOrphanedCaptures: "!!",
doctor.KindForeignLink: " ",
doctor.KindOverrideCannotRun: "!!",
}

var doctorKindNote = map[doctor.Kind]string{
doctor.KindBrokenLink: "link into this repo whose target is gone — NOT unlinked",
doctor.KindMismatch: "linked to the wrong file in this repo — NOT repointed",
doctor.KindStaleCapture: "captured here, but no live file in the project — NOT deleted",
doctor.KindOrphanedCaptures: "captured here, but the whole project is gone — NOT deleted",
doctor.KindForeignLink: "points outside this repo — not ours, and not a fault",
doctor.KindBrokenLink: "link into this repo whose target is gone — NOT unlinked",
doctor.KindMismatch: "linked to the wrong file in this repo — NOT repointed",
doctor.KindStaleCapture: "captured here, but no live file in the project — NOT deleted",
doctor.KindOrphanedCaptures: "captured here, but the whole project is gone — NOT deleted",
doctor.KindForeignLink: "points outside this repo — not ours, and not a fault",
doctor.KindOverrideCannotRun: "override hook that cannot run — the overrides do nothing",
}

func (r doctorRun) emitText(w io.Writer) {
Expand Down Expand Up @@ -310,6 +316,10 @@ func (r doctorRun) emitSummary(w io.Writer) {
fmt.Fprintf(w, "%d foreign link%s reported for completeness; %s belong%s to something else and %s not affect this exit code.\n",
n, plural(n, "", "s"), plural(n, "it", "they"), plural(n, "s", ""), plural(n, "does", "do"))
}
if n := counts[doctor.KindOverrideCannotRun]; n > 0 {
fmt.Fprintf(w, "%d project%s override%s agents through a hook that cannot run here. Put a current agentsync on PATH, or remove the overrides with agentsync override --clear.\n",
n, plural(n, "", "s"), plural(n, "s", ""))
}
if n := len(r.report.Problems); n > 0 {
fmt.Fprintf(w, "%d path%s could not be judged, so this diagnosis is incomplete.\n",
n, plural(n, "", "s"))
Expand Down
37 changes: 31 additions & 6 deletions cmd/agentsync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
//
// optimize is the only command that uses the network: it asks an OpenRouter
// advisor about each agent's model and effort, and writes nothing.
//
// override writes only the project's .claude/settings.local.json, with --apply.
package main

import (
Expand All @@ -52,6 +54,7 @@ import (
"unicode/utf8"

"github.com/agentic-tooling/agentsync/internal/config"
"github.com/agentic-tooling/agentsync/internal/override"
"github.com/agentic-tooling/agentsync/internal/scan"
)

Expand All @@ -67,6 +70,7 @@ Usage:
agentsync inventory [flags] regenerate inventory.json: every agent on this machine
agentsync optimize [flags] ask an OpenRouter advisor which model and effort each
agent should use (report only; the one networked command)
agentsync override [flags] set this checkout's model and effort for a Claude agent
agentsync version

Common flags:
Expand Down Expand Up @@ -236,10 +240,24 @@ OpenRouter, one request per agent with no retries. It reads model: and effort:
from each agent's frontmatter and writes nothing. Exit 1 when a row is
unresolved or skipped.

override flags:
--agent NAME the agent to override, by its frontmatter name; it must be
in the root's .claude/agents/ (--all is refused)
--model M haiku, sonnet, opus or fable
--effort E low, medium, high, xhigh or max (give --model, --effort or both)
--clear NAME remove that agent's override
--apply write the change; without it override is a dry run

With no flags, override lists the root's overrides. They live in one
PreToolUse hook in <root>/.claude/settings.local.json, which Claude Code keeps
per user. The hook runs agentsync from PATH; doctor checks that it can.

scan and doctor are read-only. import writes only into this repo, only with
--apply, and never into a project's .claude/agents directory. link is the one
command that writes into a project, only with --apply, and it refuses any
project holding a file this repo has no copy of — import that first.
command that writes into a project's .claude/agents, only with --apply, and it
refuses any project holding a file this repo has no copy of — import that
first. override writes only the project's .claude/settings.local.json, also
only with --apply.

inventory also writes only into this repo, and takes no --apply: its sole
output is inventory.json, which it owns, recomputes from scratch every run, and
Expand Down Expand Up @@ -306,6 +324,10 @@ func run(args []string, stdout, stderr io.Writer) int {
return runInventory(args[1:], stdout, stderr)
case "optimize":
return runOptimize(args[1:], stdout, stderr)
case "override":
return runOverride(args[1:], stdout, stderr)
case "hook":
return runHook(args[1:], stdout, stderr)
case "-h", "--help", "help":
fmt.Fprint(stdout, usage)
return exitOK
Expand Down Expand Up @@ -424,7 +446,7 @@ func runScan(args []string, stdout, stderr io.Writer) int {
return exitFailure
}
} else {
emitText(stdout, res, repo.Root, root.Path, *atRiskOnly)
emitText(stdout, res, repo.Root, root.Path, *atRiskOnly, entryOverrides(res, stderr))
}

if *failOnRisk && len(res.AtRisk()) > 0 {
Expand Down Expand Up @@ -505,7 +527,7 @@ var stateNote = map[scan.State]string{
scan.StateHealthyLink: "already linked to the repo",
}

func emitText(w io.Writer, res scan.Result, repoRoot, root string, atRiskOnly bool) {
func emitText(w io.Writer, res scan.Result, repoRoot, root string, atRiskOnly bool, ov map[string]override.Value) {
shown := res
if atRiskOnly {
shown = filterAtRisk(res)
Expand All @@ -524,7 +546,7 @@ func emitText(w io.Writer, res scan.Result, repoRoot, root string, atRiskOnly bo
}
fmt.Fprintln(w)
} else {
emitGroups(w, shown.Entries)
emitGroups(w, shown.Entries, ov)
}

emitProblems(w, res.Problems)
Expand Down Expand Up @@ -565,7 +587,7 @@ func projectColumn(entries []scan.Entry) int {
return width
}

func emitGroups(w io.Writer, entries []scan.Entry) {
func emitGroups(w io.Writer, entries []scan.Entry, ov map[string]override.Value) {
width := projectColumn(entries)

byState := make(map[scan.State][]scan.Entry)
Expand Down Expand Up @@ -614,6 +636,9 @@ func emitGroups(w io.Writer, entries []scan.Entry) {
note = " (committed in its own repo)"
}
}
if v, ok := ov[e.Path]; ok {
note += " override: " + v.String()
}
fmt.Fprintf(w, " %-*s %-28s%s\n", width, e.Project, e.Name, note)
}
fmt.Fprintln(w)
Expand Down
2 changes: 1 addition & 1 deletion cmd/agentsync/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func atRiskNames(res scan.Result) []string {
func renderText(t *testing.T, res scan.Result, atRiskOnly bool) string {
t.Helper()
var buf bytes.Buffer
emitText(&buf, res, "/repo", "/root", atRiskOnly)
emitText(&buf, res, "/repo", "/root", atRiskOnly, nil)
return buf.String()
}

Expand Down
8 changes: 8 additions & 0 deletions cmd/agentsync/optimize.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"math"
"os"
"path/filepath"
"slices"
"strings"
"text/tabwriter"

Expand Down Expand Up @@ -114,6 +115,13 @@ func runOptimize(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(notes, "Note: $%s is set; Claude Code uses it over the frontmatter at run time.\n", v)
}
}
ov := entryOverrides(res, stderr)
for _, e := range res.Entries {
if v, ok := ov[e.Path]; ok && (len(names) == 0 || slices.Contains(names, e.Name)) {
fmt.Fprintf(notes, "Note: %s/%s has a project override (%s) in %s; Claude Code uses it over the frontmatter there.\n",
e.Project, e.Name, v, displayPath(repo.Home, settingsPath(filepath.Dir(e.Path))))
}
}

rep := optimize.Run(ctx, client, agents, cfg)
rep.Overrides = overrides
Expand Down
Loading
Loading