krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

d6d78eea2febd692e88894d6833a8f303785d515

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:30:35Z

CLI: wire forge to the SSH control plane

- passthrough architecture: subcommands map onto server command paths
  with DisableFlagParsing, so the server stays the single source of
  truth for flags; --json and exit codes pass through untouched
- shells out to the system ssh binary (inherits ~/.ssh/config, agent,
  ProxyJump, hardware keys); args quoted for the server-side POSIX
  tokenizer; ssh exit 255 mapped to protocol error
- instance profiles at ~/.config/forge/config.toml (XDG-aware) with
  optional per-instance ssh_options; forge remote add/list
- repo inference: inside a clone, owner/name comes from the origin
  remote (ssh:// and scp-like URLs parsed); explicit owner/name as the
  first positional always wins; configured instances matched by
  host+port so their ssh_options apply
- $EDITOR opens for issue/mr create and comments when no body flag is
  given on a TTY; content travels as --file - over stdin
- local commands: repo clone (instance URL), mr checkout <n> (fetches
  refs/merge-requests/N/head into branch mr/N), init (git init + repo
  create under the authenticated user + origin), man page generation
  via cobra/doc; shell completions built in
- e2e: full flow through the real binary — remote add, whoami --json,
  create/clone, inferred and explicit repo args, exit-code passthrough,
  MR create/checkout/merge, keys add over stdin, forge init, man and
  completion generation
 cmd/forge/local.go                   | 298 +++++++++++++++++++++++++++++++++++
 cmd/forge/main.go                    | 258 ++++++++++++++++++++++--------
 cmd/forge/ssh.go                     | 123 +++++++++++++++
 e2e/cli_test.go                      | 205 ++++++++++++++++++++++++
 go.mod                               |   4 +
 go.sum                               |   4 +
 internal/cliconfig/cliconfig.go      | 120 ++++++++++++++
 internal/cliconfig/cliconfig_test.go |  46 ++++++
 8 files changed, 996 insertions(+), 62 deletions(-)

diff --git a/cmd/forge/local.go b/cmd/forge/local.go
new file mode 100644
index 0000000..e4f90dd
--- /dev/null
+++ b/cmd/forge/local.go
@@ -0,0 +1,298 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/term"
+
+	"github.com/krazywarez/forge/internal/cliconfig"
+	"github.com/krazywarez/forge/internal/protocol"
+)
+
+// hasBodyFlag reports whether args already carry body/message input.
+func hasBodyFlag(args []string) bool {
+	for _, a := range args {
+		if a == "--body" || a == "--message" || a == "--file" {
+			return true
+		}
+	}
+	return false
+}
+
+// maybeEditor opens $EDITOR for long text when the command usually wants a
+// body, none was given, and we are on a terminal. The result is passed to
+// the server via --file - on stdin. Returns the (possibly extended) args,
+// the stdin to use, and ok=false if the user aborted.
+func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, error) {
+	if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) {
+		return args, nil, true, nil
+	}
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		// No editor configured: proceed with an empty body rather than
+		// failing — bodies are optional everywhere.
+		return args, nil, true, nil
+	}
+	f, err := os.CreateTemp("", "forge-"+kind+"-*.md")
+	if err != nil {
+		return nil, nil, false, err
+	}
+	defer os.Remove(f.Name())
+	fmt.Fprintf(f, "\n# Write the %s body above. Lines starting with '#' are ignored.\n# Save an empty file to skip the body.\n", kind)
+	f.Close()
+
+	ed := exec.Command("sh", "-c", editor+" "+shellQuote(f.Name()))
+	ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr
+	if err := ed.Run(); err != nil {
+		return nil, nil, false, fmt.Errorf("editor: %w", err)
+	}
+	raw, err := os.ReadFile(f.Name())
+	if err != nil {
+		return nil, nil, false, err
+	}
+	var body strings.Builder
+	for _, line := range strings.Split(string(raw), "\n") {
+		if strings.HasPrefix(line, "#") {
+			continue
+		}
+		body.WriteString(line + "\n")
+	}
+	text := strings.TrimSpace(body.String())
+	if text == "" {
+		return args, nil, true, nil
+	}
+	return append(args, "--file", "-"), strings.NewReader(text + "\n"), true, nil
+}
+
+func runGitLocal(args ...string) int {
+	cmd := exec.Command("git", args...)
+	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+	if err := cmd.Run(); err != nil {
+		if ee, ok := err.(*exec.ExitError); ok {
+			return ee.ExitCode()
+		}
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	return 0
+}
+
+// cmdRepoClone implements `forge repo clone <owner/name> [dir]`.
+func cmdRepoClone(args []string) int {
+	if len(args) < 1 || strings.HasPrefix(args[0], "-") {
+		fmt.Fprintln(os.Stderr, "usage: forge repo clone <owner/name> [dir]")
+		return protocol.ExitUsage
+	}
+	t, err := resolveTarget()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	gitArgs := append([]string{"clone", t.inst.CloneURL(args[0])}, args[1:]...)
+	if len(t.inst.SSHOptions) > 0 {
+		os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " "))
+	}
+	return runGitLocal(gitArgs...)
+}
+
+// cmdMRCheckout implements `forge mr checkout <n>`: fetch the MR head from
+// origin and check it out as a local branch.
+func cmdMRCheckout(args []string) int {
+	if len(args) != 1 {
+		fmt.Fprintln(os.Stderr, "usage: forge mr checkout <n>")
+		return protocol.ExitUsage
+	}
+	n := args[0]
+	ref := "refs/merge-requests/" + n + "/head"
+	if code := runGitLocal("fetch", "origin", ref); code != 0 {
+		return code
+	}
+	return runGitLocal("checkout", "-B", "mr/"+n, "FETCH_HEAD")
+}
+
+// cmdInit implements `forge init [name] [--private]`: git init if needed,
+// create the repository on the default instance, and point origin at it.
+func cmdInit(args []string) int {
+	var name string
+	private := false
+	for _, a := range args {
+		switch {
+		case a == "--private":
+			private = true
+		case strings.HasPrefix(a, "-"):
+			fmt.Fprintln(os.Stderr, "usage: forge init [name] [--private]")
+			return protocol.ExitUsage
+		default:
+			name = a
+		}
+	}
+	if name == "" {
+		wd, err := os.Getwd()
+		if err != nil {
+			fmt.Fprintln(os.Stderr, "forge:", err)
+			return protocol.ExitFailure
+		}
+		name = filepath.Base(wd)
+	}
+
+	cfg, err := cliconfig.Load()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	inst, _, err := cfg.DefaultInstance()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	t := target{inst: inst}
+
+	// The server requires owner = the authenticated user; ask who that is.
+	whoami, code := captureSSH(t, []string{"whoami"})
+	if code != 0 {
+		return code
+	}
+	username := strings.TrimSpace(whoami)
+	repoPath := username + "/" + name
+
+	createArgs := []string{"repo", "create", repoPath}
+	if private {
+		createArgs = append(createArgs, "--private")
+	}
+	if code := runSSH(t, createArgs, strings.NewReader("")); code != 0 {
+		return code
+	}
+
+	if _, err := os.Stat(".git"); os.IsNotExist(err) {
+		if code := runGitLocal("init", "-q", "-b", "main"); code != 0 {
+			return code
+		}
+	}
+	url := inst.CloneURL(repoPath)
+	if code := runGitLocal("remote", "add", "origin", url); code != 0 {
+		return code
+	}
+	fmt.Printf("origin -> %s\npush with: git push -u origin main\n", url)
+	return 0
+}
+
+// captureSSH runs a server command and returns its stdout.
+func captureSSH(t target, serverArgv []string) (string, int) {
+	args := []string{}
+	if t.inst.Port != 0 && t.inst.Port != 22 {
+		args = append(args, "-p", fmt.Sprint(t.inst.Port))
+	}
+	args = append(args, t.inst.SSHOptions...)
+	quoted := quoteAll(serverArgv)
+	args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
+	cmd := exec.Command("ssh", args...)
+	cmd.Stderr = os.Stderr
+	out, err := cmd.Output()
+	if err != nil {
+		if ee, ok := err.(*exec.ExitError); ok {
+			return "", ee.ExitCode()
+		}
+		fmt.Fprintln(os.Stderr, "forge: running ssh:", err)
+		return "", protocol.ExitProtocol
+	}
+	return string(out), 0
+}
+
+func quoteAll(args []string) []string {
+	out := make([]string, len(args))
+	for i, a := range args {
+		out[i] = shellQuote(a)
+	}
+	return out
+}
+
+// cmdRemoteAdd implements `forge remote add <name> <host> [flags]`.
+func cmdRemoteAdd(args []string) int {
+	var name, host, user string
+	var port int
+	var setDefault bool
+	var sshOptions []string
+	i := 0
+	for i < len(args) {
+		a := args[i]
+		switch a {
+		case "--port":
+			if i+1 >= len(args) {
+				fmt.Fprintln(os.Stderr, "--port requires a value")
+				return protocol.ExitUsage
+			}
+			fmt.Sscanf(args[i+1], "%d", &port)
+			i += 2
+		case "--user":
+			if i+1 >= len(args) {
+				fmt.Fprintln(os.Stderr, "--user requires a value")
+				return protocol.ExitUsage
+			}
+			user = args[i+1]
+			i += 2
+		case "--ssh-option":
+			if i+1 >= len(args) {
+				fmt.Fprintln(os.Stderr, "--ssh-option requires a value")
+				return protocol.ExitUsage
+			}
+			sshOptions = append(sshOptions, args[i+1])
+			i += 2
+		case "--default":
+			setDefault = true
+			i++
+		default:
+			if name == "" {
+				name = a
+			} else if host == "" {
+				host = a
+			} else {
+				fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
+				return protocol.ExitUsage
+			}
+			i++
+		}
+	}
+	if name == "" || host == "" {
+		fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
+		return protocol.ExitUsage
+	}
+	cfg, err := cliconfig.Load()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	cfg.Instances[name] = cliconfig.Instance{Host: host, Port: port, User: user, SSHOptions: sshOptions}
+	if setDefault || cfg.Default == "" {
+		cfg.Default = name
+	}
+	if err := cliconfig.Save(cfg); err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	fmt.Printf("added instance %s (%s)\n", name, host)
+	return 0
+}
+
+func cmdRemoteList() int {
+	cfg, err := cliconfig.Load()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	for name, inst := range cfg.Instances {
+		def := ""
+		if name == cfg.Default {
+			def = " (default)"
+		}
+		port := inst.Port
+		if port == 0 {
+			port = 22
+		}
+		fmt.Printf("%s\t%s@%s:%d%s\n", name, inst.SSHUser(), inst.Host, port, def)
+	}
+	return 0
+}
diff --git a/cmd/forge/main.go b/cmd/forge/main.go
index 5effcfd..7849ece 100644
--- a/cmd/forge/main.go
+++ b/cmd/forge/main.go
@@ -1,13 +1,17 @@
-// forge is the client CLI. It speaks to a forge server over the system ssh
-// binary; it is ergonomics on top of a control plane that is fully usable
-// from bare OpenSSH.
+// forge is the client CLI. It is ergonomics over a control plane that is
+// fully usable from bare OpenSSH: most commands pass through to the server
+// over the system ssh binary, adding instance resolution, repo inference
+// from the origin remote, and $EDITOR for long text.
 package main
 
 import (
 	"fmt"
+	"io"
 	"os"
+	"strings"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/cobra/doc"
 
 	"github.com/krazywarez/forge/internal/protocol"
 )
