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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ self-updates from `https://release.gander.md` (GitHub Releases fallback).
| `stop.go` | `gander stop [<file>\|<id>] [--all]` |
| `logs.go` | `gander logs [<id>] [--follow\|--no-follow]` tail file |
| `api.go` | gandermd HTTP client (signup, share CRUD, manage intent, invites) |
| `signup.go`/`auth.go`/`list.go`/`remove.go`/`manage.go`/`invite.go` | gandermd account subcommands |
| `signup.go`/`auth.go`/`list.go`/`remove.go`/`archive.go`/`manage.go`/`invite.go` | gandermd account subcommands |
| `upgrade.go` | `--upgrade` self-update via `https://release.gander.md/latest.json` (GitHub Releases API fallback); coordinates with the runner (shutdown over UDS, replace binary, supervisor restarts under the new code) |
| `completion.go` | `gander completion {bash\|zsh}` |
| `skill.go` | `gander skill [install]` — fetch gander-skill into `~/.gander/skill`, symlink into agent skill dirs |
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ A local HTTP server is started on `127.0.0.1:7821` so the browser can receive ch

### Share on gander.md

If you're running an agent that streams markdown to a file, `gander watch` is the shortest path from the agent's writes to your browser — open the URL once and every connected viewer sees the latest version in real time. `gander.md` is the public hosting service for gander. Once you sign up, you can `share`, `watch`, `list`, and `remove` markdown from your terminal, mint team invites with `gander invite`, and viewers see the same live-reload preview and document chrome you'd see locally.
If you're running an agent that streams markdown to a file, `gander watch` is the shortest path from the agent's writes to your browser — open the URL once and every connected viewer sees the latest version in real time. `gander.md` is the public hosting service for gander. Once you sign up, you can `share`, `watch`, `list`, `archive`, and `remove` markdown from your terminal, mint team invites with `gander invite`, and viewers see the same live-reload preview and document chrome you'd see locally.

