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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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

Expand Down
123 changes: 123 additions & 0 deletions cmd/dotagents/agentsview.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the forwarded bind host in tunnel hints

When a remote invocation forwards a specific non-loopback bind address, such as --host 192.0.2.10, AgentsView listens on that address but this URL is always built with 127.0.0.1; tunnelCommand consequently forwards to localhost:PORT, where nothing is listening. Since the CLI help and documentation advertise --host ADDR, either derive the tunnel target from that flag or force AgentsView to bind to loopback in remote mode.

AGENTS.md reference: AGENTS.md:L49-L51

Useful? React with 👍 / 👎.

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")
}
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 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.`) {
Expand Down
7 changes: 7 additions & 0 deletions cmd/dotagents/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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")
Expand All @@ -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 <name> [--description ...]")
fmt.Println(" dotagents skill list [--agents ...]")
fmt.Println(" dotagents skill info <name>")
Expand Down
105 changes: 105 additions & 0 deletions cmd/dotagents/sessions_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 18 additions & 3 deletions skills/dotagents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [--description ...]
dotagents skill list [--agents ...]
dotagents skill info <name>
Expand All @@ -39,9 +40,11 @@ dotagents mcp <list|add|import|remove> [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.

Expand Down Expand Up @@ -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 |
Expand Down
Loading