@@ -19,8 +23,6 @@ func main() {
 		SilenceUsage:  true,
 		SilenceErrors: true,
 	}
-	root.PersistentFlags().Bool("json", false, "machine-readable output")
-	root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)")
 
 	root.AddCommand(
 		authCmd(),
@@ -28,116 +30,248 @@ func main() {
 		issueCmd(),
 		mrCmd(),
 		webCmd(),
-		adminCmd(),
 		remoteCmd(),
 		initCmd(),
+		manCmd(root),
 	)
 
 	if err := root.Execute(); err != nil {
 		fmt.Fprintln(os.Stderr, "forge:", err)
-		os.Exit(protocol.ExitFailure)
+		os.Exit(protocol.ExitUsage)
 	}
 }
 
-// stub returns a leaf command that fails until its milestone lands.
-func stub(use, short string) *cobra.Command {
+// passOpts describes how one CLI command maps onto the server command.
+type passOpts struct {
+	server    []string // server-side command path
+	needsRepo bool     // prepend inferred owner/name unless given
+	stdinOK   bool     // wire local stdin through (keys add, --file -)
+	editor    string   // open $EDITOR for a body when none given
+}
+
+// pass builds a passthrough command. Flags are parsed by the server, which
+// is the single source of truth for them; the CLI stays thin.
+func pass(use, short string, o passOpts) *cobra.Command {
 	return &cobra.Command{
-		Use:   use,
-		Short: short,
+		Use:                use,
+		Short:              short,
+		DisableFlagParsing: true,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			return fmt.Errorf("not implemented")
+			// cobra still owns `forge <cmd> --help`.
+			for _, a := range args {
+				if a == "--help" || a == "-h" {
+					return cmd.Help()
+				}
+			}
+			os.Exit(runPass(o, args))
+			return nil
 		},
 	}
 }
 