```bash
gander signup --email you@example.com # opens browser form, polls for API token
Expand All @@ -247,7 +247,9 @@ gander share README.md --visibility=hidden # unpublish the viewer URL (404); st
gander share README.md --label review --label agent # replace labels (omit on later share/watch to leave them)
# --silent, dir-adopt, and agent CLIs also stamp review; type labels stay additive
gander share README.md --no-labels # clear labels
gander list # table of active shares (includes COMMENTING, VISIBILITY, LABELS)
gander list # table of active shares (includes ARCHIVED, COMMENTING, VISIBILITY, LABELS)
gander archive README.md # hide the link and turn commenting off; the file stays
gander archive --yes README.md # same, skip the confirm
gander remove README.md # 404s the short link
gander remove --all # remove every share in your account
gander invite # print a team invite URL (shown once)
Expand Down Expand Up @@ -388,6 +390,8 @@ gander mcp Run the MCP server on stdio
gander mcp install Merge MCP config into local agent harnesses
gander uninstall [--yes] [--keep-config] Remove CLI, MCP, skill, runner (and optionally ~/.gander)
gander remove [--all] [<file>] Delete a share from gander.md
gander archive [--all|--pick <short_id>|--yes|--non-interactive] <file|short_id|url>
Hide a share; sharing the file again restores it
gander list List shares currently on gander.md
gander invite [--email <addr>] [--share <short_id>] Mint a team invite link
gander manage | dashboard | dash | --d Open the dashboard in your browser
Expand Down
35 changes: 34 additions & 1 deletion api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -51,6 +52,30 @@ type shareResp struct {
UnresolvedCount int `json:"unresolved_count"`
AgentUnresolvedCount int `json:"agent_unresolved_count"`
Labels []string `json:"labels"`
Archived bool `json:"archived,omitempty"`
}

type apiError struct {
Status int
Body string
}

func (e *apiError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body))
}

func isArchivedConflict(err error) bool {
var ae *apiError
if !errors.As(err, &ae) || ae.Status != http.StatusConflict {
return false
}
var body struct {
Error string `json:"error"`
}
if json.Unmarshal([]byte(ae.Body), &body) != nil {
return false
}
return body.Error == "archived"
}

// shareOpts are POST /api/shares policy fields. Empty strings are omitted so
Expand Down Expand Up @@ -136,7 +161,7 @@ func (c *apiClient) doStatus(method, path string, body, dst any) (int, error) {

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return resp.StatusCode, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
return resp.StatusCode, &apiError{Status: resp.StatusCode, Body: string(raw)}
}
if dst == nil {
_, _ = io.Copy(io.Discard, resp.Body)
Expand Down Expand Up @@ -237,6 +262,14 @@ func (c *apiClient) UpdateShare(uuid, content string) (*shareResp, error) {
return &out, nil
}

func (c *apiClient) ArchiveShare(uuid string) (*shareResp, error) {
var out shareResp
if err := c.do("POST", fmt.Sprintf("/api/shares/%s/archive", uuid), nil, &out); err != nil {
return nil, err
}
return &out, nil
}

func (c *apiClient) DeleteShare(uuid string) error {
return c.do("DELETE", fmt.Sprintf("/api/shares/%s", uuid), nil, nil)
}
Expand Down
144 changes: 144 additions & 0 deletions archive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package main

import (
"flag"
"fmt"
)

func runArchive(args []string) error {
rio := defaultRemoveIO()
return runArchiveWith(args, &rio)
}

func runArchiveWith(args []string, rio *removeIO) error {
fs := flag.NewFlagSet("archive", flag.ContinueOnError)
all := fs.Bool("all", false, "archive every match (with a confirm prompt unless --yes)")
pick := fs.String("pick", "", "short_id to archive when the argument is ambiguous")
yes := fs.Bool("yes", false, "skip the confirmation prompt")
nonInteractive := fs.Bool("non-interactive", false, "fail instead of prompting for input")
if err := fs.Parse(args); err != nil {
return err
}
rest := fs.Args()
if len(rest) != 1 {
return fmt.Errorf("usage: gander archive [--all|--pick <short_id>|--yes|--non-interactive] (<filename>|<short_id>|<url>)")
}
opts := removeOptions{
all: *all,
pick: *pick,
yes: *yes,
nonInteractive: *nonInteractive,
}
return doArchive(rest[0], opts, rio)
}

func doArchive(arg string, opts removeOptions, rio *removeIO) error {
cfg, err := requireAuth()
if err != nil {
return err
}
cli := newAPIClient(cfg.APIURL, cfg.APIToken)

target := parseRemoveArg(arg)
matches, err := resolveRemoveTargets(cli, &cfg, target)
if err != nil {
return err
}

if opts.all && opts.pick != "" {
return fmt.Errorf("--all and --pick are mutually exclusive")
}

switch len(matches) {
case 0:
return fmt.Errorf("no share found for %q", arg)
case 1:
return confirmAndArchive([]shareResp{matches[0]}, opts, cfg, rio)
default:
if opts.pick != "" {
chosen, ok := pickFromMatches(matches, opts.pick)
if !ok {
return fmt.Errorf("--pick %s did not match any of the %d candidates; pass one of the SHORT_IDs listed below\n\n%s",
opts.pick, len(matches), formatMatchesTable(matches))
}
return confirmAndArchive([]shareResp{chosen}, opts, cfg, rio)
}
if opts.all {
return confirmAndArchive(matches, opts, cfg, rio)
}
if opts.nonInteractive || !rio.isTTY {
return ambiguousError(arg, matches)
}
chosen, err := promptPick(matches, rio, "archive")
if err != nil {
return err
}
return confirmAndArchive([]shareResp{chosen}, opts, cfg, rio)
}
}

func confirmAndArchive(targets []shareResp, opts removeOptions, cfg Config, rio *removeIO) error {
if len(targets) == 0 {
return nil
}
if !opts.yes && rio.isTTY {
fmt.Fprintf(rio.out(), "About to archive %d share(s):\n", len(targets))
fmt.Fprintln(rio.out(), formatMatchesTable(targets))
fmt.Fprintln(rio.out(), "The link is hidden, commenting is turned off, and sharing the file again puts the share back with the visibility and commenting it had before.")
ok, err := promptYesNo(rio)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("aborted")
}
}
cli := newAPIClient(cfg.APIURL, cfg.APIToken)
for i := range targets {
resp, err := cli.ArchiveShare(targets[i].UUID)
if err != nil {
return fmt.Errorf("archive %s: %w", targets[i].ShortID, err)
}
filename := targets[i].Filename
shareURL := targets[i].URL
if resp != nil {
if resp.Filename != "" {
filename = resp.Filename
}
if resp.URL != "" {
shareURL = resp.URL
}
}
fmt.Fprintf(rio.out(), "Archived %s (%s).\n", filename, shareURL)
archiveWatchStop(localPathForShare(cfg, targets[i]))
}
return nil
}

func localPathForShare(cfg Config, sh shareResp) string {
for path, sid := range cfg.Shares {
if sid == sh.ShortID {
return path
}
}
if sh.Path == "" {
return ""
}
if canonical, err := canonicalPath(sh.Path); err == nil {
return canonical
}
return sh.Path
}

func stopArchivedFileWatch(path string) {
if path == "" {
return
}
home, err := runnerHomeForCLI()
if err != nil {
return
}
_, _ = ipcRoundTrip(home, ipcRequest{Op: ipcOpDropArchived, Path: path})
}

var archiveWatchStop = stopArchivedFileWatch
Loading
Loading