ClipmanCli/cmd/clipman-cli/main.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"bufio"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
"golang.org/x/term"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/clipdb"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/config"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/identity"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/merge"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/model"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/operation"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/output"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/platform"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/server"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/syncengine"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/template"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/ui/handoff"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/ui/line"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/ui/tui"
)
var version = "0.10.0"
type appError struct {
code int
err error
}
func (e appError) Error() string { return e.err.Error() }
func fail(code int, format string, args ...any) error {
return appError{code: code, err: fmt.Errorf(format, args...)}
}
// errHelpRequested reports that a command printed its own usage in response to
// --help or -h. It is not a failure and must not be written to standard error.
var errHelpRequested = errors.New("help requested")
type optionalString struct {
value string
set bool
}
type globals struct {
configPath, server, caCertFile string
password optionalString
json, quiet, verbose, showVersion, help bool
insecure bool
}
type appContext struct {
globals globals
configPath string
config config.Config
token, password, databaseID string
client *server.Client
engine *syncengine.Engine
}
func main() { code := run(os.Args[1:]); os.Exit(code) }
func run(args []string) int {
globals, remaining, err := parseGlobals(args)
if err != nil {
return printError(err)
}
if globals.showVersion {
if _, err := fmt.Printf("clipman-cli %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH); err != nil {
return printError(fail(1, "cannot write output: %v", err))
}
return 0
}
if len(remaining) == 0 {
if globals.help {
printUsage(os.Stdout)
return 0
}
if platform.IsInteractive() {
remaining = []string{"menu"}
} else {
printUsage(os.Stderr)
return 2
}
}
command := remaining[0]
commandArgs := remaining[1:]
if globals.help || hasHelpOption(commandArgs) {
if printCommandUsage(os.Stdout, command) {
return 0
}
return printError(fail(2, "unknown command %q", command))
}
if command == "help" {
if len(commandArgs) == 0 {
printUsage(os.Stdout)
return 0
}
if len(commandArgs) == 1 && printCommandUsage(os.Stdout, commandArgs[0]) {
return 0
}
return printError(fail(2, "usage: clipman-cli help [COMMAND]"))
}
known := map[string]bool{"init": true, "status": true, "list": true, "get": true, "put": true, "rm": true, "sync": true, "pick": true, "menu": true, "rules": true, "agent": true}
if !known[command] {
return printError(fail(2, "unknown command %q", command))
}
var commandErr error
if command == "init" {
commandErr = runInit(globals, commandArgs)
} else if command == "agent" {
commandErr = runAgent(globals, commandArgs)
} else {
ctx, err := loadContext(globals)
if err != nil {
return printError(err)
}
switch command {
case "status":
commandErr = runStatus(ctx, commandArgs)
case "list":
commandErr = runList(ctx, commandArgs)
case "get":
commandErr = runGet(ctx, commandArgs)
case "put":
commandErr = runPut(ctx, commandArgs)
case "rm":
commandErr = runRemove(ctx, commandArgs)
case "sync":
commandErr = runSync(ctx, commandArgs)
case "pick":
commandErr = runPick(ctx, commandArgs)
case "menu":
commandErr = runMenu(ctx, commandArgs)
case "rules":
commandErr = runRules(ctx, commandArgs)
}
}
return printError(commandErr)
}
func hasHelpOption(args []string) bool {
return len(args) > 0 && (args[0] == "--help" || args[0] == "-h")
}
func printError(err error) int {
if err == nil || errors.Is(err, errHelpRequested) {
return 0
}
code := 1
var app appError
if errors.As(err, &app) {
code = app.code
}
fmt.Fprintln(os.Stderr, "clipman-cli:", err)
return code
}
func parseGlobals(args []string) (globals, []string, error) {
var g globals
value := func(index *int, arg, name string) (string, bool, error) {
prefix := "--" + name + "="
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix), true, nil
}
if arg == "--"+name {
if *index+1 >= len(args) {
return "", false, fail(2, "%s requires a value", arg)
}
*index = *index + 1
return args[*index], true, nil
}
return "", false, nil
}
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return g, args[i+1:], nil
}
if !strings.HasPrefix(arg, "-") {
return g, args[i:], nil
}
if v, ok, err := value(&i, arg, "config"); err != nil {
return g, nil, err
} else if ok {
g.configPath = v
continue
}
if v, ok, err := value(&i, arg, "server"); err != nil {
return g, nil, err
} else if ok {
g.server = v
continue
}
if v, ok, err := value(&i, arg, "password"); err != nil {
return g, nil, err
} else if ok {
g.password = optionalString{value: v, set: true}
continue
}
if v, ok, err := value(&i, arg, "ca-cert"); err != nil {
return g, nil, err
} else if ok {
g.caCertFile = v
continue
}
switch arg {
case "--json":
g.json = true
case "--quiet", "-q":
g.quiet = true
case "--verbose":
g.verbose = true
case "--insecure":
g.insecure = true
case "--version":
g.showVersion = true
case "--help", "-h":
g.help = true
default:
return g, nil, fail(2, "unknown global option %q; place command options after the command", arg)
}
}
return g, nil, nil
}
func addOutputFlags(fs *flag.FlagSet, g *globals) {
fs.BoolVar(&g.json, "json", g.json, "write JSON output")
fs.BoolVar(&g.quiet, "quiet", g.quiet, "suppress status messages")
fs.BoolVar(&g.quiet, "q", g.quiet, "suppress status messages")
fs.BoolVar(&g.verbose, "verbose", g.verbose, "write diagnostic status messages")
}
// newFlagSet builds a command flag set that writes nothing itself, so that
// parseCommandFlags is the single place that reports usage and errors. The flag
// package would otherwise repeat the error and add a long generated option
// dump, which is unordered and reads poorly aloud in a screen reader.
func newFlagSet(command string) *flag.FlagSet {
fs := flag.NewFlagSet(command, flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.Usage = func() {}
return fs
}
// parseCommandFlags parses args and turns a --help or -h anywhere in the
// command line into the command's usage on standard output, so that
// `get --json --help` behaves like `get --help`. Any other parse failure gets
// the same one-line syntax on standard error, followed by the error itself.
func parseCommandFlags(fs *flag.FlagSet, command string, args []string) error {
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
printCommandUsage(os.Stdout, command)
return errHelpRequested
}
printCommandUsage(os.Stderr, command)
return fail(2, "%v", err)
}
return nil
}
// permuteArgs moves positional operands behind the options so that options may
// follow an index, as the documented syntax implies. The flag package stops at
// the first operand, which would otherwise make `get 0 --json` read --json as a
// second index and report a confusing "only one index may be supplied".
func permuteArgs(fs *flag.FlagSet, args []string) []string {
options := make([]string, 0, len(args))
operands := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
operands = append(operands, args[i+1:]...)
break
}
if len(arg) < 2 || !strings.HasPrefix(arg, "-") {
operands = append(operands, arg)
continue
}
options = append(options, arg)
if strings.Contains(arg, "=") {
continue
}
defined := fs.Lookup(strings.TrimLeft(arg, "-"))
if defined == nil {
continue
}
if boolValue, ok := defined.Value.(interface{ IsBoolFlag() bool }); ok && boolValue.IsBoolFlag() {
continue
}
if i+1 < len(args) {
i++
options = append(options, args[i])
}
}
if len(operands) == 0 {
return options
}
// The separator keeps an operand that begins with "-" from being reparsed
// as an option once it has moved behind the options.
return append(append(options, "--"), operands...)
}
// verbosef writes a diagnostic line to standard error for --verbose. It never
// reports a token, a password, or a full database identifier.
func verbosef(g globals, format string, args ...any) {
if !g.verbose || g.quiet {
return
}
fmt.Fprintf(os.Stderr, "clipman-cli: "+format+"\n", args...)
}
// tlsOptionsForGlobals resolves the TLS trust options to use for a connection,
// preferring explicit --insecure/--ca-cert global flags over whatever was
// persisted to the configuration file by `init`.
func tlsOptionsForGlobals(g globals, cfg config.Config) ([]server.Option, error) {
if g.insecure && g.caCertFile != "" {
return nil, fail(2, "--insecure and --ca-cert cannot be used together")
}
if g.insecure {
fmt.Fprintln(os.Stderr, "Warning: --insecure disables TLS certificate verification. Use only on a trusted private network.")
return []server.Option{server.WithInsecureSkipVerify()}, nil
}
if g.caCertFile != "" {
pem, err := readCACertFile(g.caCertFile)
if err != nil {
return nil, err
}
return []server.Option{server.WithCACertPEM(pem)}, nil
}
if cfg.TLSInsecure {
fmt.Fprintln(os.Stderr, "Warning: this profile disables TLS certificate verification. Use only on a trusted private network.")
return []server.Option{server.WithInsecureSkipVerify()}, nil
}
if cfg.CACertPEM != "" {
if cfg.CAExclusive {
return []server.Option{server.WithExclusiveCACertPEM([]byte(cfg.CACertPEM))}, nil
}
return []server.Option{server.WithCACertPEM([]byte(cfg.CACertPEM))}, nil
}
return nil, nil
}
func readCACertFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fail(3, "cannot read CA certificate file: %v", err)
}
if !x509.NewCertPool().AppendCertsFromPEM(data) {
return nil, fail(2, "%s does not contain a valid PEM certificate", path)
}
return data, nil
}
// isCertificateTrustError reports whether err is a TLS failure caused by an
// untrusted/unrecognized certificate, as opposed to a network or protocol
// failure that a trust prompt cannot help with.
func isCertificateTrustError(err error) bool {
var verifyErr *tls.CertificateVerificationError
if errors.As(err, &verifyErr) {
err = verifyErr.Err
}
var unknownAuthority x509.UnknownAuthorityError
return errors.As(err, &unknownAuthority)
}
// fetchServerCertificate connects to rawURL's host without verifying the
// certificate, purely to retrieve it for display in a trust prompt. It does
// not use the result to make the connection trusted.
func fetchServerCertificates(ctx context.Context, rawURL string) (*x509.Certificate, *x509.Certificate, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, nil, err
}
if parsed.Scheme != "https" {
return nil, nil, errors.New("server does not use HTTPS")
}
host := parsed.Hostname()
if host == "" {
return nil, nil, errors.New("server host is missing")
}
port := parsed.Port()
if port == "" {
port = "443"
}
address := net.JoinHostPort(host, port)
dialer := &tls.Dialer{
NetDialer: &net.Dialer{Timeout: 8 * time.Second},
Config: &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
ServerName: host,
},
}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return nil, nil, err
}
defer conn.Close()
tlsConn, ok := conn.(*tls.Conn)
if !ok {
return nil, nil, errors.New("server connection did not negotiate TLS")
}
state := tlsConn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return nil, nil, errors.New("server did not present a certificate")
}
return state.PeerCertificates[0], state.PeerCertificates[len(state.PeerCertificates)-1], nil
}
func certificateFingerprintSHA256(cert *x509.Certificate) string {
sum := sha256.Sum256(cert.Raw)
parts := make([]string, len(sum))
for i, b := range sum {
parts[i] = fmt.Sprintf("%02X", b)
}
return strings.Join(parts, ":")
}
// promptTrustCertificate shows the server's certificate details and fingerprint
// and asks the user to confirm trusting it, browser-exception style. It
// returns the PEM-encoded certificate to trust, or nil if the user declined
// or the certificate could not be retrieved for display.
func promptTrustCertificate(ctx context.Context, rawURL string) ([]byte, error) {
leaf, authority, err := fetchServerCertificates(ctx, rawURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not retrieve the server certificate to display: %v\n", err)
return nil, nil
}
fmt.Fprintln(os.Stderr, "The server's TLS certificate is not trusted.")
fmt.Fprintf(os.Stderr, " Server subject: %s\n", leaf.Subject)
fmt.Fprintf(os.Stderr, " Server issuer: %s\n", leaf.Issuer)
fmt.Fprintf(os.Stderr, " Server certificate valid: %s to %s\n", leaf.NotBefore.Format("2006-01-02"), leaf.NotAfter.Format("2006-01-02"))
fmt.Fprintf(os.Stderr, " Authority SHA-256 fingerprint: %s\n", certificateFingerprintSHA256(authority))
fmt.Fprintln(os.Stderr, "Compare the authority fingerprint with the one shown by the Clipman Server administrator before trusting it.")
trust, err := promptYesNo("Trust this certificate for this server", false)
if err != nil {
return nil, err
}
if !trust {
return nil, nil
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: authority.Raw}), nil
}
func loadContext(g globals) (*appContext, error) {
return loadContextWithPrompt(g, true)
}
func loadContextWithPrompt(g globals, allowPrompt bool) (*appContext, error) {
path, err := platform.ConfigPath(g.configPath)
if err != nil {
return nil, fail(3, "cannot locate configuration: %v", err)
}
cfg, err := config.Load(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fail(3, "Clipman CLI is not configured; run clipman-cli init")
}
return nil, fail(3, "cannot read configuration: %v", err)
}
token, err := cfg.ResolvedToken()
if err != nil {
return nil, fail(3, "cannot unlock server token: %v", err)
}
password, err := resolvePassword(g, cfg, allowPrompt)
if err != nil {
return nil, err
}
serverURL := cfg.Server
if g.server != "" {
serverURL = g.server
}
tlsOptions, err := tlsOptionsForGlobals(g, cfg)
if err != nil {
return nil, err
}
databaseID := identity.DatabaseID(token, password)
client, err := server.New(serverURL, token, databaseID, version+" ("+runtime.GOOS+"/"+runtime.GOARCH+")", tlsOptions...)
if err != nil {
return nil, fail(2, "invalid server configuration: %v", err)
}
verbosef(g, "configuration %s", path)
verbosef(g, "server %s", serverURL)
verbosef(g, "history bucket %s", fingerprint(databaseID))
limits := clipdb.Limits{MaxBlobBytes: cfg.Limits.MaxBlobBytes, MaxJSONBytes: cfg.Limits.MaxJSONBytes, MaxEntries: cfg.Limits.MaxEntries, MaxTextBytes: cfg.Limits.MaxTextBytes}
engine := &syncengine.Engine{Client: client, Password: password, Limits: limits, Retries: 3, Token: token, CachedRules: initialCachedRules(path)}
return &appContext{globals: g, configPath: path, config: cfg, token: token, password: password, databaseID: databaseID, client: client, engine: engine}, nil
}
func resolvePassword(g globals, cfg config.Config, allowPrompt bool) (string, error) {
var password string
if g.password.set {
password = g.password.value
} else if value, ok := os.LookupEnv("CLIPMAN_PASSWORD"); ok {
password = value
} else if value, ok, err := cfg.ResolvedPassword(); err != nil {
return "", fail(3, "cannot unlock history password: %v", err)
} else if ok {
password = value
} else if !allowPrompt {
return "", fail(5, "history password is required; set CLIPMAN_PASSWORD or use --password")
} else {
var err error
password, err = promptPassword("History password: ")
if err != nil {
return "", err
}
}
if password == "" {
return "", fail(5, "Clipman Server requires a nonblank history password")
}
return password, nil
}
func runInit(g globals, args []string) error {
fs := newFlagSet("init")
addOutputFlags(fs, &g)
tokenValue := fs.String("token", "", "server token (visible in process lists)")
tokenFile := fs.String("token-file", "", "read server token from a file")
connectionFile := fs.String("connection-file", "", "read server address and token from Clipman connection details")
savePassword := fs.String("save-password", "none", "none or config")
machine := fs.String("machine", "", "source machine name")
nonInteractive := fs.Bool("non-interactive", false, "do not prompt")
force := fs.Bool("force", false, "replace existing configuration")
portable := fs.Bool("portable", false, "write the configuration beside this executable")
if err := parseCommandFlags(fs, "init", args); err != nil {
return err
}
savePasswordExplicit := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "save-password" {
savePasswordExplicit = true
}
})
// Where the settings go is asked before anything else, because it decides
// which file the rest of this command is about to write.
//
// The question avoids "system wide", which would be wrong — nothing here
// writes machine-wide, only to this user account — and "portable", which
// is jargon that does not say portable relative to what. What a person
// answering actually needs to know is which copies end up sharing the
// settings.
saveBesideProgram := *portable
if !saveBesideProgram && shouldAskWhereToSave(g, *nonInteractive) {
fmt.Fprintln(os.Stderr, "Settings are normally saved for your user account, where every copy of Clipman CLI shares them.")
answer, promptErr := promptYesNo("Save them beside this program instead, so only this copy uses them?", false)
if promptErr != nil {
return promptErr
}
saveBesideProgram = answer
}
var path string
var err error
if saveBesideProgram {
if g.configPath != "" {
return fail(2, "--portable and --config cannot be used together")
}
path, err = platform.PortableConfigPath()
if err != nil {
return fail(3, "cannot locate this executable: %v", err)
}
} else {
path, err = platform.ConfigPath(g.configPath)
if err != nil {
return fail(3, "cannot locate configuration: %v", err)
}
}
if config.Exists(path) && !*force {
return fail(2, "configuration already exists at %s; use --force to replace it", path)
}
serverURL := g.server
var connectionCACertPEM []byte
if *connectionFile == "" && *tokenValue == "" && *tokenFile == "" && serverURL == "" && !*nonInteractive {
useFile, promptErr := promptYesNo("Do you have a Clipman Server connection file (.clpconf)?", false)
if promptErr != nil {
return promptErr
}
if useFile {
filePath, promptErr := promptLine("Path to connection file: ")
if promptErr != nil {
return promptErr
}
*connectionFile = strings.Trim(strings.TrimSpace(filePath), "\"")
}
}
if *connectionFile != "" {
if *tokenFile != "" || *tokenValue != "" {
return fail(2, "--connection-file cannot be combined with --token or --token-file")
}
connectionInfo, readErr := os.Stat(*connectionFile)
if readErr != nil {
return fail(3, "cannot inspect connection file: %v", readErr)
}
if connectionInfo.Size() > 65536 {
return fail(2, "connection file is too large")
}
connectionData, readErr := os.ReadFile(*connectionFile)
if readErr != nil {
return fail(3, "cannot read connection file: %v", readErr)
}
extractedServer, extractedToken, extractedCA, profileErr := server.ConnectionProfile(string(connectionData))
if profileErr != nil {
return fail(2, "invalid connection file: %v", profileErr)
}
if serverURL == "" {
serverURL = extractedServer
}
*tokenValue = extractedToken
connectionCACertPEM = []byte(extractedCA)
}
if serverURL == "" {
if *nonInteractive {
return fail(2, "--server is required in non-interactive mode")
}
serverURL, err = promptLine("Clipman Server address: ")
if err != nil {
return err
}
}
token := *tokenValue
if *tokenFile != "" {
if token != "" {
return fail(2, "--token and --token-file cannot be used together")
}
data, readErr := os.ReadFile(*tokenFile)
if readErr != nil {
return fail(3, "cannot read token file: %v", readErr)
}
token = string(data)
}
if token == "" {
if value := os.Getenv("CLIPMAN_TOKEN"); value != "" {
token = value
} else if *nonInteractive {
return fail(2, "a token is required in non-interactive mode")
} else {
token, err = promptPassword("Clipman Server token: ")
if err != nil {
return err
}
}
}
token = server.CleanToken(token)
if token == "" {
return fail(2, "server token is empty")
}
var password string
if g.password.set {
password = g.password.value
} else if value, ok := os.LookupEnv("CLIPMAN_PASSWORD"); ok {
password = value
} else if *nonInteractive {
return fail(2, "provide a nonblank --password in non-interactive mode")
} else {
password, err = promptPassword("History password: ")
if err != nil {
return err
}
}
if password == "" {
return fail(5, "Clipman Server requires a nonblank history password")
}
if !savePasswordExplicit && !*nonInteractive {
save, promptErr := promptYesNo("Save the history password in the configuration file so it is not requested again?", false)
if promptErr != nil {
return promptErr
}
if save {
*savePassword = "config"
if !g.quiet {
if runtime.GOOS == "windows" {
fmt.Fprintln(os.Stderr, "The password will be saved, encrypted for this Windows user account.")
} else {
fmt.Fprintln(os.Stderr, "The password will be saved in the configuration file, protected only by file permissions (owner-only, not encrypted). Anyone with access to this account or root can read it.")
}
}
}
}
normalized, err := server.NormalizeURL(serverURL)
if err != nil {
return fail(2, "invalid server address: %v", err)
}
if server.IsInsecureRemoteURL(normalized) {
fmt.Fprintln(os.Stderr, "Warning: plain HTTP exposes the server token on the network. Use HTTPS, a VPN, or a trusted private network.")
}
if g.insecure && g.caCertFile != "" {
return fail(2, "--insecure and --ca-cert cannot be used together")
}
if len(connectionCACertPEM) > 0 && (g.insecure || g.caCertFile != "") {
return fail(2, "a CA-bearing --connection-file cannot be combined with --insecure or --ca-cert")
}
var caCertPEM []byte
var tlsOptions []server.Option
switch {
case len(connectionCACertPEM) > 0:
caCertPEM = connectionCACertPEM
tlsOptions = append(tlsOptions, server.WithExclusiveCACertPEM(caCertPEM))
case g.insecure:
fmt.Fprintln(os.Stderr, "Warning: --insecure disables TLS certificate verification. Use only on a trusted private network.")
tlsOptions = append(tlsOptions, server.WithInsecureSkipVerify())
case g.caCertFile != "":
caCertPEM, err = readCACertFile(g.caCertFile)
if err != nil {
return err
}
tlsOptions = append(tlsOptions, server.WithCACertPEM(caCertPEM))
}
databaseID := identity.DatabaseID(token, password)
client, err := server.New(normalized, token, databaseID, version+" ("+runtime.GOOS+"/"+runtime.GOARCH+")", tlsOptions...)
if err != nil {
return fail(2, "invalid server configuration: %v", err)
}
healthCtx, healthCancel := context.WithTimeout(context.Background(), 30*time.Second)
_, healthErr := client.Health(healthCtx)
healthCancel()
if healthErr != nil && len(tlsOptions) == 0 && !*nonInteractive && isCertificateTrustError(healthErr) {
fetchCtx, fetchCancel := context.WithTimeout(context.Background(), 15*time.Second)
trustedPEM, promptErr := promptTrustCertificate(fetchCtx, normalized)
fetchCancel()
if promptErr != nil {
return promptErr
}
if len(trustedPEM) > 0 {
caCertPEM = trustedPEM
tlsOptions = append(tlsOptions, server.WithCACertPEM(caCertPEM))
client, err = server.New(normalized, token, databaseID, version+" ("+runtime.GOOS+"/"+runtime.GOARCH+")", tlsOptions...)
if err != nil {
return fail(2, "invalid server configuration: %v", err)
}
retryCtx, retryCancel := context.WithTimeout(context.Background(), 30*time.Second)
_, healthErr = client.Health(retryCtx)
retryCancel()
if healthErr == nil && !g.quiet {
fmt.Fprintln(os.Stderr, "Certificate trusted; it will be remembered for this server.")
}
}
}
if healthErr != nil {
return mapRuntimeError("server health check failed", healthErr)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
exists := true
validated := false
download, err := client.Get(ctx)
if errors.Is(err, server.ErrNotFound) {
exists = false
} else if err != nil {
return mapRuntimeError("database check failed", err)
} else {
if _, err = clipdb.Decode(download.Data, password, clipdb.DefaultLimits()); err != nil {
return mapRuntimeError("database could not be opened", err)
}
validated = true
}
cfg := config.Default()
cfg.Server = normalized
cfg.TLSInsecure = g.insecure
cfg.CACertPEM = string(caCertPEM)
if len(connectionCACertPEM) > 0 {
authority, authorityErr := server.ParsePrivateAuthority(connectionCACertPEM, normalized)
if authorityErr != nil {
return fail(2, "invalid connection-file authority: %v", authorityErr)
}
cfg.CAExclusive = true
cfg.CAHost = authority.Host
}
cfg.Machine = strings.TrimSpace(*machine)
if cfg.Machine == "" {
cfg.Machine = hostname()
}
protectedToken, err := config.ProtectForConfig(token)
if err != nil {
return fail(3, "cannot protect server token: %v", err)
}
cfg.TokenProtected = protectedToken
switch strings.ToLower(*savePassword) {
case "none":
cfg.PasswordMode = "prompt"
case "config":
cfg.PasswordMode = "config"
protected, protectErr := config.ProtectForConfig(password)
if protectErr != nil {
return fail(3, "cannot protect history password: %v", protectErr)
}
cfg.PasswordProtected = protected
default:
return fail(2, "--save-password must be none or config")
}
if err = config.Save(path, cfg); err != nil {
return fail(3, "cannot save configuration: %v", err)
}
if g.json {
return writeJSON(map[string]any{"server": normalized, "bucket_fingerprint": fingerprint(databaseID), "bucket_exists": exists, "password_validated": validated, "config_path": path})
}
fmt.Fprintf(os.Stderr, "Clipman CLI configured at %s.\n", path)
if !exists {
fmt.Fprintln(os.Stderr, "No database exists for this token/password combination. The password cannot be validated until this bucket contains data.")
}
return nil
}
// shouldAskWhereToSave reports whether init should ask where the settings
// belong. It stays quiet whenever the location has already been decided —
// by --config, CLIPMAN_CONFIG, or CLIPMAN_HOME — because asking after the
// user has named a location invites contradicting themselves.
func shouldAskWhereToSave(g globals, nonInteractive bool) bool {
if nonInteractive || g.configPath != "" {
return false
}
if os.Getenv("CLIPMAN_CONFIG") != "" || os.Getenv("CLIPMAN_HOME") != "" {
return false
}
return platform.IsInteractive()
}
func runStatus(ctx *appContext, args []string) error {
fs := newFlagSet("status")
addOutputFlags(fs, &ctx.globals)
refresh := fs.Bool("refresh", false, "download and validate the database")
if err := parseCommandFlags(fs, "status", args); err != nil {
return err
}
callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
health, err := ctx.client.Health(callCtx)
if err != nil {
return mapRuntimeError("server is unavailable", err)
}
exists := true
metadata, err := ctx.client.Head(callCtx)
if errors.Is(err, server.ErrNotFound) {
exists = false
err = nil
}
if err != nil {
return mapRuntimeError("database status failed", err)
}
// entries stays null in JSON unless --refresh actually counted them, so a
// consumer cannot mistake "not counted" for a real count.
var entries any
if *refresh && exists {
state, readErr := ctx.engine.Read(callCtx)
if readErr != nil {
return mapRuntimeError("database validation failed", readErr)
}
entries = len(state.Database.Entries)
}
result := map[string]any{"config_path": ctx.configPath, "server": ctx.client.BaseURL, "bucket_fingerprint": fingerprint(ctx.databaseID), "database_exists": exists, "revision": metadata.Revision, "length": metadata.Length, "entries": entries, "health": health, "interface": ctx.config.Renderer}
if ctx.globals.json {
return writeJSON(result)
}
// The configuration path leads, because with several profiles in play the
// first question is which one this command is using.
// The interface is reported here because it is the one question an
// interactive session cannot answer for you: it names which of the two
// browsers `menu` will open, from outside either of them.
if _, err = fmt.Printf("Configuration: %s\nServer: %s\nDatabase: %s\nInterface: %s\n", ctx.configPath, ctx.client.BaseURL, map[bool]string{true: "available", false: "not yet created"}[exists], ctx.config.Renderer); err != nil {
return fail(1, "cannot write output: %v", err)
}
if exists {
if _, err = fmt.Printf("Revision: %s\nSize: %d bytes\n", metadata.Revision, metadata.Length); err != nil {
return fail(1, "cannot write output: %v", err)
}
}
if entries != nil {
if _, err = fmt.Printf("Entries: %d\n", entries); err != nil {
return fail(1, "cannot write output: %v", err)
}
}
return nil
}
func runSync(ctx *appContext, args []string) error {
fs := newFlagSet("sync")
addOutputFlags(fs, &ctx.globals)
if err := parseCommandFlags(fs, "sync", args); err != nil {
return err
}
if len(fs.Args()) > 0 {
return fail(2, "sync takes no positional arguments")
}
retryPendingWrites(ctx)
callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
view, err := ctx.engine.ReadView(callCtx, ctx.config.Machine)
if err != nil {
return mapRuntimeError("sync failed", err)
}
cacheRulesFromView(ctx, view)
selfRegisterDevice(ctx, view)
exists := len(view.Channels) > 0 && view.Channels[0].Revision != ""
if ctx.globals.json {
return writeJSON(map[string]any{"revision": view.Channels[0].Revision, "database_exists": exists, "entries": len(view.View.Entries), "uploaded": false})
}
if !ctx.globals.quiet {
// An absent database is not an empty history: it usually means the
// history password or token differs from the one that holds the data,
// so saying "history is current" here would hide a typed password.
if !exists {
fmt.Fprintln(os.Stderr, "No history exists on the server for this token and history password yet.")
} else {
fmt.Fprintf(os.Stderr, "History is current: %d entries.\n", len(view.View.Entries))
}
}
return nil
}
func runList(ctx *appContext, args []string) error {
fs := newFlagSet("list")
addOutputFlags(fs, &ctx.globals)
count := fs.Int("n", 20, "maximum entries")
all := fs.Bool("all", false, "list all entries")
group := fs.String("group", "", "filter group")
search := fs.String("search", "", "search name and text")
kind := fs.String("kind", ctx.config.DefaultKind, "history, templates, or all")
pinned := fs.Bool("pinned-first", ctx.config.PinnedFirst, "show pinned entries first")
porcelain := fs.Bool("porcelain", false, "stable tab-separated output")
if err := parseCommandFlags(fs, "list", args); err != nil {
return err
}
retryPendingWrites(ctx)
view, err := readState(ctx)
if err != nil {
return err
}
parsedKind, err := operation.ParseKind(*kind)
if err != nil {
return fail(2, "%v", err)
}
entries := operation.View(*view.View, parsedKind, *pinned)
filtered := entries[:0]
for _, entry := range entries {
if *group != "" && !strings.EqualFold(entry.Group, *group) {
continue
}
if *search != "" && !strings.Contains(strings.ToLower(entry.Name+"\n"+entry.Text), strings.ToLower(*search)) {
continue
}
filtered = append(filtered, entry)
}
entries = filtered
if !*all && *count >= 0 && len(entries) > *count {
entries = entries[:*count]
}
if ctx.globals.json {
items := make([]map[string]any, 0, len(entries))
for index, entry := range entries {
items = append(items, output.EntryJSON(index, entry))
}
return writeJSON(items)
}
now := time.Now()
for index, entry := range entries {
row := output.ListRow(index, entry, now)
if *porcelain {
row = output.PorcelainRow(index, entry, now)
}
if _, err = fmt.Println(row); err != nil {
return fail(1, "cannot write output: %v", err)
}
}
return nil
}
func runGet(ctx *appContext, args []string) error {
selector, kind, pinned, touch, newline, raw, err := parseGet(args, ctx.config, &ctx.globals)
if err != nil {
return err
}
view, err := readState(ctx)
if err != nil {
return err
}
entries := operation.View(*view.View, kind, pinned)
entry, index, err := operation.Select(entries, selector)
if err != nil {
return selectionError(err)
}
if touch {
var touched model.Entry
result, mutErr := ctx.engine.MutateView(context.Background(), ctx.config.Machine, func(database *model.Database) error {
updated, e := operation.Touch(database, entry.ID, time.Now().UnixMilli())
if e != nil {
return e
}
touched = updated
return nil
})
if absorbed := absorbWriteThrough(ctx, mutErr); absorbed != nil {
return mapRuntimeError("touch failed", mutErr)
}
cacheRulesFromView(ctx, result)
entry = touched
}
if ctx.globals.json {
value := output.EntryJSON(index, entry)
if entry.IsTemplate && !raw {
value["ResolvedText"] = template.Resolve(entry.Text, time.Now())
}
return writeJSON(value)
}
text := entry.Text
if entry.IsTemplate && !raw {
text = template.Resolve(text, time.Now())
}
_, err = os.Stdout.Write([]byte(text))
if err != nil {
return fail(1, "cannot write clip text: %v", err)
}
if newline && !strings.HasSuffix(text, "\n") {
_, err = os.Stdout.Write([]byte("\n"))
}
return err
}
func runPut(ctx *appContext, args []string) error {
fs := newFlagSet("put")
addOutputFlags(fs, &ctx.globals)
file := fs.String("file", "", "read text from file")
textValue := fs.String("text", "", "text to store")
name := fs.String("name", "", "entry name")
group := fs.String("group", "", "entry group")
pin := fs.Bool("pin", false, "pin the entry")
template := fs.Bool("template", false, "create a template entry")
duplicate := fs.String("duplicate", "movetotop", "ignore, movetotop, or keep")
if err := parseCommandFlags(fs, "put", args); err != nil {
return err
}
switch strings.ToLower(strings.TrimSpace(*duplicate)) {
case "ignore", "movetotop", "keep":
default:
return fail(2, "--duplicate must be ignore, movetotop, or keep")
}
if *file != "" && *textValue != "" {
return fail(2, "--file and --text cannot be used together")
}
var data []byte
var err error
if *file != "" {
data, err = os.ReadFile(*file)
} else if *textValue != "" {
data = []byte(*textValue)
} else {
data, err = io.ReadAll(io.LimitReader(os.Stdin, ctx.config.Limits.MaxTextBytes+1))
}
if err != nil {
return fail(1, "cannot read input: %v", err)
}
if int64(len(data)) > ctx.config.Limits.MaxTextBytes {
return fail(1, "input exceeds %d-byte limit", ctx.config.Limits.MaxTextBytes)
}
if len(data) == 0 {
return fail(2, "clip text cannot be empty")
}
if !utf8.Valid(data) {
return fail(1, "clip text is not valid UTF-8")
}
text := string(data)
newID := merge.NewID()
var result map[string]any
view, err := ctx.engine.MutateView(context.Background(), ctx.config.Machine, func(database *model.Database) error {
entry, outcome := operation.Put(database, text, *name, *group, ctx.config.Machine, *duplicate, newID, *pin, *template, time.Now().UnixMilli())
result = map[string]any{"entry": entry, "outcome": outcome}
return nil
})
if absorbed := absorbWriteThrough(ctx, err); absorbed != nil {
return mapRuntimeError("put failed", err)
}
cacheRulesFromView(ctx, view)
if ctx.globals.json {
return writeJSON(result)
}
if !ctx.globals.quiet {
entry := result["entry"].(model.Entry)
fmt.Fprintf(os.Stderr, "%s %s\n", result["outcome"], entry.ID)
}
return nil
}
func runRemove(ctx *appContext, args []string) error {
fs := newFlagSet("rm")
addOutputFlags(fs, &ctx.globals)
id := fs.String("id", "", "exact entry ID")
name := fs.String("name", "", "exact entry name")
search := fs.String("search", "", "search entry text or name")
kindValue := fs.String("kind", ctx.config.DefaultKind, "history, templates, or all")
yes := fs.Bool("yes", false, "skip confirmation")
caseSensitive := fs.Bool("case-sensitive", false, "case-sensitive name/search")
if err := parseCommandFlags(fs, "rm", permuteArgs(fs, args)); err != nil {
return err
}
selector, err := buildSelector(fs.Args(), *id, *name, *search, false, *caseSensitive)
if err != nil {
return err
}
view, err := readState(ctx)
if err != nil {
return err
}
parsedKind, err := operation.ParseKind(*kindValue)
if err != nil {
return fail(2, "%v", err)
}
entries := operation.View(*view.View, parsedKind, ctx.config.PinnedFirst)
entry, index, err := operation.Select(entries, selector)
if err != nil {
return selectionError(err)
}
if !*yes {
answer, askErr := promptLine(fmt.Sprintf("Delete entry %d (%s)? Type yes to confirm: ", index, output.Preview(entry)))
if askErr != nil {
return askErr
}
if !strings.EqualFold(strings.TrimSpace(answer), "yes") {
return fail(2, "deletion cancelled")
}
}
var removed model.Entry
updated, err := ctx.engine.MutateView(context.Background(), ctx.config.Machine, func(database *model.Database) error {
result, e := operation.Delete(database, entry.ID, ctx.config.Machine, time.Now().UnixMilli())
if e != nil {
return e
}
removed = result
return nil
})
if absorbed := absorbWriteThrough(ctx, err); absorbed != nil {
return mapRuntimeError("delete failed", err)
}
cacheRulesFromView(ctx, updated)
if ctx.globals.json {
return writeJSON(map[string]any{"id": removed.ID, "index": index, "kind": *kindValue})
}
if !ctx.globals.quiet {
fmt.Fprintf(os.Stderr, "Deleted %s.\n", removed.ID)
}
return nil
}
// cliStore adapts the sync engine to the browsers' Store interface. Load is
// the only network read a browser performs unless the user asks to refresh, so
// a session costs one download rather than one per command.
//
// It is a pointer receiver for SetKind alone: the full-screen renderer can
// switch between history and templates, and the store has to follow.
type cliStore struct {
ctx *appContext
kind operation.Kind
pinnedFirst bool
// last is the database the most recent download produced, kept so switching
// interfaces can hand it to the next one.
last *model.Database
// reuse, when set, is that database being handed over. Switching tears the
// screen down before the new interface draws anything, and a download plus a
// PBKDF2 decrypt in that gap is silence with nothing to explain it. It is
// consumed once, so an explicit reload still asks the server.
reuse *model.Database
}
func (s *cliStore) SetKind(kind operation.Kind) { s.kind = kind }
func (s *cliStore) Load(context.Context) ([]model.Entry, error) {
if s.reuse != nil {
database := *s.reuse
s.reuse = nil
// Re-derived rather than reused wholesale: the view is rebuilt for the
// kind now in force, which Tab may have changed on the way out.
return operation.View(database, s.kind, s.pinnedFirst), nil
}
view, err := readState(s.ctx)
if err != nil {
return nil, err
}
database := *view.View
s.last = &database
return operation.View(database, s.kind, s.pinnedFirst), nil
}
// handOver arms the next Load with what has already been downloaded.
func (s *cliStore) handOver() { s.reuse = s.last }
func (s *cliStore) Delete(callCtx context.Context, id string) error {
view, err := s.ctx.engine.MutateView(callCtx, s.ctx.config.Machine, func(database *model.Database) error {
_, deleteErr := operation.Delete(database, id, s.ctx.config.Machine, time.Now().UnixMilli())
return deleteErr
})
if absorbed := absorbWriteThrough(s.ctx, err); absorbed != nil {
return mapRuntimeError("delete failed", err)
}
cacheRulesFromView(s.ctx, view)
return nil
}
func (s *cliStore) Create(callCtx context.Context, text, name string) (model.Entry, error) {
// A clip typed into the browser belongs to the kind being browsed, so
// adding one while viewing templates creates a template.
isTemplate := s.kind == operation.Templates
newID := merge.NewID()
var created model.Entry
view, err := s.ctx.engine.MutateView(callCtx, s.ctx.config.Machine, func(database *model.Database) error {
entry, _ := operation.Put(database, text, name, "", s.ctx.config.Machine, "movetotop", newID, false, isTemplate, time.Now().UnixMilli())
created = entry
return nil
})
if absorbed := absorbWriteThrough(s.ctx, err); absorbed != nil {
return model.Entry{}, mapRuntimeError("add failed", err)
}
cacheRulesFromView(s.ctx, view)
return created, nil
}
// terminalConsole routes the browser's prompts and announcements to the
// controlling terminal, keeping them out of the payload stream.
type terminalConsole struct{}
func (terminalConsole) Say(text string) {
console, err := platform.OpenConsoleOutput()
if err != nil {
fmt.Fprintln(os.Stderr, text)
return
}
defer console.Close()
fmt.Fprintln(console, text)
}
func (terminalConsole) ReadLine(prompt string) (string, error) { return promptLine(prompt) }
func runPick(ctx *appContext, args []string) error {
options, err := parseInteractiveOptions(ctx, args, "pick")
if err != nil {
return err
}
if options.renderer == rendererTUI {
return interactiveError(runTUI(options, true))
}
return interactiveError(options.lineBrowser().Pick(context.Background()))
}
func runMenu(ctx *appContext, args []string) error {
if ctx.globals.json {
return fail(2, "menu does not support JSON output")
}
options, err := parseInteractiveOptions(ctx, args, "menu")
if err != nil {
return err
}
return interactiveError(browseUntilDone(ctx, options))
}
// browseUntilDone runs the configured interface, and starts the other one when
// the user asks to switch.
//
// The loop turns only on a deliberate keystroke that has already been confirmed,
// so it cannot spin: nothing in here retries on its own, and a terminal that
// cannot run what was asked for stops the switch rather than repeating it.
func browseUntilDone(ctx *appContext, options interactiveOptions) error {
for {
var err error
if options.renderer == rendererTUI {
err = runTUI(options, false)
} else {
err = options.lineBrowser().Run(context.Background())
}
// A switch is not a failure; it is the only channel a browser has back
// to its caller. Everything else, including success, ends the session.
var request *handoff.Request
if !errors.As(err, &request) {
return err
}
options = switchTo(ctx, options, request)
}
}
// switchTo prepares the other interface. Three things happen here, and each of
// them is what keeps a switch from costing the user something:
//
// - the terminal is proven able to run what is being asked for before the
// choice is committed, so an unusable one costs no session and no setting;
// - the history already downloaded is handed to the next interface, because
// the screen is torn down before the new one draws and a download in that
// gap is silence with nothing to explain it;
// - the preference is written to the configuration file, so the next run
// starts where this one ended.
func switchTo(ctx *appContext, options interactiveOptions, request *handoff.Request) interactiveOptions {
options.arrival = request
options.kind = request.Kind
options.store.SetKind(request.Kind)
options.store.handOver()
options.screen = nil
if options.renderer == rendererTUI {
options.renderer = rendererLine
saveRenderer(ctx, "line")
return options
}
// Created before the choice is saved. A terminal that cannot take a
// full-screen interface must lose the user nothing: they stay where they
// are, the configuration is untouched, and they are told why.
screen, err := tcell.NewScreen()
if err != nil {
fmt.Fprintf(os.Stderr, "The full-screen interface is unavailable on this terminal (%v).\nStaying in the line interface.\n", err)
return options
}
options.renderer = rendererTUI
options.screen = screen
saveRenderer(ctx, "tui")
return options
}
// saveRenderer records the choice so the next run starts in the same interface.
//
// A failure to write is announced and then ignored. The user asked to move, not
// to save; refusing the move because a preference file could not be written
// would cost them the session over something they did not ask for.
func saveRenderer(ctx *appContext, name string) {
updated := ctx.config
updated.Renderer = name
if err := config.Save(ctx.configPath, updated); err != nil {
fmt.Fprintf(os.Stderr, "Switched, but the choice could not be saved: %v\nUse --renderer %s to make it permanent.\n", err, name)
return
}
ctx.config = updated
}
// interactiveError maps a renderer's sentinel outcomes onto exit codes. Both
// renderers report the same conditions, so the mapping lives in one place.
func interactiveError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, line.ErrCancelled), errors.Is(err, tui.ErrCancelled):
return fail(2, "selection cancelled")
case errors.Is(err, line.ErrNoEntries), errors.Is(err, tui.ErrNoEntries):
return fail(6, "no matching entries")
}
return err
}
type rendererChoice int
const (
rendererLine rendererChoice = iota
rendererTUI
)
type interactiveOptions struct {
ctx *appContext
store *cliStore
kind operation.Kind
renderer rendererChoice
pageSize int
pinnedFirst bool
debugPath string
// arrival carries the user's place across a switch between interfaces.
arrival *handoff.Request
// screen, when set, is a terminal screen already proven to work. A switch
// creates it up front so an unusable terminal is discovered before the
// choice is committed, rather than after the session has been lost.
screen tcell.Screen
}
func (o interactiveOptions) lineBrowser() *line.Browser {
return &line.Browser{
Store: o.store,
Console: terminalConsole{},
Stdout: os.Stdout,
PageSize: o.pageSize,
PinnedFirst: o.pinnedFirst,
Now: time.Now,
Kind: o.kind,
Arrival: o.arrival,
}
}
// runTUI starts the full-screen renderer. A terminal that cannot run it is
// reported plainly, with the line renderer offered by name: silently falling
// back would change where output goes without the user asking.
func runTUI(options interactiveOptions, pickOnly bool) error {
screen := options.screen
if screen == nil {
created, err := tcell.NewScreen()
if err != nil {
return fail(1, "the full-screen interface is unavailable on this terminal (%v); rerun with --renderer line", err)
}
screen = created
}
// Where the trace is going is announced before the screen is taken over,
// so it is on the terminal before the interface starts and still in the
// scrollback afterwards. Anything printed on the way out is discarded when
// the screen is restored.
if options.debugPath != "" {
fmt.Fprintf(os.Stderr, "Writing a caret trace to %s\n", options.debugPath)
}
browser := &tui.Browser{
Store: options.store, Screen: screen, Stdout: os.Stdout,
PinnedFirst: options.pinnedFirst, Kind: options.kind,
Now: time.Now, PickOnly: pickOnly, DebugPath: options.debugPath,
Arrival: options.arrival,
}
err := browser.Run(context.Background())
// A switch request travels back as an error and must reach the caller
// untouched; wrapping it here would report a deliberate move as a failure.
var switching *handoff.Request
if errors.As(err, &switching) {
return err
}
if err != nil && !errors.Is(err, tui.ErrCancelled) && !errors.Is(err, tui.ErrNoEntries) {
var appErr appError
if !errors.As(err, &appErr) {
return fail(1, "%v; rerun with --renderer line if this terminal cannot run it", err)
}
}
return err
}
// parseInteractiveOptions parses the options menu and pick share. For the line
// renderer -n is the page size and every entry stays reachable by paging; for
// pick, which offers one choice and exits, it bounds the whole list.
func parseInteractiveOptions(ctx *appContext, args []string, command string) (interactiveOptions, error) {
fs := newFlagSet(command)
count := fs.Int("n", 20, "entries announced at once")
all := fs.Bool("all", false, "announce every entry without paging")
kindValue := fs.String("kind", ctx.config.DefaultKind, "history, templates, or all")
pinned := fs.Bool("pinned-first", ctx.config.PinnedFirst, "show pinned entries first")
rendererValue := fs.String("renderer", ctx.config.Renderer, "line or tui")
useTUI := fs.Bool("tui", false, "shorthand for --renderer tui")
useLine := fs.Bool("line", false, "shorthand for --renderer line")
debug := fs.Bool("debug", false, "write a caret trace beside this program")
debugLog := fs.String("debug-log", "", "write the caret trace to this file")
if err := parseCommandFlags(fs, command, args); err != nil {
return interactiveOptions{}, err
}
if len(fs.Args()) != 0 {
return interactiveOptions{}, fail(2, "%s takes no positional arguments", command)
}
if *useTUI && *useLine {
return interactiveOptions{}, fail(2, "--tui and --line cannot be used together")
}
kind, err := operation.ParseKind(*kindValue)
if err != nil {
return interactiveOptions{}, fail(2, "%v", err)
}
renderer := rendererLine
switch {
case *useTUI:
renderer = rendererTUI
case *useLine:
renderer = rendererLine
default:
switch strings.ToLower(strings.TrimSpace(*rendererValue)) {
case "", "line":
renderer = rendererLine
case "tui":
renderer = rendererTUI
default:
return interactiveOptions{}, fail(2, "--renderer must be line or tui")
}
}
if !platform.IsInteractive() {
return interactiveOptions{}, fail(2, "%s needs a controlling terminal", command)
}
pageSize := *count
if *all || pageSize == 0 {
pageSize = -1
}
return interactiveOptions{
ctx: ctx,
store: &cliStore{ctx: ctx, kind: kind, pinnedFirst: *pinned},
kind: kind,
renderer: renderer,
pageSize: pageSize,
pinnedFirst: *pinned,
debugPath: debugTracePath(*debug, *debugLog),
}, nil
}
// debugTracePath resolves where a caret trace should go. --debug-log names the
// file, --debug takes the default beside the program, and neither can be
// defeated by the difference between cmd and PowerShell quoting the way an
// environment variable can.
func debugTracePath(enabled bool, explicit string) string {
if value := strings.TrimSpace(explicit); value != "" {
if absolute, err := filepath.Abs(value); err == nil {
return absolute
}
return value
}
if !enabled {
return ""
}
return tui.DefaultDebugPath()
}
func parseGet(args []string, cfg config.Config, g *globals) (operation.Selector, operation.Kind, bool, bool, bool, bool, error) {
fs := newFlagSet("get")
addOutputFlags(fs, g)
id := fs.String("id", "", "exact entry ID")
name := fs.String("name", "", "exact entry name")
search := fs.String("search", "", "search entry text or name")
kind := fs.String("kind", cfg.DefaultKind, "history, templates, or all")
first := fs.Bool("first", false, "use first ambiguous match")
caseSensitive := fs.Bool("case-sensitive", false, "case-sensitive name/search")
pinned := fs.Bool("pinned-first", cfg.PinnedFirst, "show pinned entries first")
touch := fs.Bool("touch", false, "mark the entry used")
newline := fs.Bool("newline", false, "ensure a final LF")
raw := fs.Bool("raw", false, "do not resolve template variables")
fs.BoolVar(newline, "n", false, "ensure a final LF")
if err := parseCommandFlags(fs, "get", permuteArgs(fs, args)); err != nil {
return operation.Selector{}, operation.History, false, false, false, false, err
}
parsedKind, err := operation.ParseKind(*kind)
if err != nil {
return operation.Selector{}, operation.History, false, false, false, false, fail(2, "%v", err)
}
selector, err := buildSelector(fs.Args(), *id, *name, *search, *first, *caseSensitive)
return selector, parsedKind, *pinned, *touch, *newline, *raw, err
}
func buildSelector(args []string, id, name, search string, first, caseSensitive bool) (operation.Selector, error) {
used := 0
if id != "" {
used++
}
if name != "" {
used++
}
if search != "" {
used++
}
var index *int
if len(args) > 1 {
return operation.Selector{}, fail(2, "only one index may be supplied")
}
if len(args) == 1 {
value, err := strconv.Atoi(args[0])
if err != nil || value < 0 {
return operation.Selector{}, fail(2, "index must be a non-negative number")
}
index = &value
used++
}
if used > 1 {
return operation.Selector{}, fail(2, "index, --id, --name, and --search are mutually exclusive")
}
return operation.Selector{Index: index, ID: id, Name: name, Search: search, First: first, CaseSensitive: caseSensitive}, nil
}
func readState(ctx *appContext) (*syncengine.ViewState, error) {
callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
view, err := ctx.engine.ReadView(callCtx, ctx.config.Machine)
if err != nil {
return nil, mapRuntimeError("history could not be loaded", err)
}
cacheRulesFromView(ctx, view)
if len(view.Channels) > 0 && view.Channels[0].Revision != "" {
verbosef(ctx.globals, "downloaded revision %s, %d entries", view.Channels[0].Revision, len(view.View.Entries))
} else {
verbosef(ctx.globals, "no database exists for this token and history password")
}
return view, nil
}
func selectionError(err error) error {
if strings.Contains(err.Error(), "ambiguous") {
return fail(2, "%v; use an exact ID or index", err)
}
return fail(6, "%v", err)
}
func mapRuntimeError(prefix string, err error) error {
switch {
case errors.Is(err, server.ErrUnauthorized):
return fail(3, "%s: %v", prefix, err)
case errors.Is(err, server.ErrNotFound):
return fail(6, "%s: %v", prefix, err)
case errors.Is(err, clipdb.ErrPasswordRequired), errors.Is(err, clipdb.ErrPasswordOrData):
return fail(5, "%s: %v", prefix, err)
}
if isNetworkFailure(err) {
return fail(4, "%s: %v", prefix, err)
}
// Anything left is local: an encode, decode, merge, or normalization failure
// that happened to surface through a call which also touches the network.
// Reporting those as 4 told a script to retry something that retrying cannot
// fix, and told a user the network was at fault when it was not.
return fail(1, "%s: %v", prefix, err)
}
// isNetworkFailure reports whether err is the network's or the server's fault,
// which is what exit code 4 means. Everything it does not claim is local, and
// falls to 1.
func isNetworkFailure(err error) bool {
// net.Error covers timeouts, refused connections, DNS failures, and the
// *url.Error the HTTP client wraps its transport errors in — including TLS
// handshake failures, which reach us through that wrapper.
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
// A TLS trust failure that arrives unwrapped is still a connection that
// could not be established.
if isCertificateTrustError(err) {
return true
}
var statusErr *server.StatusError
if errors.As(err, &statusErr) {
return statusErr.Remote()
}
return false
}
func promptPassword(label string) (string, error) {
console, err := platform.OpenConsole()
if err != nil {
return "", fail(2, "interactive terminal is unavailable")
}
defer console.Close()
output, outputErr := platform.OpenConsoleOutput()
if outputErr != nil {
return "", fail(2, "interactive terminal output is unavailable")
}
defer output.Close()
fmt.Fprint(output, label)
value, err := term.ReadPassword(int(console.Fd()))
fmt.Fprintln(output)
if err != nil {
return "", fail(2, "password input failed: %v", err)
}
return string(value), nil
}
func promptLine(label string) (string, error) {
console, err := platform.OpenConsole()
if err != nil {
return "", fail(2, "interactive terminal is unavailable")
}
defer console.Close()
output, outputErr := platform.OpenConsoleOutput()
if outputErr != nil {
return "", fail(2, "interactive terminal output is unavailable")
}
defer output.Close()
fmt.Fprint(output, label)
line, err := bufio.NewReader(console).ReadString('\n')
if err != nil && err != io.EOF {
return "", fail(2, "input failed: %v", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
func promptYesNo(label string, defaultYes bool) (bool, error) {
suffix := " [y/N]: "
if defaultYes {
suffix = " [Y/n]: "
}
for {
line, err := promptLine(label + suffix)
if err != nil {
return false, err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "":
return defaultYes, nil
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
fmt.Fprintln(os.Stderr, "Please answer y or n.")
}
}
}
func hostname() string {
value, err := os.Hostname()
if err != nil || strings.TrimSpace(value) == "" {
return runtime.GOOS
}
return value
}
func fingerprint(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
func writeJSON(value any) error {
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
return encoder.Encode(value)
}
func printUsage(out io.Writer) {
fmt.Fprintln(out, "Clipman CLI - terminal access to Clipman text history\n\nUsage: clipman-cli [global options] <command> [options]\n\nCommands:\n init Configure a Clipman Server profile\n status Check server and history status\n list List text-history entries\n get Write one entry to standard output\n put Read UTF-8 text and add it to history\n rm Delete exactly one entry\n pick Select one entry and write it to standard output\n menu Browse history interactively; the default when no command is given\n sync Download and validate current history\n agent Opt-in bounded, read-only history search for integrations\n rules Manage sync channels and device subscriptions\n\nGlobal options:\n --config PATH Select a configuration file\n --server URL Override the configured server\n --password VALUE Supply the history password\n --ca-cert FILE Trust an additional PEM CA/certificate for a self-signed server\n --insecure Disable TLS certificate verification (use only on a trusted network)\n --json Emit structured JSON where supported\n --quiet, -q Suppress nonessential messages\n --verbose Write diagnostic messages to standard error\n --version Show version information")
}
func printCommandUsage(out io.Writer, command string) bool {
usage := map[string]string{
"agent": "Usage: clipman-cli [--config PATH] agent <permissions|search|get> ...\n agent permissions [--read allow|deny] [--yes]\n agent search --query TEXT [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--group NAME] [--device NAME] [--limit COUNT]\n agent get --id ID\n Agent reads are disabled by default, never prompt for credentials, and always return bounded JSON.",
"agent permissions": "Usage: clipman-cli [--config PATH] agent permissions [--read allow|deny] [--yes]\n Enabling reads requires --yes to acknowledge disclosure of ordinary history to the requesting agent.",
"agent search": "Usage: clipman-cli [--config PATH] agent search --query TEXT [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--group NAME] [--device NAME] [--limit COUNT]\n Defaults to today in the computer's timezone. Maximum 31 calendar days and 50 results.",
"agent get": "Usage: clipman-cli [--config PATH] agent get --id ID\n Returns one plain-text entry, without resolving templates or modifying the clipboard. Maximum JSON response: 64 KiB.",
"init": "Usage: clipman-cli [global options] init [--connection-file FILE | --token-file FILE | --token VALUE] [--save-password none|config] [--machine NAME] [--non-interactive] [--force] [--portable]\n (use the global --ca-cert FILE or --insecure option before init to trust a self-signed server certificate)\n (without --save-password, an interactive run asks whether to save the history password)\n (--portable writes config.toml beside this executable; it is then used automatically by that copy)",
"status": "Usage: clipman-cli [global options] status [--refresh] [--json]",
"list": "Usage: clipman-cli [global options] list [-n COUNT | --all] [--group NAME] [--search TEXT] [--kind history|templates|all] [--pinned-first] [--porcelain] [--json]",
"get": "Usage: clipman-cli [global options] get [INDEX | --id ID | --name NAME | --search TEXT] [--kind history|templates|all] [--first] [--touch] [--newline] [--raw] [--json]",
"put": "Usage: clipman-cli [global options] put [--file FILE | --text TEXT] [--name NAME] [--group NAME] [--pin] [--template] [--duplicate ignore|movetotop|keep] [--json]",
"rm": "Usage: clipman-cli [global options] rm [INDEX | --id ID | --name NAME | --search TEXT] [--kind history|templates|all] [--case-sensitive] [--yes] [--json]",
"sync": "Usage: clipman-cli [global options] sync [--json] [--quiet]",
"pick": "Usage: clipman-cli [global options] pick [-n COUNT | --all] [--kind history|templates|all] [--pinned-first] [--renderer line|tui | --tui | --line]\n (-n sets how many entries are announced at once; n and p move between pages)",
"menu": "Usage: clipman-cli [global options] menu [-n COUNT | --all] [--kind history|templates|all] [--pinned-first] [--renderer line|tui | --tui | --line]\n (-n sets the page size; every entry stays reachable by paging, and --all announces them at once)\n (line renderer: NUMBER reads an entry a page at a time, o NUMBER outputs, w NUMBER saves to a file, x NUMBER runs a program, d NUMBER deletes, /TEXT searches, n and p page, a adds, r reloads, u switches interface, ? helps, q quits)\n (full-screen renderer: arrows move, g goes to a number, Enter outputs, v reads the whole clip, w saves to a file, x runs a program, / filters, Tab switches kind, d deletes, r reloads, u switches interface, ? shows keys, q quits)\n (--debug writes a caret trace beside this program; --debug-log FILE chooses where)",
"help": "Usage: clipman-cli help",
"rules": "Usage: clipman-cli [global options] rules <show|enable|disable|channel|device> ...\n rules show Print the sync rules document\n rules enable Turn sync channels on\n rules disable Turn sync channels off\n rules channel add <name> [--group G]... [--source-device D]... [--kind richtextimages]\n rules channel remove <name>\n rules device set <device> --channels <k1,k2|*>",
"rules show": "Usage: clipman-cli [global options] rules show [--json]",
"rules enable": "Usage: clipman-cli [global options] rules enable [--json]",
"rules disable": "Usage: clipman-cli [global options] rules disable [--json]",
"rules channel add": "Usage: clipman-cli [global options] rules channel add <name> [--group GROUP]... [--source-device DEVICE]... [--kind richtextimages] [--json]",
"rules channel remove": "Usage: clipman-cli [global options] rules channel remove <name> [--json]",
"rules device set": "Usage: clipman-cli [global options] rules device set <device> --channels <k1,k2|*> [--json]",
}
text, ok := usage[command]
if !ok {
return false
}
fmt.Fprintln(out, text)
return true
}