+func runPass(o passOpts, args []string) int {
+	t, err := resolveTarget()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "forge:", err)
+		return protocol.ExitFailure
+	}
+	if o.needsRepo {
+		args, err = withRepo(t, args)
+		if err != nil {
+			fmt.Fprintln(os.Stderr, "forge:", err)
+			return protocol.ExitUsage
+		}
+	}
+
+	var stdin io.Reader = strings.NewReader("")
+	if o.editor != "" {
+		extended, body, ok, err := maybeEditor(args, o.editor)
+		if err != nil {
+			fmt.Fprintln(os.Stderr, "forge:", err)
+			return protocol.ExitFailure
+		}
+		if !ok {
+			return protocol.ExitFailure
+		}
+		args = extended
+		if body != nil {
+			stdin = body
+		}
+	}
+	if stdin == nil || isEmptyReader(stdin) {
+		if o.stdinOK && usesStdin(args) {
+			stdin = os.Stdin
+		}
+	}
+	return runSSH(t, append(o.server, args...), stdin)
+}
+
+func isEmptyReader(r io.Reader) bool {
+	sr, ok := r.(*strings.Reader)
+	return ok && sr.Len() == 0
+}
+
+// usesStdin reports whether the arguments request stdin content.
+func usesStdin(args []string) bool {
+	for i, a := range args {
+		if a == "--file" && i+1 < len(args) && args[i+1] == "-" {
+			return true
+		}
+	}
+	return false
+}
+
 func group(use, short string, subs ...*cobra.Command) *cobra.Command {
 	c := &cobra.Command{Use: use, Short: short}
 	c.AddCommand(subs...)
 	return c
 }
 
