From 949a431c4c1a567ef9e7c8e732716b771561e9b9 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 23 Aug 2026 10:40:25 +0200 Subject: [PATCH] feat: status command + windows toast notifications - status: read-only report of deployed/repository revision, pending changes, skipped items (with reasons), unrecognized local items, binary version; --json machine-readable; exit 1 when local modifications block repository updates (headless drift checks) - sync/status share a read-only prepare() front half - notify package: windows toast via PowerShell script (go-toast mechanism, zero deps, build-tag gated), noop on linux/macos; cross-compile verified - toast fires exactly once per sync with warnings, never for routine syncs; notification failures are logged and never fail the sync (fail-open) --- .beads/interactions.jsonl | 1 + internal/cli/root.go | 1 + internal/cli/status.go | 43 ++++++++++ internal/client/executor.go | 10 +++ internal/client/notify_test.go | 105 +++++++++++++++++++++++ internal/client/status.go | 102 ++++++++++++++++++++++ internal/client/sync.go | 31 +++++-- internal/e2e/status_test.go | 140 +++++++++++++++++++++++++++++++ internal/notify/noop_noop.go | 11 +++ internal/notify/notify.go | 9 ++ internal/notify/toast_windows.go | 49 +++++++++++ 11 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 internal/cli/status.go create mode 100644 internal/client/notify_test.go create mode 100644 internal/client/status.go create mode 100644 internal/e2e/status_test.go create mode 100644 internal/notify/noop_noop.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/toast_windows.go diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index b78f282..605eaf9 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -17,3 +17,4 @@ {"id":"int-b7efd31fd41bbb5acd082eb3c2f34dbd","kind":"field_change","created_at":"2026-08-22T19:56:21.734777976Z","actor":"m3ta-chiron","issue_id":"agent-lib-j2w.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} {"id":"int-d0f6b2e3946129f47ed8f071902a5c78","kind":"field_change","created_at":"2026-08-22T20:01:15.960626197Z","actor":"m3ta-chiron","issue_id":"agent-lib-j2w.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} {"id":"int-baf99558287967cfd7c3aeb9c0e6354c","kind":"field_change","created_at":"2026-08-23T08:31:08.602480836Z","actor":"m3ta-chiron","issue_id":"agent-lib-j2w.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-41040467e9dbadf410d0563d3775e728","kind":"field_change","created_at":"2026-08-23T08:36:53.924991634Z","actor":"m3ta-chiron","issue_id":"agent-lib-j2w.6","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} diff --git a/internal/cli/root.go b/internal/cli/root.go index 1d1cf32..1e40056 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -24,6 +24,7 @@ func NewRootCmd() *cobra.Command { root.AddCommand(newVendorCmd()) root.AddCommand(newValidateCmd()) root.AddCommand(newSyncCmd()) + root.AddCommand(newStatusCmd()) return root } diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 0000000..6bceef8 --- /dev/null +++ b/internal/cli/status.go @@ -0,0 +1,43 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/m3tam3re/agent-lib/internal/client" +) + +func newStatusCmd() *cobra.Command { + var configPath string + var asJSON bool + cmd := &cobra.Command{ + Use: "status", + Short: "Report deployed revision, pending changes, skips and unrecognized items", + Long: "Read-only local state report. Exits non-zero when local modifications " + + "block repository updates — suitable for headless drift checks. " + + "agent-lib version is included in the report.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + report, err := client.Status(client.SyncOptions{ConfigPath: configPath}) + if err != nil { + return fmt.Errorf("status: %w", err) + } + if asJSON { + if err := emitJSON(cmd, report); err != nil { + return err + } + } else { + client.PrintStatus(cmd.OutOrStdout(), report) + } + if report.Drift { + os.Exit(1) + } + return nil + }, + } + cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env or platform default)") + cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output") + return cmd +} diff --git a/internal/client/executor.go b/internal/client/executor.go index 204fce3..561876a 100644 --- a/internal/client/executor.go +++ b/internal/client/executor.go @@ -9,9 +9,14 @@ import ( "github.com/m3tam3re/agent-lib/internal/deploy" "github.com/m3tam3re/agent-lib/internal/gitsource" "github.com/m3tam3re/agent-lib/internal/lockfile" + "github.com/m3tam3re/agent-lib/internal/notify" "github.com/m3tam3re/agent-lib/internal/target" ) +// Notifier delivers user-facing notifications; swap it in tests. Failures +// never fail the sync (fail-open, logged to stdout). +var Notifier notify.Sender = notify.Platform() + // SyncReport summarizes one planner-driven sync run. type SyncReport struct { Revision string @@ -72,6 +77,11 @@ func ExecutePlan(tree *gitsource.GitTree, plan []PlanEntry, manifest *Manifest, if err := appendWarnings(paths.LogFile, warnings); err != nil { fmt.Fprintf(stdout, "warning: could not write sync log: %v\n", err) } + title := "agent-lib sync" + body := fmt.Sprintf("%d item(s) kept local: modifications blocked company updates. See the sync log for details.", report.Skipped+report.Left) + if err := Notifier.Send(title, body); err != nil { + fmt.Fprintf(stdout, "warning: notification failed: %v\n", err) + } } printPlannedSyncReport(stdout, report) diff --git a/internal/client/notify_test.go b/internal/client/notify_test.go new file mode 100644 index 0000000..92fa50e --- /dev/null +++ b/internal/client/notify_test.go @@ -0,0 +1,105 @@ +package client + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/m3tam3re/agent-lib/internal/gitsource" + "github.com/m3tam3re/agent-lib/internal/lockfile" +) + +type countingNotifier struct { + calls int + titles []string + failWith error +} + +func (c *countingNotifier) Send(title, body string) error { + c.calls++ + c.titles = append(c.titles, title) + return c.failWith +} + +func skipPlanEntry(t *testing.T) PlanEntry { + return PlanEntry{ + Action: ActSkip, Type: "skills", Name: "alpha", + RelPath: "skills/alpha", DstPath: filepath.Join(t.TempDir(), "skills", "alpha"), + NewHash: "sha256:x", Reason: "locally modified; keeping the local version (upstream changed)", + } +} + +func executeForTest(t *testing.T, plan []PlanEntry) (*SyncReport, error) { + t.Helper() + dir := t.TempDir() + paths := StatePaths{Root: dir, Manifest: filepath.Join(dir, "manifest.json"), LogFile: filepath.Join(dir, "sync.log")} + manifest := manifestWith(map[string]*ManifestItem{ + "skills/alpha": {Name: "alpha", Type: "skills", Hash: "sha256:old"}, + }) + var tree *gitsource.GitTree + return ExecutePlan(tree, plan, manifest, paths, "rev123", os.Stdout) +} + +func TestToastFiredOnceForWarnings(t *testing.T) { + orig := Notifier + t.Cleanup(func() { Notifier = orig }) + fake := &countingNotifier{} + Notifier = fake + + _, err := executeForTest(t, []PlanEntry{skipPlanEntry(t), { + Action: ActLeave, Type: "skills", Name: "mine", Reason: "unmanaged local item; left untouched", + }}) + if err != nil { + t.Fatal(err) + } + if fake.calls != 1 { + t.Errorf("exactly one toast per sync with warnings, got %d", fake.calls) + } + if len(fake.titles) != 1 || fake.titles[0] != "agent-lib sync" { + t.Errorf("unexpected toast titles: %v", fake.titles) + } +} + +func TestNoToastForRoutineSync(t *testing.T) { + orig := Notifier + t.Cleanup(func() { Notifier = orig }) + fake := &countingNotifier{} + Notifier = fake + + _, err := executeForTest(t, []PlanEntry{{ + Action: ActKeep, Type: "skills", Name: "alpha", + RelPath: "skills/alpha", DstPath: filepath.Join(t.TempDir(), "skills", "alpha"), + NewHash: "sha256:x", + }}) + if err != nil { + t.Fatal(err) + } + if fake.calls != 0 { + t.Errorf("routine sync must stay silent, got %d toasts", fake.calls) + } +} + +func TestToastFailureNeverFailsSync(t *testing.T) { + orig := Notifier + t.Cleanup(func() { Notifier = orig }) + Notifier = &countingNotifier{failWith: errors.New("no interactive session")} + + report, err := executeForTest(t, []PlanEntry{skipPlanEntry(t)}) + if err != nil { + t.Fatalf("toast failure must not fail the sync: %v", err) + } + if report.Skipped != 1 { + t.Errorf("skip must still be applied: %+v", report) + } + if _, err := os.Stat(filepath.Join(t.TempDir())); err != nil { + t.Fatal(err) + } +} + +func TestPlanEntryFieldCoverage(t *testing.T) { + e := skipPlanEntry(t) + if e.Action != ActSkip || e.Type != lockfile.TypeSkill || e.Reason == "" { + t.Fatalf("plan entry incomplete: %+v", e) + } +} diff --git a/internal/client/status.go b/internal/client/status.go new file mode 100644 index 0000000..20b8983 --- /dev/null +++ b/internal/client/status.go @@ -0,0 +1,102 @@ +package client + +import ( + "fmt" + "io" + + "github.com/m3tam3re/agent-lib/internal/version" +) + +// StatusEntry names one item in a status section. +type StatusEntry struct { + Item string `json:"item"` + Reason string `json:"reason,omitempty"` +} + +// StatusReport is the machine-readable local-state report. +type StatusReport struct { + Version string `json:"agent_lib_version"` + DeployedRev string `json:"deployed_revision"` + RepoRev string `json:"repository_revision"` + InSync bool `json:"in_sync"` + Drift bool `json:"problematic_drift"` + Pending []StatusEntry `json:"pending_changes,omitempty"` + Skipped []StatusEntry `json:"skipped_items,omitempty"` + Unrecognized []StatusEntry `json:"unrecognized_items,omitempty"` +} + +// Status reports the local state without mutating anything. Drift is true +// when local modifications block repository updates (or occupy names the +// repository needs) — the state headless drift checks flag with a non-zero +// exit code. +func Status(opts SyncOptions) (*StatusReport, error) { + p, err := prepare(opts) + if err != nil { + return nil, err + } + report := &StatusReport{ + Version: version.Version, + DeployedRev: p.manifest.Revision, + RepoRev: p.pull.Rev, + } + for _, e := range p.plan { + item := e.Type + "/" + e.Name + switch e.Action { + case ActDeploy, ActUpdate, ActRemove: + report.Pending = append(report.Pending, StatusEntry{Item: item, Reason: string(e.Action)}) + case ActSkip: + blocked := true + if rec := p.manifest.Items[manifestKey(e.Type, e.Name)]; rec != nil && rec.Hash == e.NewHash { + blocked = false + } + if blocked { + report.Drift = true + } + report.Skipped = append(report.Skipped, StatusEntry{Item: item, Reason: e.Reason}) + case ActLeave: + report.Unrecognized = append(report.Unrecognized, StatusEntry{Item: item, Reason: e.Reason}) + } + } + report.InSync = report.DeployedRev == report.RepoRev && len(report.Pending) == 0 + return report, nil +} + +// PrintStatus renders the human-readable report and returns the drift +// verdict so the caller can set the exit code. +func PrintStatus(w io.Writer, r *StatusReport) bool { + fmt.Fprintf(w, "agent-lib %s\n", r.Version) + fmt.Fprintf(w, "deployed revision: %s\n", revOrNone(r.DeployedRev)) + fmt.Fprintf(w, "repository revision: %s\n", revOrNone(r.RepoRev)) + if r.InSync { + fmt.Fprintln(w, "state: in sync") + } else { + fmt.Fprintln(w, "state: out of sync") + } + printSection := func(title string, entries []StatusEntry) { + if len(entries) == 0 { + return + } + fmt.Fprintf(w, "%s:\n", title) + for _, e := range entries { + fmt.Fprintf(w, " %s", e.Item) + if e.Reason != "" { + fmt.Fprintf(w, " — %s", e.Reason) + } + fmt.Fprintln(w) + } + } + printSection("pending changes", r.Pending) + printSection("skipped (local modifications)", r.Skipped) + printSection("unrecognized local items", r.Unrecognized) + if r.Drift { + fmt.Fprintln(w, "drift: local modifications block repository updates") + } + return r.Drift +} + +func revOrNone(rev string) string { + if rev == "" { + return "(never synced)" + } + return rev +} diff --git a/internal/client/sync.go b/internal/client/sync.go index 4898af5..a655c17 100644 --- a/internal/client/sync.go +++ b/internal/client/sync.go @@ -15,11 +15,19 @@ type SyncOptions struct { ConfigPath string } -// Sync pulls the work repository, plans the sync as a pure function over -// (inventory, manifest, disk facts) and executes the plan. Validation and -// hashing happen before the first mutation; unreachable repositories fail -// cleanly with the previous state intact. -func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) { +// prepared is everything sync and status share: the pulled repository, the +// planned action list, and the local state — before any mutation. +type prepared struct { + paths StatePaths + pull *PullResult + manifest *Manifest + items []PlannedItem + plan []PlanEntry +} + +// prepare pulls, inventories, hashes and plans — the complete read-only +// front half of a client run. +func prepare(opts SyncOptions) (*prepared, error) { cfg, err := LoadClientConfig(opts.ConfigPath) if err != nil { return nil, err @@ -71,8 +79,19 @@ func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) { return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath) } } + return &prepared{paths: paths, pull: pull, manifest: manifest, items: items, plan: plan}, nil +} - return ExecutePlan(pull.Tree, plan, manifest, paths, pull.Rev, stdout) +// Sync pulls the work repository, plans the sync as a pure function over +// (inventory, manifest, disk facts) and executes the plan. Validation and +// hashing happen before the first mutation; unreachable repositories fail +// cleanly with the previous state intact. +func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) { + p, err := prepare(opts) + if err != nil { + return nil, err + } + return ExecutePlan(p.pull.Tree, p.plan, p.manifest, p.paths, p.pull.Rev, stdout) } // safePath rejects destinations that escape the user's home directory. diff --git a/internal/e2e/status_test.go b/internal/e2e/status_test.go new file mode 100644 index 0000000..eec8b9f --- /dev/null +++ b/internal/e2e/status_test.go @@ -0,0 +1,140 @@ +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func statusRun(t *testing.T, env *syncEnv, jsonFlag bool) (string, error) { + t.Helper() + args := []string{"status", "--config", env.config} + if jsonFlag { + args = append(args, "--json") + } + cmd := agentLibCmd(args...) + cmd.Env = append(os.Environ(), + "HOME="+env.home, "USERPROFILE="+env.home, + "AGENT_LIB_STATE_DIR="+env.stateDir, + ) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func TestStatusInSync(t *testing.T) { + h := newHarness(t) + env := newSyncEnv(t, h) + buildWorkRepo(t, h, env.workRepo) + env.writeConfig(t, env.workRepo, "") + syncOnce(t, env) + + out, err := statusRun(t, env, false) + if err != nil { + t.Fatalf("in-sync status must exit 0: %v\n%s", err, out) + } + for _, want := range []string{"in sync", "deployed revision", "repository revision", "agent-lib"} { + if !contains(out, want) { + t.Errorf("status output missing %q:\n%s", want, out) + } + } +} + +func TestStatusDriftNonZero(t *testing.T) { + h := newHarness(t) + env := newSyncEnv(t, h) + buildWorkRepo(t, h, env.workRepo) + env.writeConfig(t, env.workRepo, "") + syncOnce(t, env) + + os.WriteFile(filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md"), + []byte("---\nname: Own Skill\n---\n# my edits\n"), 0o644) + p := filepath.Join(env.workRepo, "skills/own-skill/SKILL.md") + os.WriteFile(p, []byte("---\nname: Own Skill\ndescription: v2\n---\n# Upstream v2\n"), 0o644) + commitWorkRepo(t, h, env.workRepo, "change own-skill") + + out, err := statusRun(t, env, false) + if err == nil { + t.Fatalf("blocked-update drift must exit non-zero:\n%s", out) + } + for _, want := range []string{"skipped", "own-skill", "locally modified", "drift"} { + if !contains(out, want) { + t.Errorf("status missing %q:\n%s", want, out) + } + } + + jsonOut, err := statusRun(t, env, true) + if err == nil { + t.Fatalf("json status must keep the drift exit code:\n%s", jsonOut) + } + var report struct { + InSync bool `json:"in_sync"` + Drift bool `json:"problematic_drift"` + Skipped []struct { + Item string `json:"item"` + Reason string `json:"reason"` + } `json:"skipped_items"` + Pending []struct { + Item string `json:"item"` + } `json:"pending_changes"` + } + if err := json.Unmarshal([]byte(jsonOut), &report); err != nil { + t.Fatalf("status --json not parseable: %v\n%s", err, jsonOut) + } + if !report.Drift || report.InSync { + t.Errorf("json flags wrong: %+v", report) + } + if len(report.Skipped) != 1 || report.Skipped[0].Item != "skills/own-skill" { + t.Errorf("skipped item missing from json: %+v", report.Skipped) + } +} + +func TestStatusUnrecognizedButNoDrift(t *testing.T) { + h := newHarness(t) + env := newSyncEnv(t, h) + buildWorkRepo(t, h, env.workRepo) + env.writeConfig(t, env.workRepo, "") + syncOnce(t, env) + + mine := filepath.Join(env.home, ".agents/skills/my-own-thing") + os.MkdirAll(mine, 0o755) + os.WriteFile(filepath.Join(mine, "SKILL.md"), []byte("---\nname: Mine\n---\n"), 0o644) + + out, err := statusRun(t, env, false) + if err != nil { + t.Fatalf("unrecognized items alone are not problematic drift:\n%s", out) + } + if !contains(out, "unrecognized local items") || !contains(out, "my-own-thing") { + t.Errorf("unrecognized item must be listed:\n%s", out) + } + if contains(out, "drift:") { + t.Errorf("no drift line expected:\n%s", out) + } +} + +func TestStatusNeverSynced(t *testing.T) { + h := newHarness(t) + env := newSyncEnv(t, h) + buildWorkRepo(t, h, env.workRepo) + env.writeConfig(t, env.workRepo, "") + + out, err := statusRun(t, env, true) + if err != nil { + t.Fatalf("fresh status must exit 0: %v\n%s", err, out) + } + var report struct { + DeployedRev string `json:"deployed_revision"` + Pending []struct { + Item string `json:"item"` + } `json:"pending_changes"` + } + if err := json.Unmarshal([]byte(out), &report); err != nil { + t.Fatalf("json not parseable: %v", err) + } + if report.DeployedRev != "" { + t.Errorf("never synced must have empty deployed revision: %q", report.DeployedRev) + } + if len(report.Pending) == 0 { + t.Error("fresh status must list everything as pending deploy") + } +} diff --git a/internal/notify/noop_noop.go b/internal/notify/noop_noop.go new file mode 100644 index 0000000..b1a9e15 --- /dev/null +++ b/internal/notify/noop_noop.go @@ -0,0 +1,11 @@ +//go:build !windows + +package notify + +// Noop drops every notification; Linux and macOS stay silent by design. +type Noop struct{} + +func (Noop) Send(title, body string) error { return nil } + +// Platform returns the no-op sender on Linux and macOS. +func Platform() Sender { return Noop{} } diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..a96283f --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,9 @@ +// Package notify fires user-facing notifications. The platform sender is a +// Windows toast (PowerShell-backed, mirroring go-toast's script); Linux and +// macOS are deliberate no-ops. +package notify + +// Sender delivers one notification. +type Sender interface { + Send(title, body string) error +} diff --git a/internal/notify/toast_windows.go b/internal/notify/toast_windows.go new file mode 100644 index 0000000..fcad560 --- /dev/null +++ b/internal/notify/toast_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package notify + +import ( + "fmt" + "os/exec" + "strings" +) + +// PowerShellToast raises a Windows toast notification by invoking +// PowerShell with an AppId-tagged script — the same mechanism go-toast +// uses, without the dependency. +type PowerShellToast struct { + AppID string +} + +// Platform returns the PowerShell toast sender on Windows. +func Platform() Sender { return PowerShellToast{AppID: "agent-lib"} } + +var toastScript = `$app = '%[1]s' +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +$tmpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) +$txt = $tmpl.GetElementsByTagName('text') +$txt.Item(0).AppendChild($tmpl.CreateTextNode('%[2]s')) | Out-Null +$txt.Item(1).AppendChild($tmpl.CreateTextNode('%[3]s')) | Out-Null +$toast = [Windows.UI.Notifications.ToastNotification]::new($tmpl) +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show($toast)` + +// Send fires one toast. It requires an interactive user session; callers +// must treat errors as non-fatal. +func (p PowerShellToast) Send(title, body string) error { + appID := p.AppID + if appID == "" { + appID = "agent-lib" + } + script := fmt.Sprintf(toastScript, escape(appID), escape(title), escape(body)) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("toast failed: %v: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// escape makes s safe inside a PowerShell single-quoted string. +func escape(s string) string { + r := strings.NewReplacer("'", "''", "\n", " ", "\r", " ") + return r.Replace(s) +}