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)
This commit is contained in:
@@ -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-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-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-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"}}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func NewRootCmd() *cobra.Command {
|
|||||||
root.AddCommand(newVendorCmd())
|
root.AddCommand(newVendorCmd())
|
||||||
root.AddCommand(newValidateCmd())
|
root.AddCommand(newValidateCmd())
|
||||||
root.AddCommand(newSyncCmd())
|
root.AddCommand(newSyncCmd())
|
||||||
|
root.AddCommand(newStatusCmd())
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -9,9 +9,14 @@ import (
|
|||||||
"github.com/m3tam3re/agent-lib/internal/deploy"
|
"github.com/m3tam3re/agent-lib/internal/deploy"
|
||||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||||
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
||||||
|
"github.com/m3tam3re/agent-lib/internal/notify"
|
||||||
"github.com/m3tam3re/agent-lib/internal/target"
|
"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.
|
// SyncReport summarizes one planner-driven sync run.
|
||||||
type SyncReport struct {
|
type SyncReport struct {
|
||||||
Revision string
|
Revision string
|
||||||
@@ -72,6 +77,11 @@ func ExecutePlan(tree *gitsource.GitTree, plan []PlanEntry, manifest *Manifest,
|
|||||||
if err := appendWarnings(paths.LogFile, warnings); err != nil {
|
if err := appendWarnings(paths.LogFile, warnings); err != nil {
|
||||||
fmt.Fprintf(stdout, "warning: could not write sync log: %v\n", err)
|
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)
|
printPlannedSyncReport(stdout, report)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+25
-6
@@ -15,11 +15,19 @@ type SyncOptions struct {
|
|||||||
ConfigPath string
|
ConfigPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync pulls the work repository, plans the sync as a pure function over
|
// prepared is everything sync and status share: the pulled repository, the
|
||||||
// (inventory, manifest, disk facts) and executes the plan. Validation and
|
// planned action list, and the local state — before any mutation.
|
||||||
// hashing happen before the first mutation; unreachable repositories fail
|
type prepared struct {
|
||||||
// cleanly with the previous state intact.
|
paths StatePaths
|
||||||
func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
|
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)
|
cfg, err := LoadClientConfig(opts.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 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.
|
// safePath rejects destinations that escape the user's home directory.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{} }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user