From 54c408f19304e664c8e7dcce8b73f69875ec2410 Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Wed, 16 Sep 2026 14:34:06 +0400 Subject: [PATCH] Add dotagents sessions launcher for AgentsView integration --- README.md | 16 +++- cmd/dotagents/agentsview.go | 123 +++++++++++++++++++++++++++++++ cmd/dotagents/cli_launch_test.go | 2 +- cmd/dotagents/main.go | 7 ++ cmd/dotagents/sessions_test.go | 105 ++++++++++++++++++++++++++ skills/dotagents/SKILL.md | 21 +++++- 6 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 cmd/dotagents/agentsview.go create mode 100644 cmd/dotagents/sessions_test.go diff --git a/README.md b/README.md index 35ac08f..719e735 100644 --- a/README.md +++ b/README.md @@ -100,16 +100,26 @@ dotagents config # Bubble Tea canonical YAML editor (terminal) dotagents config validate|print dotagents view [--addr 127.0.0.1:8765] [--no-open] [--secure-cookie] [--ssh-host user@host] # loopback web config UI dotagents inspect [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] # launch HarnessKit (cross-harness inspector) +dotagents sessions [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] # launch AgentsView (sessions and usage) dotagents skill new|list|info|update|promote dotagents publish [--target NAME] [--skills a,b] [--dry-run] [--json] [--yes] # push skills to a remote registry dotagents mcp list|add|import|remove ``` -## Inspecting your skill roots +## Supported integrations -`dotagents skill list` shows, per detected harness, every entry in its skill root with provenance: managed links (with the external source and pinned commit when applicable), foreign symlinks (other tools' plugins), unmanaged directories, drifted and broken links — plus the estimated context cost of each harness's skill listing. `dotagents skill info ` answers "where does this skill come from and who sees it". +dotagents can launch two optional external tools. Neither is installed, vendored, or required by dotagents: -`dotagents inspect` shells out to [HarnessKit](https://github.com/RealZST/HarnessKit) (`hk serve`) for a read-mostly inspection UI over every detected harness — skills, MCP servers, hooks, and configs in one place. It prints the tokenized URL on its own line and opens it in your default browser locally; use `--no-open` to skip the launch, or `--ssh-host user@host` on a remote box to print an `ssh -L` tunnel command instead (inside an SSH session the host is derived from `SSH_CONNECTION`). Other flags (`--port`, `--host`, `--no-token`) are forwarded to `hk serve`. HarnessKit does its own harness discovery and can also enable/disable/deploy; those writes bypass dotagents, so use `inspect` to look and reconcile any changes with `dotagents sync`. Install HarnessKit separately. (`dotagents inspect` was `dotagents view` before v0.9.0, when `view` became the config UI.) +| Integration | Purpose | Connector | +|---|---|---| +| [HarnessKit](https://github.com/RealZST/HarnessKit) | Inspect and audit skills, MCP servers, hooks, and native harness configuration | `dotagents inspect` | +| [AgentsView](https://github.com/kenn-io/agentsview) | Search and replay sessions; inspect tool telemetry, token usage, and estimated cost | `dotagents sessions` | + +`dotagents skill list` remains the built-in provenance view for each harness skill root. It reports managed links, foreign symlinks, unmanaged directories, drift, broken links, and estimated context cost. + +`dotagents inspect` shells out to HarnessKit (`hk serve`). Treat it as read-mostly: HarnessKit's enable/disable/deploy actions bypass dotagents, so reconcile any changes with `dotagents sync`. Install HarnessKit separately. + +`dotagents sessions` shells out to AgentsView (`agentsview serve`). AgentsView owns its local transcript index and configuration; dotagents does not sync or mutate either. `--no-open` maps to AgentsView's `--no-browser`; `--ssh-host user@host` prints a loopback tunnel command on a remote machine. Other flags are forwarded to `agentsview serve`. Install AgentsView separately. ## Installing skills without dotagents diff --git a/cmd/dotagents/agentsview.go b/cmd/dotagents/agentsview.go new file mode 100644 index 0000000..a281c78 --- /dev/null +++ b/cmd/dotagents/agentsview.go @@ -0,0 +1,123 @@ +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +const agentsViewBinary = "agentsview" + +var agentsViewLookPath = exec.LookPath + +const agentsViewInstallHint = `AgentsView not found on PATH. + +dotagents sessions launches AgentsView as an optional session search, replay, +telemetry, and usage dashboard. AgentsView remains independently installed and +owns its own local index. + +Install it from https://github.com/kenn-io/agentsview, then re-run: dotagents sessions` + +type sessionsOptions struct { + NoOpen bool + SSHHost string +} + +func parseSessionsArgs(args []string) (sessionsOptions, []string, error) { + var opts sessionsOptions + var passthrough []string + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--no-open": + opts.NoOpen = true + case arg == "--open": + opts.NoOpen = false + case arg == "--ssh-host": + if i+1 >= len(args) { + return sessionsOptions{}, nil, errors.New("--ssh-host requires a value (e.g. --ssh-host user@host)") + } + opts.SSHHost = args[i+1] + i++ + case strings.HasPrefix(arg, "--ssh-host="): + opts.SSHHost = strings.TrimPrefix(arg, "--ssh-host=") + default: + passthrough = append(passthrough, arg) + } + } + return opts, passthrough, nil +} + +func agentsViewServeArgs(opts sessionsOptions, passthrough []string, remote bool) []string { + args := []string{"serve"} + if (opts.NoOpen || remote) && !containsArg(passthrough, "--no-browser") { + args = append(args, "--no-browser") + } + return append(args, passthrough...) +} + +func containsArg(args []string, target string) bool { + for _, arg := range args { + if arg == target { + return true + } + } + return false +} + +func agentsViewPort(args []string) string { + for i := range args { + switch { + case args[i] == "--port" && i+1 < len(args): + return args[i+1] + case strings.HasPrefix(args[i], "--port="): + return strings.TrimPrefix(args[i], "--port=") + } + } + return "8080" +} + +func announceSessionsRemote(port string, sshHost string) { + url := "http://127.0.0.1:" + port + fmt.Fprintln(os.Stdout, "Launching AgentsView on the remote host.") + fmt.Fprintf(os.Stdout, " %s\n", url) + if tunnel, ok := tunnelCommand(url, sshHost); ok { + fmt.Fprintf(os.Stdout, " from your machine: %s\n", tunnel) + fmt.Fprintln(os.Stdout, " then open the URL above locally.") + } else { + fmt.Fprintln(os.Stdout, " add --ssh-host user@host for a ready tunnel command.") + } + fmt.Fprintln(os.Stdout) +} + +func runSessions(args []string) error { + opts, passthrough, err := parseSessionsArgs(args) + if err != nil { + return err + } + path, err := agentsViewLookPath(agentsViewBinary) + if err != nil { + return errors.New(agentsViewInstallHint) + } + + remote := os.Getenv("SSH_CONNECTION") != "" || opts.SSHHost != "" + if !isHelpRequest(passthrough) { + if remote { + announceSessionsRemote(agentsViewPort(passthrough), resolveSSHHost(opts.SSHHost, os.Getenv)) + } else { + fmt.Fprintln(os.Stdout, "Launching AgentsView for session search, replay, telemetry, and usage.") + } + } + + cmd := exec.Command(path, agentsViewServeArgs(opts, passthrough, remote)...) // nosemgrep: go.lang.security.audit.dangerous-exec-command + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func isHelpRequest(args []string) bool { + return containsArg(args, "--help") || containsArg(args, "-h") +} diff --git a/cmd/dotagents/cli_launch_test.go b/cmd/dotagents/cli_launch_test.go index 8b0a211..bdf582d 100644 --- a/cmd/dotagents/cli_launch_test.go +++ b/cmd/dotagents/cli_launch_test.go @@ -167,7 +167,7 @@ func TestRootHelpAdvertisesCanonicalDescriptiveFamilies(t *testing.T) { } families = append(families, fields[0]) } - if got, want := strings.Join(families, ","), "setup,status,sync,doctor,config,view,skill,publish,mcp"; got != want { + if got, want := strings.Join(families, ","), "setup,status,sync,doctor,config,view,inspect,sessions,skill,publish,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.`) { diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 21ffd7d..38b079e 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -187,6 +187,8 @@ func run(args []string) error { return runView(args[1:]) case "inspect": return runInspect(args[1:]) + case "sessions": + return runSessions(args[1:]) case "skill": return runSkillCommand(args[1:]) case "publish": @@ -551,6 +553,10 @@ func printUsage() { fmt.Println(" config Author the canonical YAML in an interactive TUI") fmt.Println(" view Author the canonical YAML in a loopback web UI (browser)") fmt.Println() + fmt.Println("Supported integrations:") + fmt.Println(" inspect Launch HarnessKit for cross-harness configuration inspection") + fmt.Println(" sessions Launch AgentsView for session search, telemetry, and usage") + fmt.Println() fmt.Println("Command groups:") fmt.Println(" skill Inspect, create, update, and promote skills") fmt.Println(" publish Push canonical skills to a remote skill registry") @@ -570,6 +576,7 @@ func printAllUsage() { fmt.Println(" dotagents config [validate|print] [--config PATH]") fmt.Println(" dotagents view [--addr 127.0.0.1:8765] [--no-open] [--secure-cookie] [--ssh-host user@host] [--token-file PATH]") fmt.Println(" dotagents inspect [--no-open] [--ssh-host user@host] [hk serve flags: --port N, --host ADDR, --no-token]") + fmt.Println(" dotagents sessions [--no-open] [--ssh-host user@host] [agentsview serve flags: --port N, --host ADDR, --no-sync]") fmt.Println(" dotagents skill new [--description ...]") fmt.Println(" dotagents skill list [--agents ...]") fmt.Println(" dotagents skill info ") diff --git a/cmd/dotagents/sessions_test.go b/cmd/dotagents/sessions_test.go new file mode 100644 index 0000000..77f4c74 --- /dev/null +++ b/cmd/dotagents/sessions_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func TestParseSessionsArgs(t *testing.T) { + opts, passthrough, err := parseSessionsArgs([]string{"--no-open", "--ssh-host=m4", "--port", "9090", "--no-sync"}) + if err != nil { + t.Fatal(err) + } + if !opts.NoOpen || opts.SSHHost != "m4" { + t.Fatalf("parseSessionsArgs options = %+v", opts) + } + want := []string{"--port", "9090", "--no-sync"} + if !reflect.DeepEqual(passthrough, want) { + t.Fatalf("parseSessionsArgs passthrough = %v, want %v", passthrough, want) + } +} + +func TestParseSessionsArgsRequiresSSHHost(t *testing.T) { + if _, _, err := parseSessionsArgs([]string{"--ssh-host"}); err == nil { + t.Fatal("expected missing --ssh-host value to fail") + } +} + +func TestAgentsViewServeArgs(t *testing.T) { + tests := []struct { + name string + opts sessionsOptions + passthrough []string + remote bool + want []string + }{ + {name: "local defaults", want: []string{"serve"}}, + {name: "no open", opts: sessionsOptions{NoOpen: true}, want: []string{"serve", "--no-browser"}}, + {name: "remote", remote: true, passthrough: []string{"--port", "9090"}, want: []string{"serve", "--no-browser", "--port", "9090"}}, + {name: "existing no browser", remote: true, passthrough: []string{"--no-browser"}, want: []string{"serve", "--no-browser"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := agentsViewServeArgs(tt.opts, tt.passthrough, tt.remote) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("agentsViewServeArgs = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAgentsViewPort(t *testing.T) { + for _, tt := range []struct { + args []string + want string + }{ + {want: "8080"}, + {args: []string{"--port", "9090"}, want: "9090"}, + {args: []string{"--port=7070"}, want: "7070"}, + } { + if got := agentsViewPort(tt.args); got != tt.want { + t.Fatalf("agentsViewPort(%v) = %q, want %q", tt.args, got, tt.want) + } + } +} + +func TestRunSessionsMissingBinary(t *testing.T) { + orig := agentsViewLookPath + t.Cleanup(func() { agentsViewLookPath = orig }) + agentsViewLookPath = func(string) (string, error) { return "", errors.New("not found") } + + err := runSessions(nil) + if err == nil { + t.Fatal("expected error when agentsview binary is missing") + } + if !strings.Contains(err.Error(), "AgentsView") || !strings.Contains(err.Error(), "github.com/kenn-io/agentsview") { + t.Fatalf("error should guide install, got: %v", err) + } + if !strings.Contains(err.Error(), "dotagents sessions") { + t.Fatalf("install hint should point at the sessions command, got: %v", err) + } +} + +func TestRunSessionsHelpSkipsRemoteBanner(t *testing.T) { + orig := agentsViewLookPath + t.Cleanup(func() { agentsViewLookPath = orig }) + agentsViewLookPath = func(string) (string, error) { return "/bin/echo", nil } + + stdout, stderr, err := captureCLIOutput(t, func() error { + return runSessions([]string{"--help"}) + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(stdout, "Launching AgentsView on the remote host.") { + t.Fatalf("help should not trigger remote banner:\n%s", stdout) + } + if strings.Contains(stdout, "Launching AgentsView for session") { + t.Fatalf("help should not print the launch banner:\n%s", stdout) + } + if stderr != "" { + t.Fatalf("help wrote stderr: %q", stderr) + } +} diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index ebf9d44..849b41e 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -27,6 +27,7 @@ dotagents config validate dotagents config print dotagents view [--addr 127.0.0.1:8765] [--no-open] [--secure-cookie] [--ssh-host user@host] dotagents inspect [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] +dotagents sessions [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] dotagents skill new [--description ...] dotagents skill list [--agents ...] dotagents skill info @@ -39,9 +40,11 @@ dotagents mcp [options] `config` (terminal TUI) and `view` (browser web UI) are the canonical authoring surfaces. Both edit shared YAML or the machine-local overlay; effective configuration is read-only. In `view`, each toggle applies immediately; neither -surface runs `sync` implicitly. `view` binds only to loopback and uses a session cookie plus -CSRF and origin protection. `inspect` is a separate read-mostly HarnessKit -launcher, not an authoring surface (before v0.9.0 that launcher was `view`). +surface runs `sync` implicitly. `view` binds only to loopback and uses a session +cookie plus CSRF and origin protection. `inspect` is a separate read-mostly +HarnessKit launcher. `sessions` is a separate AgentsView launcher for transcript +search, replay, telemetry, and usage. Both integrations are optional external +tools, not dotagents dependencies. Run `dotagents help --all` for maintenance commands and compatibility aliases. Do not use hidden aliases in new scripts or documentation. @@ -189,6 +192,18 @@ dotagents inspect --no-open --port 7070 # print the URL, do not open a dotagents inspect --ssh-host me@box --host 0.0.0.0 # remote: print an ssh -L tunnel command ``` +## sessions + +Launches [AgentsView](https://github.com/kenn-io/agentsview) (`agentsview serve`) as an optional local session search, replay, telemetry, and usage dashboard. AgentsView owns its index and configuration; dotagents does not sync or mutate either. Requires `agentsview` on `PATH`, installed separately. + +`--no-open` maps to AgentsView's `--no-browser`. On a remote host, pass `--ssh-host user@host` (or run inside an SSH session) to suppress remote browser launch and print a loopback `ssh -L` tunnel command. Other flags are forwarded to `agentsview serve`. + +```bash +dotagents sessions # open AgentsView locally +dotagents sessions --no-open --port 8080 # serve without opening a browser +dotagents sessions --ssh-host me@box --port 8080 # remote: print a tunnel command +``` + ## Capability matrix | Harness | Skills | Roles | MCP | Hooks |