+// local wraps a locally-implemented command (git plumbing, config).
+func local(use, short string, fn func(args []string) int) *cobra.Command {
+	return &cobra.Command{
+		Use:                use,
+		Short:              short,
+		DisableFlagParsing: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			for _, a := range args {
+				if a == "--help" || a == "-h" {
+					return cmd.Help()
+				}
+			}
+			os.Exit(fn(args))
+			return nil
+		},
+	}
+}
+
 func authCmd() *cobra.Command {
-	return group("auth", "identity: keys, emails, whoami",
-		stub("whoami", "show the authenticated account"),
+	keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)",
+		passOpts{server: []string{"keys", "add"}, stdinOK: true})
+	// keys add always reads stdin on the server; wire it through directly.
+	keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
+		t, err := resolveTarget()
+		if err != nil {
+			return err
+		}
+		os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), os.Stdin))
+		return nil
+	}
+	pgpAdd := &cobra.Command{
+		Use: "add", Short: "register an OpenPGP public key (armored, on stdin)",
+		DisableFlagParsing: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			t, err := resolveTarget()
+			if err != nil {
+				return err
+			}
+			os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), os.Stdin))
+			return nil
+		},
+	}
+	return group("auth", "identity: whoami, SSH and PGP keys",
+		pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}),
 		group("keys", "manage SSH keys",
-			stub("list", "list registered SSH keys"),
-			stub("add", "register an SSH key"),
-			stub("remove", "remove an SSH key"),
+			pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
+			keysAdd,
+			pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
 		),
 		group("pgp", "manage OpenPGP keys",
-			stub("list", "list registered PGP keys"),
-			stub("add", "register a PGP key"),
-			stub("remove", "remove a PGP key"),
-		),
-		group("email", "manage email addresses",
-			stub("add", "add an address"),
-			stub("verify", "confirm a verification code"),
+			pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
+			pgpAdd,
+			pass("remove", "remove a PGP key by fingerprint", passOpts{server: []string{"pgp", "remove"}}),
 		),
 	)
 }
 
 func repoCmd() *cobra.Command {
 	return group("repo", "create and manage repositories",
-		stub("create", "create a repository"),
-		stub("list", "list repositories"),
-		stub("show", "show repository details"),
-		stub("clone", "clone via ssh"),
-		stub("rename", "rename a repository"),
-		stub("delete", "delete a repository"),
-		stub("fork", "fork a repository"),
-		stub("import", "server-side mirror from a foreign URL"),
-		stub("settings", "get or set repository settings"),
+		pass("create", "create a repository: forge repo create <owner/name> [--private]",
+			passOpts{server: []string{"repo", "create"}}),
+		pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}),
+		pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
+		pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
+		pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
+		pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
+		local("clone", "clone via ssh: forge repo clone <owner/name> [dir]", cmdRepoClone),
+		group("access", "manage access grants",
+			pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
+			pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
+			pass("list", "list access grants", passOpts{server: []string{"repo", "access", "list"}, needsRepo: true}),
+		),
+		group("settings", "repository settings",
+			pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
+			pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
+			pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
+			pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
+			pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}),
+		),
 	)
 }
 
 func issueCmd() *cobra.Command {
 	return group("issue", "issues",
-		stub("create", "open an issue"),
-		stub("list", "list issues"),
-		stub("show", "show an issue"),
-		stub("comment", "comment on an issue"),
-		stub("close", "close an issue"),
-		stub("reopen", "reopen an issue"),
-		stub("label", "add or remove labels"),
-		stub("assign", "assign users"),
+		pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
+			passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}),
+		pass("list", "list issues [--state open|closed|all]", passOpts{server: []string{"issue", "list"}, needsRepo: true}),
+		pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
+		pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
+			passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
+		pass("close", "close an issue", passOpts{server: []string{"issue", "close"}, needsRepo: true}),
+		pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
+		pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
+		pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
 	)
 }
 
 func mrCmd() *cobra.Command {
 	return group("mr", "merge requests",
-		stub("create", "open a merge request"),
-		stub("list", "list merge requests"),
-		stub("show", "show a merge request"),
-		stub("diff", "show the diff"),
-		stub("checkout", "fetch and check out the MR head locally"),
-		stub("comment", "comment on a merge request"),
-		stub("review", "approve or request changes"),
-		stub("merge", "merge (fast-forward or merge-commit)"),
-		stub("close", "close without merging"),
+		pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
+			passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}),
+		pass("list", "list merge requests [--state ...]", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
+		pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
+		pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
+		local("checkout", "fetch and check out the MR head locally: forge mr checkout <n>", cmdMRCheckout),
+		pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
+		pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
+		pass("merge", "merge (fast-forward or merge-commit): [--strategy ff|merge]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
+		pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
 	)
 }
 
 func webCmd() *cobra.Command {
 	return group("web", "browser session",
-		stub("login", "mint a one-time browser login URL over ssh"),
-	)
-}
-
-func adminCmd() *cobra.Command {
-	return group("admin", "instance administration (admin accounts only)",
-		stub("user", "manage users"),
-		stub("invite", "issue registration invites"),
-		stub("stats", "instance statistics"),
+		pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}),
 	)
 }
 
 func remoteCmd() *cobra.Command {
 	return group("remote", "local instance profiles (no server contact)",
-		stub("add", "add a named forge instance"),
-		stub("list", "list configured instances"),
+		local("add", "add a named forge instance: forge remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]",
+			cmdRemoteAdd),
+		local("list", "list configured instances", func([]string) int { return cmdRemoteList() }),
 	)
 }
 
 func initCmd() *cobra.Command {
-	return stub("init", "git init + repo create + set origin, in one step")
+	return local("init", "git init + repo create + set origin, in one step: forge init [name] [--private]", cmdInit)
+}
+
+// manCmd generates man pages; a CLI-first tool without man pages is not
+// CLI-first.
+func manCmd(root *cobra.Command) *cobra.Command {
+	var dir string
+	cmd := &cobra.Command{
+		Use:    "man",
+		Short:  "generate man pages into a directory",
+		Hidden: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if err := os.MkdirAll(dir, 0o755); err != nil {
+				return err
+			}
+			return doc.GenManTree(root, &doc.GenManHeader{Title: "FORGE", Section: "1"}, dir)
+		},
+	}
+	cmd.Flags().StringVar(&dir, "dir", "man", "output directory")
+	return cmd
 }
