Connect CLI invocations to a long-running daemon instance of the same binary.
One binary, two modes: the daemon runs endlessly (serve), and subsequent invocations connect to it to execute CLI commands or open a TUI.
type Config struct {
Host string // defaults to "127.0.0.1" on both server and client
Port int
Password string // optional; empty disables auth (loopback only; not safe on multi-user hosts)
HostKeyPEM []byte // server: optional persisted SSH host private key (PEM). Empty = ephemeral per Listen.
HostPublicKey []byte // client: pinned host public key (authorized_keys format). Required for non-loopback Host; empty = no verification (loopback only, MITM risk on remote).
ShutdownGrace time.Duration // server: how long Listen waits for in-flight handlers after ctx cancel before force-closing. Zero = 5s.
}Note: Host is checked as a literal IP. Hostnames (e.g.
"localhost") are treated as non-loopback — Listen rejects them when Password is empty, and Connect rejects them when HostPublicKey is empty. Use a literal loopback IP (e.g.127.0.0.1) or set Password / HostPublicKey explicitly.
type Session interface {
io.Reader
io.Writer
Stderr() io.Writer
// File forwarding: server reads/writes files on the client's local
// filesystem. The client enforces an exact-string allowlist derived from
// the args it passed to Connect — only paths matching one of those argv
// strings (or the RHS of any "-"-prefixed "key=value" arg, e.g.
// "--file=/tmp/x" or "-f=/tmp/x") are served. Transfers are confirmed
// end-to-end: a truncated read or a failed write surfaces as an error
// (from Close, for the streaming forms) rather than silent partial data.
ReadFile(path string) ([]byte, error)
OpenFile(path string) (io.ReadCloser, error)
WriteFile(path string, data []byte) error
CreateFile(path string) (io.WriteCloser, error)
}
type Handler func(ctx context.Context, s Session, args []string) error
// Handler is the single dispatch point. args is empty for interactive
// (no-args) clients — return *Interactive there to launch the main TUI.
// Return ErrNotHandled if the command is unknown; the session exits 127.
// Return *ExitError to set a custom remote exit code:
// return &clink.ExitError{Code: 2, Err: err}
// Return *Interactive to launch a Bubble Tea TUI for this command:
// return &clink.Interactive{Model: dashboard.New()}
// (subcommand TUIs require the client to call Connect with clink.WithPTY)
// ctx is cancelled when the client disconnects.
// Handler runs on one goroutine per session, concurrently with other sessions —
// it and everything it closes over must be goroutine-safe. See "Concurrency".
// Daemon side: listen for incoming commands and TUI sessions.
clink.Listen(ctx, conf, handler)
// Client side. Connect forwards args to the daemon.
// AutoPTY allocates a PTY iff os.Stdin is a terminal (mirrors ssh's default),
// so the same call handles TUI subcommands and pipe-friendly plain commands.
// WithLocalCommand names a subcommand that must run locally instead of being
// forwarded (typically the one that starts the daemon). Connect refuses to
// invoke a local command when a daemon is already reachable, preventing a
// double-run.
// WithLocalFallback names a command that runs locally ONLY when no daemon is
// reachable; if the daemon is up, Connect forwards as usual. Use name "" to
// match the no-args invocation — handy for an entry that opens the dashboard
// when the daemon runs and starts the daemon otherwise.
clink.Connect(ctx, conf, args,
clink.AutoPTY(),
clink.WithLocalCommand("run", runLocally),
clink.WithLocalFallback("", runLocally),
)
// Reachable is the liveness probe both options use — a 200ms TCP dial, no
// handshake, no auth. Exported for consumers that would rather dispatch local
// commands with a plain if/else (see "Dispatching it yourself").
clink.Reachable(conf)clink is framework-agnostic: the client only forwards, so it doesn't need any
CLI framework. Only the run command (which starts the daemon) uses one on
its way to clink.Listen. Below uses urfave/cli v3; cobra works identically.
package main
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/go-devkit/clink"
"github.com/urfave/cli/v3"
)
var conf = clink.Config{Port: 2222, Password: "secret"}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
err := clink.Connect(ctx, conf, os.Args[1:],
clink.AutoPTY(),
clink.WithLocalCommand("run", runLocally),
// Optional: `myapp` (no subcommand) starts the daemon if none is up,
// else forwards and opens the main TUI.
clink.WithLocalFallback("", runLocally),
)
if err != nil {
os.Exit(1)
}
}
// runLocally is invoked instead of Connect when args[0] == "run".
// This is where wire/DI builds the full application and hands the CLI
// tree to clink.Listen.
func runLocally(ctx context.Context, _ []string) error {
// e.g. wire.BuildApp() — full DI happens ONLY here, on the server env.
app, cleanup, err := buildApp(ctx)
if err != nil {
return err
}
defer cleanup()
handler := func(hctx context.Context, s clink.Session, args []string) error {
// Build a fresh command tree per session: handlers run concurrently and
// a shared *cli.Command carries per-run state (I/O writers, parsed
// flags) that two sessions would race on.
cmd := app.NewRoot() // *cli.Command with wire-injected subcommands
// urfave/cli v3 inherits Reader/Writer/ErrWriter from parent, so only
// the root needs wiring.
cmd.Reader, cmd.Writer, cmd.ErrWriter = s, s, s.Stderr()
return cmd.Run(clink.WithSession(hctx, s), append([]string{cmd.Name}, args...))
}
return clink.Listen(ctx, conf, handler)
}myapp run— starts the daemon; refuses if one is already up.myapp anything else …— forwards to the daemon. PTY allocated when stdin is a tty (TUI subcommands work); no PTY when piped (myapp report | jqworks).myapp(no subcommand) — opens the daemon's shell-mode session; Handler receives empty args and can return*Interactivefor the main TUI.
The client binary never touches wire, DB, or any server-only service. Only
runLocally does. Same binary on both sides.
The handler above is the same in every urfave-based consumer, and the parts that
are easy to get wrong — urfave's process-global OsExiter and ErrWriter,
argv[0], cli.ExitCoder → clink.ExitError, panic recovery — are not
application-specific. github.com/go-devkit/clink/urfave (a nested module, so
urfave/cli stays off clink's dependency list) does all of it:
return urfave.Serve(ctx, conf, func(s clink.Session) *cli.Command {
return rootCommand(s, commands)
})newRoot runs per session, which is also what keeps the fresh-tree rule below
satisfied. Handler is exported for applications that need to wrap it — routing
empty args to a main TUI, say.
WithLocalCommand is convenience, not a requirement: it is a name match plus the
Reachable probe. An if does the same job, and keeps dispatch where you can
read it:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
var err error
switch {
case len(os.Args) > 1 && os.Args[1] == "run":
if clink.Reachable(conf) {
err = errors.New("daemon already running")
break
}
err = urfave.Serve(ctx, conf, newRoot) // never dials; this process IS the daemon
default:
err = clink.Connect(ctx, conf, os.Args[1:], clink.AutoPTY())
}
// ... ExitCode(err) handling
}Everything not named run forwards — no per-subcommand registration either way.
Use the options when you want the ready-made refusal message and the
forward-if-up / run-if-down fallback; use the if when you want the control.
Handler runs on its own goroutine per session, and clink serializes nothing:
several clients — or several sessions from one client — can be inside Handler
simultaneously. Handler and everything it closes over (CLI command tree,
wire-built services, caches, loggers) must be safe for concurrent use.
CLI frameworks are usually not. A single *cli.Command (urfave) or
*cobra.Command holds per-run state — the I/O writers you assign and the parsed
flag values — so reusing one instance across sessions races and can leak one
client's output into another's. Build or clone the command tree inside Handler,
as the example above does.
A single Session is per-connection and not safe for concurrent use by multiple
goroutines within one Handler call.
clink assumes daemon and clients share one trust domain, typically a loopback-bound daemon serving processes of the same user:
- Empty
Passwordmeans no authentication.Listenthen accepts any public key, andConnectauthenticates with a throwaway in-memory key. Any local user or process able to reach the port gets a session — and thus arbitrary command execution plus file read/write on connecting clients' behalf.Listenrefuses to start in this mode on a non-loopback host, but on a multi-user machine loopback is not a boundary: setPassword. - No rate limiting, no connection or session cap. Password auth compares a SHA-256 digest in constant time, but nothing throttles guesses or bounds the number of open connections. Don't expose the port; front it with a real gateway if you must.
- Host key pinning is opt-in. Without
HostKeyPEMthe daemon's key is ephemeral per start, so clients cannot pin it across restarts. For any non-loopbackHost,ConnectrequiresHostPublicKey. - File forwarding is allowlisted, not sandboxed. The client only serves
paths that appeared verbatim in the args it passed to
Connect, so the daemon cannot read arbitrary client files — but it can read or overwrite anything the user named on the command line.
There is no version or protocol negotiation between Connect and Listen. The
wire contract — argv forwarding, the file-request channel payload, exit-code
signalling — is assumed identical on both ends because both ends are the same
binary. A client built against a different clink version than the running
daemon may fail in unhelpful ways instead of reporting a mismatch. Restart the
daemon after upgrading the binary.