diff --git a/cmd/forge/ssh.go b/cmd/forge/ssh.go
new file mode 100644
index 0000000..051274b
--- /dev/null
+++ b/cmd/forge/ssh.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/krazywarez/forge/internal/cliconfig"
+	"github.com/krazywarez/forge/internal/protocol"
+)
+
+// context is the resolved target for a command: which instance to talk to
+// and, when run inside a clone of a forge repo, which repository.
+type target struct {
+	inst cliconfig.Instance
+	repo string // owner/name, "" when not inferable
+}
+
+// resolveTarget picks the instance and repo. Inside a git repo whose origin
+// remote points at a configured (or any ssh) forge host, that wins;
+// otherwise the configured default instance.
+func resolveTarget() (target, error) {
+	cfg, err := cliconfig.Load()
+	if err != nil {
+		return target{}, err
+	}
+
+	if url := originURL(); url != "" {
+		if parsed, repo, ok := cliconfig.ParseRemoteURL(url); ok {
+			// Prefer a configured instance for the same host+port: it may
+			// carry ssh_options the bare URL cannot express.
+			norm := func(p int) int {
+				if p == 0 {
+					return 22
+				}
+				return p
+			}
+			for _, inst := range cfg.Instances {
+				if inst.Host == parsed.Host && norm(inst.Port) == norm(parsed.Port) {
+					return target{inst: inst, repo: repo}, nil
+				}
+			}
+			return target{inst: parsed, repo: repo}, nil
+		}
+	}
+
+	inst, _, err := cfg.DefaultInstance()
+	if err != nil {
+		return target{}, err
+	}
+	return target{inst: inst}, nil
+}
+
+func originURL() string {
+	out, err := exec.Command("git", "remote", "get-url", "origin").Output()
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}
+
+// bareWord matches arguments that need no quoting for the server-side
+// POSIX tokenizer.
+var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`)
+
+// shellQuote quotes one argument for the SSH command string; the server
+// tokenizes with POSIX rules and no expansion.
+func shellQuote(arg string) string {
+	if arg != "" && bareWord.MatchString(arg) {
+		return arg
+	}
+	return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
+}
+
+// runSSH executes the server command over the system ssh binary, wiring
+// stdio through. It returns the remote exit code.
+func runSSH(t target, serverArgv []string, stdin io.Reader) int {
+	args := []string{}
+	if t.inst.Port != 0 && t.inst.Port != 22 {
+		args = append(args, "-p", strconv.Itoa(t.inst.Port))
+	}
+	args = append(args, t.inst.SSHOptions...)
+	quoted := make([]string, len(serverArgv))
+	for i, a := range serverArgv {
+		quoted[i] = shellQuote(a)
+	}
+	args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
+
+	cmd := exec.Command("ssh", args...)
+	cmd.Stdin = stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	err := cmd.Run()
+	if err == nil {
+		return 0
+	}
+	if ee, ok := err.(*exec.ExitError); ok {
+		code := ee.ExitCode()
+		if code == 255 { // ssh-level failure (connection, auth, host key)
+			return protocol.ExitProtocol
+		}
+		return code
+	}
+	fmt.Fprintln(os.Stderr, "forge: running ssh:", err)
+	return protocol.ExitProtocol
+}
+
+// withRepo prepends the repo path to args unless the user already gave one
+// explicitly (a first argument containing '/'). Commands' server parsers
+// accept the path at any position, so the front is always safe.
+func withRepo(t target, args []string) ([]string, error) {
+	if len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/") {
+		return args, nil // explicit owner/name
+	}
+	if t.repo == "" {
+		return nil, fmt.Errorf("no repository given and none inferable: pass <owner/name> or run inside a clone of a forge repository")
+	}
+	return append([]string{t.repo}, args...), nil
+}
diff --git a/e2e/cli_test.go b/e2e/cli_test.go
new file mode 100644
index 0000000..df74903
--- /dev/null
+++ b/e2e/cli_test.go
@@ -0,0 +1,205 @@
+package e2e
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func buildForgeCLI(t *testing.T) string {
+	t.Helper()
+	bin := filepath.Join(t.TempDir(), "forge")
+	cmd := exec.Command("go", "build", "-o", bin, "github.com/krazywarez/forge/cmd/forge")
+	cmd.Dir = ".."
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("build forge: %v\n%s", err, out)
+	}
+	return bin
+}
+
+// cli runs the forge binary with an isolated config home.
+type cli struct {
+	bin       string
+	configDir string
+	inst      *instance
+	key       string
+}
+
+func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) {
+	t.Helper()
+	cmd := exec.Command(c.bin, args...)
+	cmd.Dir = dir
+	cmd.Env = append(os.Environ(),
+		"XDG_CONFIG_HOME="+c.configDir,
+		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
+		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
+		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
+		"EDITOR=", // no editor in tests: bodies come from flags
+	)
+	if stdin != "" {
+		cmd.Stdin = strings.NewReader(stdin)
+	}
+	var out, errOut strings.Builder
+	cmd.Stdout = &out
+	cmd.Stderr = &errOut
+	err := cmd.Run()
+	code := 0
+	if ee, ok := err.(*exec.ExitError); ok {
+		code = ee.ExitCode()
+	} else if err != nil {
+		t.Fatalf("forge %v: %v", args, err)
+	}
+	return out.String(), errOut.String(), code
+}
+
+func (c *cli) must(t *testing.T, dir, stdin string, args ...string) string {
+	t.Helper()
+	out, errOut, code := c.run(t, dir, stdin, args...)
+	if code != 0 {
+		t.Fatalf("forge %v: exit %d\nstdout: %s\nstderr: %s", args, code, out, errOut)
+	}
+	return out
+}
+
+func TestCLI(t *testing.T) {
+	inst := startInstance(t)
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice",
+		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
+
+	c := &cli{
+		bin:       buildForgeCLI(t),
+		configDir: t.TempDir(),
+		inst:      inst,
+		key:       aliceKey,
+	}
+
+	// Configure the instance, with ssh options so the test's throwaway key
+	// and known_hosts are used.
+	c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
+		"--port", fmt.Sprint(inst.port),
+		"--ssh-option", "-i", "--ssh-option", aliceKey,
+		"--ssh-option", "-oIdentitiesOnly=yes",
+		"--ssh-option", "-oStrictHostKeyChecking=no",
+		"--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"),
+		"--ssh-option", "-oBatchMode=yes",
+		"--default")
+	if out := c.must(t, "", "", "remote", "list"); !strings.Contains(out, "test\tgit@127.0.0.1") || !strings.Contains(out, "(default)") {
+		t.Fatalf("remote list: %s", out)
+	}
+
+	// whoami through the CLI, JSON passthrough intact.
+	out := c.must(t, "", "", "auth", "whoami", "--json")
+	var env struct {
+		ProtocolVersion int `json:"protocol_version"`
+		Data            struct {
+			Username string `json:"username"`
+		} `json:"data"`
+	}
+	if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Username != "alice" || env.ProtocolVersion != 1 {
+		t.Fatalf("whoami via CLI: %v %s", err, out)
+	}
+
+	// Repo create + clone through the CLI.
+	c.must(t, "", "", "repo", "create", "alice/proj")
+	work := t.TempDir()
+	c.must(t, work, "", "repo", "clone", "alice/proj")
+	dir := filepath.Join(work, "proj")
+	if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
+		t.Fatal("clone did not produce a repo")
+	}
+
+	// Push some content (plain git, using the clone's remote).
+	cliGitEnv := inst.gitEnv(aliceKey)
+	os.WriteFile(filepath.Join(dir, "README"), []byte("hi\n"), 0o644)
+	mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "main")
+	mustGit(t, dir, cliGitEnv, "add", ".")
+	mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "init")
+	mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "main")
+
+	// Inside the clone, the repo argument is inferred from origin.
+	c.must(t, dir, "", "issue", "create", "--title", "inferred repo works", "--body", "body")
+	out = c.must(t, dir, "", "issue", "list")
+	if !strings.Contains(out, "inferred repo works") {
+		t.Fatalf("issue list in clone: %s", out)
+	}
+	// Explicit owner/name still works from anywhere.
+	out = c.must(t, "", "", "issue", "show", "alice/proj", "1")
+	if !strings.Contains(out, "inferred repo works") {
+		t.Fatalf("issue show explicit: %s", out)
+	}
+	// Outside a clone with no explicit repo: usage error, not a hang.
+	_, errOut, code := c.run(t, "", "", "issue", "list")
+	if code != 2 || !strings.Contains(errOut, "none inferable") {
+		t.Fatalf("bare issue list outside clone: exit %d, %s", code, errOut)
+	}
+
+	// Exit codes pass through: missing issue is 3.
+	if _, _, code := c.run(t, dir, "", "issue", "show", "99"); code != 3 {
+		t.Fatalf("missing issue via CLI: exit %d, want 3", code)
+	}
+
+	// MR flow: branch, push, create (inferred), checkout, merge.
+	mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "feature")
+	os.WriteFile(filepath.Join(dir, "f.txt"), []byte("feature\n"), 0o644)
+	mustGit(t, dir, cliGitEnv, "add", ".")
+	mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "feature work")
+	mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "feature")
+	c.must(t, dir, "", "mr", "create", "--source", "feature", "--target", "main", "--title", "via cli")
+
+	// mr checkout uses the clone's own git; the MR ref comes from origin.
+	mustGit(t, dir, cliGitEnv, "checkout", "-q", "main")
+	cmd := exec.Command(c.bin, "mr", "checkout", "1")
+	cmd.Dir = dir
+	cmd.Env = append(cliGitEnv, "XDG_CONFIG_HOME="+c.configDir)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("mr checkout: %v\n%s", err, out)
+	}
+	branch := strings.TrimSpace(mustGit(t, dir, cliGitEnv, "rev-parse", "--abbrev-ref", "HEAD"))
+	if branch != "mr/1" {
+		t.Fatalf("mr checkout branch = %s", branch)
+	}
+	if _, err := os.Stat(filepath.Join(dir, "f.txt")); err != nil {
+		t.Fatal("mr checkout content missing")
+	}
+
+	c.must(t, dir, "", "mr", "merge", "1")
+	out = c.must(t, dir, "", "mr", "show", "1")
+	if !strings.Contains(out, "merged") {
+		t.Fatalf("mr not merged: %s", out)
+	}
+
+	// keys add reads the public key from CLI stdin.
+	secondKey := inst.newKey(t, "alice2")
+	pub, _ := os.ReadFile(secondKey + ".pub")
+	c.must(t, "", string(pub), "auth", "keys", "add", "--scope", "git")
+	if out = c.must(t, "", "", "auth", "keys", "list"); len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
+		t.Fatalf("keys list: %s", out)
+	}
+
+	// forge init: new local project, repo created server-side, origin set.
+	proj := filepath.Join(t.TempDir(), "newthing")
+	os.MkdirAll(proj, 0o755)
+	c.must(t, proj, "", "init", "--private")
+	originOut := mustGit(t, proj, cliGitEnv, "remote", "get-url", "origin")
+	if !strings.Contains(originOut, "/alice/newthing.git") {
+		t.Fatalf("init origin: %s", originOut)
+	}
+	if out = c.must(t, "", "", "repo", "show", "alice/newthing"); !strings.Contains(out, "private") {
+		t.Fatalf("init-created repo: %s", out)
+	}
+
+	// Man pages and completions generate.
+	manDir := t.TempDir()
+	c.must(t, "", "", "man", "--dir", manDir)
+	if entries, _ := os.ReadDir(manDir); len(entries) < 10 {
+		t.Fatalf("man pages: only %d generated", len(entries))
+	}
+	if out = c.must(t, "", "", "completion", "zsh"); !strings.Contains(out, "compdef") {
+		t.Fatal("zsh completion missing")
+	}
+}
diff --git a/go.mod b/go.mod
index d922dc9..54e370b 100644
--- a/go.mod
+++ b/go.mod
@@ -9,11 +9,13 @@ require (
 	github.com/spf13/cobra v1.10.2
 	github.com/yuin/goldmark v1.8.5
 	golang.org/x/crypto v0.55.0
+	golang.org/x/term v0.45.0
 	modernc.org/sqlite v1.57.0
 )
 
 require (
 	github.com/cloudflare/circl v1.6.2 // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
 	github.com/dlclark/regexp2/v2 v2.2.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
@@ -21,7 +23,9 @@ require (
 	github.com/mattn/go-isatty v0.0.24 // indirect
 	github.com/ncruces/go-strftime v1.0.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
+	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/sys v0.47.0 // indirect
 	modernc.org/libc v1.74.4 // indirect
 	modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index 2404706..65019dd 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,7 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
 github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
 github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
+github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
 github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
@@ -31,6 +32,7 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -38,6 +40,7 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
 github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
 golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
@@ -51,6 +54,7 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
 golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
 golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
 golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
 modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
diff --git a/internal/cliconfig/cliconfig.go b/internal/cliconfig/cliconfig.go
new file mode 100644
index 0000000..e35ee07
--- /dev/null
+++ b/internal/cliconfig/cliconfig.go
@@ -0,0 +1,120 @@
+// Package cliconfig manages the client-side configuration: named forge
+// instances at ~/.config/forge/config.toml, and parsing of origin remote
+// URLs so commands run inside a clone need no --repo argument.
+package cliconfig
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/BurntSushi/toml"
+)
+
+type Instance struct {
+	Host string `toml:"host"`
+	Port int    `toml:"port,omitempty"`
+	User string `toml:"user,omitempty"`
+	// SSHOptions are extra arguments passed to the ssh binary verbatim,
+	// e.g. ["-i", "~/.ssh/forge_ed25519"]. Most setups need none: the
+	// system ssh already honors ~/.ssh/config and the agent.
+	SSHOptions []string `toml:"ssh_options,omitempty"`
+}
+
+func (i Instance) SSHUser() string {
+	if i.User != "" {
+		return i.User
+	}
+	return "git"
+}
+
+// CloneURL returns the ssh:// URL for owner/name on this instance.
+func (i Instance) CloneURL(repo string) string {
+	hostport := i.Host
+	if i.Port != 0 && i.Port != 22 {
+		hostport = fmt.Sprintf("%s:%d", i.Host, i.Port)
+	}
+	return fmt.Sprintf("ssh://%s@%s/%s.git", i.SSHUser(), hostport, repo)
+}
+
+type Config struct {
+	Default   string              `toml:"default,omitempty"`
+	Instances map[string]Instance `toml:"instances"`
+}
+
+func Path() string {
+	if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
+		return filepath.Join(x, "forge", "config.toml")
+	}
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".config", "forge", "config.toml")
+}
+
+func Load() (Config, error) {
+	cfg := Config{Instances: map[string]Instance{}}
+	raw, err := os.ReadFile(Path())
+	if os.IsNotExist(err) {
+		return cfg, nil
+	}
+	if err != nil {
+		return cfg, err
+	}
+	if err := toml.Unmarshal(raw, &cfg); err != nil {
+		return cfg, fmt.Errorf("%s: %w", Path(), err)
+	}
+	if cfg.Instances == nil {
+		cfg.Instances = map[string]Instance{}
+	}
+	return cfg, nil
+}
+
+func Save(cfg Config) error {
+	p := Path()
+	if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
+		return err
+	}
+	var b strings.Builder
+	if err := toml.NewEncoder(&b).Encode(cfg); err != nil {
+		return err
+	}
+	return os.WriteFile(p, []byte(b.String()), 0o600)
+}
+
+// DefaultInstance returns the configured default (or the only) instance.
+func (c Config) DefaultInstance() (Instance, string, error) {
+	if c.Default != "" {
+		if inst, ok := c.Instances[c.Default]; ok {
+			return inst, c.Default, nil
+		}
+		return Instance{}, "", fmt.Errorf("default instance %q is not configured", c.Default)
+	}
+	if len(c.Instances) == 1 {
+		for name, inst := range c.Instances {
+			return inst, name, nil
+		}
+	}
+	return Instance{}, "", fmt.Errorf("no forge instance configured; run: forge remote add <name> <host>")
+}
+
+var (
+	sshURLPat = regexp.MustCompile(`^ssh://(?:([^@/]+)@)?([^:/]+)(?::(\d+))?/(.+?)(?:\.git)?/?$`)
+	scpPat    = regexp.MustCompile(`^(?:([^@/]+)@)?([^:/]+):(.+?)(?:\.git)?$`)
+)
+
+// ParseRemoteURL extracts the instance coordinates and owner/name from a
+// git remote URL in ssh:// or scp-like form.
+func ParseRemoteURL(url string) (Instance, string, bool) {
+	if m := sshURLPat.FindStringSubmatch(url); m != nil {
+		inst := Instance{Host: m[2], User: m[1]}
+		if m[3] != "" {
+			fmt.Sscanf(m[3], "%d", &inst.Port)
+		}
+		return inst, strings.Trim(m[4], "/"), true
+	}
+	if m := scpPat.FindStringSubmatch(url); m != nil && !strings.Contains(url, "://") {
+		return Instance{Host: m[2], User: m[1]}, strings.Trim(m[3], "/"), true
+	}
+	return Instance{}, "", false
+}
diff --git a/internal/cliconfig/cliconfig_test.go b/internal/cliconfig/cliconfig_test.go
new file mode 100644
index 0000000..4646ae4
--- /dev/null
+++ b/internal/cliconfig/cliconfig_test.go
@@ -0,0 +1,46 @@
+package cliconfig
+
+import "testing"
+
+func TestParseRemoteURL(t *testing.T) {
+	cases := []struct {
+		url  string
+		host string
+		port int
+		user string
+		repo string
+		ok   bool
+	}{
+		{"ssh://git@forge.example/alice/proj.git", "forge.example", 0, "git", "alice/proj", true},
+		{"ssh://git@forge.example:2222/alice/proj.git", "forge.example", 2222, "git", "alice/proj", true},
+		{"ssh://forge.example/alice/proj", "forge.example", 0, "", "alice/proj", true},
+		{"git@forge.example:alice/proj.git", "forge.example", 0, "git", "alice/proj", true},
+		{"git@forge.example:alice/proj", "forge.example", 0, "git", "alice/proj", true},
+		{"https://forge.example/alice/proj.git", "", 0, "", "", false},
+		{"/local/path/repo.git", "", 0, "", "", false},
+	}
+	for _, tc := range cases {
+		inst, repo, ok := ParseRemoteURL(tc.url)
+		if ok != tc.ok {
+			t.Errorf("%s: ok = %v, want %v", tc.url, ok, tc.ok)
+			continue
+		}
+		if !ok {
+			continue
+		}
+		if inst.Host != tc.host || inst.Port != tc.port || inst.User != tc.user || repo != tc.repo {
+			t.Errorf("%s: got host=%s port=%d user=%s repo=%s", tc.url, inst.Host, inst.Port, inst.User, repo)
+		}
+	}
+}
+
+func TestCloneURL(t *testing.T) {
+	i := Instance{Host: "forge.example"}
+	if got := i.CloneURL("a/b"); got != "ssh://git@forge.example/a/b.git" {
+		t.Errorf("CloneURL = %s", got)
+	}
+	i = Instance{Host: "forge.example", Port: 2222, User: "u"}
+	if got := i.CloneURL("a/b"); got != "ssh://u@forge.example:2222/a/b.git" {
+		t.Errorf("CloneURL = %s", got)
+	}
+}