# Clipman CLI (clipman-cli) — Design Specification Status: partly implemented; reconciled against shipped source Target: Clipman v2 command-line client for Linux, Unix-like systems, and Windows Language: Go 1.25 Original revision date: 2026-07-21 Reconciliation date: 2026-08-06, against ClipmanCli/ at version 0.9.0 This document incorporates a source audit of the Windows, macOS, Android, iOS, and Python server implementations. Where the clients disagree, the shared Windows/macOS desktop behavior is the compatibility baseline unless this document identifies a deliberate exception. The 2026-08-06 pass reconciles the design against the code that actually shipped. Sections describing shipped behavior are now descriptive, not aspirational. Sections describing work that has not been done are marked **Deferred** and are the forward plan. Read section 0 first. --- ## 0. Implementation status ### 0.1 Shipped | Area | State | |---|---| | Model, .clipdb codec, identity, merge, normalization | Complete, unit-tested | | Config file, protected paths, Windows DPAPI secret protection | Complete | | HTTP client, URL/token normalization, status mapping, redirect guard | Complete | | Conditional create with If-None-Match: * | Complete | | Commands init status list get put rm sync pick menu help | Complete | | Kind filters, selectors, ordering, exit codes, porcelain/JSON output | Complete | | Line-based menu and pick on the controlling terminal | Complete | | rules command group and channel-aware sync per sync-rules-spec.md | Complete (section 7.13) | | Template variable resolution, get --raw | Complete (added after the original draft) | | TLS trust for private authorities: --ca-cert, --insecure, connection-file CA | Complete (added after the original draft) | | Full-screen renderer, --renderer/--tui/--line, renderer config key | Complete (section 8.2) | | Interface self-naming, u switching with carried place, status Interface: | Complete (section 8.3) | | Reading, saving, and running a clip (v, w, x) in both interfaces | Complete (section 8.4) | | Editable prompts, display-width drawing and caret placement | Complete (section 8.2) | | Windows interoperability fixture corpus and generator | Complete; macOS, Android, iOS generators absent (section 0.3 item 5) | | Go-generated sync-rules fixture set with Go, Windows, Mac, and Android consumers | Complete (section 14.1) | | Manual.html, clipman-cli.1, built-in help | Complete and in sync with the code | | Cross-build to 7 targets in per-platform directories | Complete | ### 0.2 Deliberate departures from the original draft - **Passwordless buckets are not supported.** Clipman Server requires a nonblank history password, so init rejects an empty password, password_mode = "passwordless" is a config error, and identity.DatabaseID returns an empty string for a blank password. Every --allow-passwordless reference in the original draft is obsolete. - **No OS secret store.** --save-password accepts none or config. On Windows the stored token and password are DPAPI CurrentUser blobs; on Unix they are plaintext inside an owner-only 0600 file. Secret Service and Credential Manager were dropped to keep the build dependency-free and cgo-free. - **Config is a restricted TOML subset parsed by hand** (internal/config/config.go), not a TOML library. Strings must be quoted; only the [limits] table exists. No third-party TOML dependency was added. - **Connection-file import.** init --connection-file reads a Clipman Server .clpconf/.txt and extracts address, token, and an optional exclusive private certificate authority. - **Templates resolve variables on output.** get, pick, and menu resolve template variables; get --raw returns stored text unchanged. - **Entry carries ModifiedUnixMs** in addition to the fields listed in section 5.5, and rich-text HTML/RTF fields survive through Extra unknown-field preservation. ### 0.3 Deferred — the remaining work Nothing below exists in the tree. No stub, no flag, no documentation claim. 1. **Daemon and offline writes** (sections 10.5–10.7). No internal/daemon, no cache, no pending queue, no IPC. Every command is stateless: read, mutate, upload, exit. 2. ~~**TUI renderer**~~ — **shipped.** See section 8.2. The line renderer remains the default; the full-screen one is opt-in. 3. **sync uploads.** sync currently downloads, decodes, and validates. It always reports "uploaded": false because with no pending queue there is nothing to replay. Its full contract in section 7.12 arrives with the daemon. 4. **status local fields.** Daemon reachability, cache freshness, last sync, pending count, and oldest pending age depend on the daemon. 5. **Interoperability fixture corpus** (section 14.1) — **started.** The Windows corpus exists and passes in both directions; macOS, Android, and iOS generators remain future work. See ClipmanCli/testdata/fixtures/README.md. 6. **CI.** Build targets and checksum determinism are not exercised by a workflow. ### 0.4 Working baseline ~~~sh cd ClipmanCli && go test ./... # all packages pass as of 2026-08-06 ~~~ Version lives in ClipmanCli/VERSION and the version variable in cmd/clipman-cli/main.go; both read 0.9.0 and must be bumped together. Release tags use cli-v<x.y.z>. --- ## 1. Purpose Clipman has native GUI clients and a history server, but no command-line peer. clipman-cli exposes shared text history through ordinary streams: ~~~sh some_command | clipman-cli put clipman-cli get | some_command clipman-cli get 3 > snippet.txt ~~~ This supports headless servers, SSH sessions, WSL, Windows terminals, and cross-machine text transfer without a local clipboard, daemon, kernel module, or privileged device. The server remains an opaque blob store. The CLI interoperates by: 1. deriving the same password-scoped database ID; 2. reading and writing the same .clipdb wire format; 3. applying desktop-compatible merge and tombstone rules; and 4. using the existing revision-based HTTP API. ### 1.1 Non-goals for v1 - Monitoring or bridging an X11, Wayland, macOS, or Windows local clipboard. - File-list history and the separate secrets database. - General server administration or backup restoration. - Treating binary data as native clip content. V1 clips are valid UTF-8 text. - Replacing the GUI clients. Templates are in scope as a filtered view of the text-history database, and template variables are resolved on output unless --raw is used. --- ## 2. Compatibility authority and decision log ### 2.1 Source authority Interoperability behavior is checked against: - Windows: src/ClipDatabaseFile.cs, src/ServerDatabaseIdentity.cs, src/ServerStorageClient.cs, and src/ClipStore.cs. - macOS: the corresponding files under ClipmanMac/Sources/ClipmanCore and ClipmanMac/Sources/Clipman. - Android and iOS codec, identity, storage-client, and conflict-resolver code. - ClipmanServerLinux/clipman_server.py for the HTTP contract. - sync-rules-spec.md for sync channels: derivations, the rules document schema, routing, relocation, and the multi-channel algorithm. Golden fixtures generated by those implementations decide format questions; prose does not override a failing interoperability fixture. **ClipmanCli/internal/ is a fork of ClipmanLinuxBackend/internal/.** The two trees are identical apart from module paths, except that the CLI adds the TLS trust options of section 3.4 (server.WithCACertPEM, server.WithInsecureSkipVerify, server.WithExclusiveCACertPEM, config keys ca_cert_pem/ca_host/ca_exclusive/tls_insecure) and targets Go 1.25 rather than 1.20. Any change to shared logic — codec, identity, merge, model, operation, sync rules (internal/rules), sync engine (including the multi-channel engine in internal/syncengine/channels.go) — must be applied to both trees in the same change, or Linux and CLI clients will disagree about the same database. ### 2.2 Resolved product decisions - Reproduce shared Windows/macOS behavior wherever practical. - **Superseded:** the original draft supported passwordless CLIPDB1 buckets. Server mode requires a nonblank history password, so the CLI writes and reads CLIPDB2 only. It still *decodes* a CLIPDB1 or bare-gzip blob it is handed, because the codec is shared, but no CLI path produces one. - Ship a supported Windows CLI, not merely an untested cross-build. - **Superseded:** the daemon was planned as a v1 runtime accelerator. It is deferred; see section 0.3. Every shipped command is stateless. - Expose templates through an explicit kind filter; normal history is the default. - An ambiguous delete search must result in one user-selected entry. It never implies bulk deletion, even with --yes. - Support connecting to a server with a privately issued TLS certificate, because Clipman Server can now issue its own; see section 3.4. ### 2.3 Resolved: safe initial bucket creation **Resolved in favor of the conditional branch.** The server supports If-None-Match: * on database PUT and answers 412 when the bucket already exists. server.Client.Put takes a createOnly argument, and syncengine.Engine.Mutate passes !state.Exists, so a first write is always conditional and a losing racer receives ErrConflict, re-reads, reapplies the same mutation, and retries. The CLI therefore *may* claim no-lost-update safety for simultaneous initial creation. The compatibility branch described in the original draft — an unconditional PUT followed by read-back verification — was not implemented and must not be reintroduced. --- ## 3. Server protocol ### 3.1 Required endpoints | Method | Path | Authentication | Meaning | |---|---|---|---| | GET | /api/v1/health | none | Server status | | HEAD | /api/v1/database/{id} | bearer | Revision and length | | GET | /api/v1/database/{id} | bearer | Raw database blob | | PUT | /api/v1/database/{id} | bearer | Replace raw database blob | Every authenticated request sends: ~~~text Authorization: Bearer User-Agent: clipman-cli/ (/) ~~~ Successful HEAD and GET responses contain X-Clipman-Revision, ETag, and Content-Length. Successful PUT responses contain X-Clipman-Revision. The client accepts a quoted or unquoted revision and prefers X-Clipman-Revision, falling back to ETag. An upload against an existing revision sends: ~~~text If-Match: "" Content-Type: application/octet-stream Content-Length: ~~~ HTTP 409 and 412 both mean revision conflict. HTTP 404 on GET or HEAD means the bucket does not exist. A valid database PUT route creates a missing bucket; PUT itself is not expected to return 404 for a missing database. ### 3.2 Server URL normalization Accepted inputs: - clipman://host:port, mapped to http://host:port; - http://... and https://...; - bare host:port, mapped to clipman://host:port; and - a pasted Clipman connection-details or settings object from which a URL and token can be extracted. Trailing slashes are normalized. A reverse-proxy path prefix is preserved. Query strings, fragments, and embedded credentials are rejected or stripped. By default HTTPS uses the operating-system trust store and normal hostname validation. Redirects to a different scheme or host are refused rather than followed, so bearer credentials never reach another origin; the redirect chain is capped at five hops. Plain HTTP exposes the bearer token to the network. Initialization warns when HTTP is used outside loopback, private, link-local, and CGNAT ranges, and recommends HTTPS, a VPN, or a trusted private network. ### 3.4 Private certificate authorities Clipman Server can issue its own TLS certificate, so the CLI supports trusting a private authority without disabling verification. Three mutually exclusive mechanisms exist, all resolved before the first request: | Mechanism | Effect | |---|---| | --ca-cert FILE | Adds the PEM certificates to the system pool | | --insecure | Disables verification entirely; prints a warning every run | | Connection file carrying ca_cert_pem | Trusts **only** that authority, and only for the connection file's HTTPS host | The connection-file path is the strict one: ParsePrivateAuthority requires exactly one PEM CERTIFICATE block under 32 KiB, refuses anything containing private key material, requires the CA basic constraint and certificate-signing key usage, rejects a not-yet-valid or expired authority, and requires an HTTPS server address. It records the pinned host as ca_host and refuses to load if the configured server host later disagrees. When init hits an untrusted-certificate error with no trust option supplied and a terminal available, it fetches the presented chain for display only, shows subject, issuer, validity window, and the **authority** SHA-256 fingerprint, tells the user to compare that fingerprint out of band, and trusts it only on explicit confirmation. The fetched certificate is never used to make the failed connection succeed silently. Persisted equivalents are the config keys ca_cert_pem, ca_host, ca_exclusive, and tls_insecure. Explicit command-line flags win over the stored profile. ### 3.3 Timeouts and limits Defaults, configurable within documented safe ranges: - connection timeout: 8 seconds; - response-header timeout: 8 seconds; - total request timeout: 30 seconds; - maximum downloaded blob: 64 MiB; - maximum uploaded blob: 64 MiB, also respecting HTTP 413; and - bounded retry count: 3 conflict retries after the initial attempt. The client streams HTTP bodies through a size-limited reader. It never trusts Content-Length alone. --- ## 4. Database identity First canonicalize the token: 1. trim surrounding whitespace; 2. if the input is recognized connection JSON, extract AuthToken or the equivalent canonical token field; 3. otherwise remove surrounding single or double quotes and trailing comma or semicolon; and 4. save only the resulting canonical token. Derive the bucket ID as follows: ~~~text key = SHA256(UTF8(canonical_token)) msg = UTF8("Clipman.ServerDatabaseId.v1" + "\n" + password) id = Base64URLWithoutPadding(HMAC-SHA256(key, msg)) ~~~ The result is 43 ASCII characters. A missing token is a configuration error. A blank password has no bucket. identity.DatabaseID returns an empty string when either the trimmed token or the password is empty, and the command layer refuses a blank password before reaching it with exit code 5. The passwordless sentinel component described in the original draft is not implemented and must not be added while the server requires a password. The bucket ID is sensitive operational metadata. Logs redact it; status output shows no more than a short fingerprint unless --verbose and an explicit diagnostic option are both used. ### 4.1 Password validation limitation A wrong password normally derives another bucket ID. If that bucket is absent, the server returns 404; there is no encrypted blob on which to detect a wrong password. Therefore init can verify a password only when the derived bucket already exists and can be decrypted. For a missing bucket, initialization says: ~~~text No database exists for this token/password combination. The password cannot be verified until this bucket contains data. Confirm it matches the other clients before creating new history. ~~~ It must not report that the password was validated. --- ## 5. .clipdb wire format The requirement is wire compatibility, not byte-for-byte equality. Randomness, gzip metadata, JSON formatting, and field order may differ. ### 5.1 Containers ~~~text CLIPDB1 = ASCII "CLIPDB1" || gzip(UTF-8 JSON) CLIPDB2 = ASCII "CLIPDB2" || 0x01 || salt[16] || iv[16] || ciphertext[16 * n, n >= 1] || hmac[32] ~~~ For compatibility, a blob without either magic prefix is interpreted as raw gzip JSON. Unsupported magic or invalid gzip is a format error. ### 5.2 Encrypted read 1. Require at least 88 bytes: header, version, salt, IV, one AES block, and HMAC. 2. Require version byte 0x01. 3. Require nonempty ciphertext whose length is a multiple of 16. 4. Parse salt at bytes 8–23, IV at 24–39, ciphertext at 40 through the byte before the final 32 bytes, and HMAC from the final 32 bytes. 5. Derive keys using section 5.4. 6. Verify HMAC-SHA256 over every byte except the appended HMAC, using a constant-time comparison. 7. Only after successful authentication, decrypt AES-256-CBC with PKCS7 padding. 8. Decompress through the configured decompressed-size limit. 9. Validate UTF-8 and decode JSON within the JSON limits. HMAC failure is reported as wrong password or damaged/tampered data without revealing which. ### 5.3 Encrypted write 1. Generate a cryptographically random 16-byte IV. 2. Reuse the existing encrypted file's 16-byte salt when safely available, to mirror desktop behavior; otherwise generate a new random salt. Fresh salt on every write is also wire-compatible and is permitted for transient blobs. 3. Encode JSON as UTF-8 and gzip it. 4. Apply PKCS7 padding and AES-256-CBC encryption. 5. Assemble magic, version, salt, IV, and ciphertext. 6. Append HMAC-SHA256 over the assembled bytes. All randomness comes from the operating-system cryptographic random source. ### 5.4 Key derivation ~~~text dk = PBKDF2( PRF = HMAC-SHA1, password = UTF8(history_password), salt = salt[16], iterations = 150000, length = 64 ) encryption_key = dk[0:32] mac_key = dk[32:64] ~~~ HMAC-SHA1 is required for existing-client interoperability. ### 5.5 JSON model ~~~json { "Version": 1, "UpdatedUnixMs": 0, "Entries": [ { "Id": "32 lowercase hexadecimal characters", "Text": "", "Name": "", "Group": "", "SourceMachine": "", "CreatedUnixMs": 0, "LastUsedUnixMs": 0, "Pinned": false, "IsTemplate": false, "ManualOrder": 0 } ], "DeletedEntries": [ { "Id": "", "TextHash": "", "DeletedUnixMs": 0, "SourceMachine": "" } ] } ~~~ Known fields are always emitted. Missing or null strings normalize to empty strings. Times are Unix milliseconds. New IDs are random GUID/UUID values rendered as 32 lowercase hexadecimal characters without dashes. TextHash is lowercase hexadecimal SHA-256 of the exact UTF-8 bytes of Text. The shipped model.Entry also carries ModifiedUnixMs, which the sketch above omits. Unknown database- and entry-level JSON fields are preserved during every read/modify/write cycle, through an Extra map[string]json.RawMessage on Database, Entry, and DeletedEntry. This deliberately follows the macOS forward-compatibility behavior and prevents a CLI write from destroying fields introduced by a newer client. It is also how the Windows and Mac rich-text HTML/RTF fields survive a CLI round trip: the CLI never emits rich formats, but it never drops them either. ### 5.6 Parse limits and normalization Default limits: - decompressed JSON: 256 MiB; - JSON nesting depth: 100; - entries: 100,000; - tombstones: 100,000; and - one text value: 64 MiB of UTF-8. Before writing: - Version is at least 1 and retains a higher understood source value; - UpdatedUnixMs becomes the mutation time; - blank IDs on retained entries receive new IDs; - zero creation times become the mutation time; - zero last-used times become the creation time; - manual order is normalized to contiguous values while preserving relative desktop order; and - null collections become empty collections. --- ## 6. Configuration and secrets ### 6.1 Default locations Only the configuration file exists today. Cache, queue, and endpoint paths are **deferred** with the daemon and are recorded here as the intended layout. Unix: ~~~text ~/.clipman/ owner-only directory ~/.clipman/config.toml configuration (shipped) ~/.clipman/cache.clipdb daemon cache (deferred) ~/.clipman/pending.clipq daemon pending-operation queue (deferred) $XDG_RUNTIME_DIR/clipman/.sock (deferred) ~~~ If XDG_RUNTIME_DIR is unavailable, use an owner-created, owner-verified directory under the platform temporary directory that includes the numeric user ID. Never use a predictable shared directory without ownership and mode validation. Windows: ~~~text %LOCALAPPDATA%\Clipman CLI\config.toml (shipped) %LOCALAPPDATA%\Clipman CLI\cache.clipdb (deferred) %LOCALAPPDATA%\Clipman CLI\pending.clipq (deferred) \\.\pipe\clipman-cli-- (deferred) ~~~ CLIPMAN_HOME relocates the configuration/data directory. CLIPMAN_CONFIG selects a config file. An explicit --config wins over both. Precedence is: 1. command-line flag; 2. CLIPMAN_CONFIG; 3. CLIPMAN_HOME plus the standard filename; 4. config.toml beside the executable, **if it already exists**; 5. platform default. Step 4 is the portable profile: a copy of the program can carry its own configuration, so two builds can target two servers without interfering. The lookup adopts such a file only once it exists, so an ordinary installation stays on the per-user profile and nothing changes for it. Two ways to create one: init --portable, or answering yes to the question an interactive init asks. That question deliberately avoids two words. "System wide" would be false — nothing here writes machine-wide, only to the user account. "Portable" is jargon that does not say portable relative to what. It asks about the consequence instead: ~~~text Settings are normally saved for your user account, where every copy of Clipman CLI shares them. Save them beside this program instead, so only this copy uses them? [y/N] ~~~ It is skipped when --config, CLIPMAN_CONFIG, or CLIPMAN_HOME has already named a location, and on --non-interactive runs. The lookup covers the directory holding the executable, resolved through symlinks, and deliberately **not** the working directory. A config file names a server and carries a token; letting an unrelated config.toml in whatever folder the shell happens to be in take over a session would be a credential-substitution footgun, not a convenience. **Deferred:** the account identifier used for IPC is a stable hash of the canonical config path and bucket identity. Different configs and accounts never share an endpoint. ### 6.2 Filesystem protection On Unix: - private directories have no group/other permissions and are owned by the effective user; - secret/state files have no group/other permissions and are owned by the user; - newly created directories use mode 0700 and files mode 0600; - stricter safe modes are accepted when the requested operation can still run; - symlinks are rejected for config, cache, journal, and endpoint paths; - writes use same-directory temporary files, explicit permissions, fsync where durability is promised, and atomic replacement; and - every component used for a security decision is revalidated immediately before opening. On Windows: - files and directories receive an ACL granting the current user and required system principals only; - inherited broad access is removed or initialization fails with a repair hint; - reparse points are rejected for protected state unless explicitly supported and safely resolved; and - named pipes use a DACL restricted to the current user SID. Permission failure maps to exit code 3 and includes a concrete repair command or Windows UI instruction. ### 6.3 Config format The file is named config.toml and reads as a restricted TOML subset implemented in internal/config/config.go: comments, one optional [limits] table, and key = value lines whose string values must be Go-quoted. There is no TOML library dependency, and unknown keys or sections are a load error rather than being ignored. ~~~toml server = "https://server.example:52731" token_protected = "base64 DPAPI blob" # Windows; "token" holds plaintext on Unix machine = "workstation" renderer = "line" pinned_first = false tls_insecure = false # written only when true ca_cert_pem = "-----BEGIN CERTIFICATE-----\n..." ca_host = "server.example" # requires ca_cert_pem ca_exclusive = true # requires ca_cert_pem; written only when true default_kind = "history" password_mode = "prompt" password_protected = "base64 DPAPI blob" # only with password_mode = "config" [limits] max_blob_bytes = 67108864 max_json_bytes = 268435456 max_entries = 100000 max_text_bytes = 67108864 ~~~ The default machine name is the OS hostname. The only accepted renderer value is line; tui is rejected until section 8.2 ships. Kind values are history, templates, and all. Password mode is prompt or config; passwordless is rejected with an explanatory message, and keyring is not a value. Validation refuses contradictory profiles: token with token_protected, password with password_protected, saved password values without password_mode = "config", tls_insecure together with ca_cert_pem, ca_host/ca_exclusive without ca_cert_pem, and any limit outside its documented ceiling. ### 6.4 Password resolution Resolution order: 1. --password or -​-password=; 2. presence of CLIPMAN_PASSWORD; 3. the protected password in config, when password_mode = "config"; and 4. a no-echo prompt on the controlling terminal. A blank result at the end of that chain is a failure with exit code 5, not a passwordless profile. Non-interactive contexts that reach step 4 without a terminal fail rather than hanging. **Not implemented:** platform secret stores. Unix Secret Service and Windows Credential Manager were dropped; adding either must not introduce cgo. On Windows the config-stored password is a DPAPI CurrentUser blob, so it is bound to the user account. On Unix it is plaintext inside an owner-only 0600 file, and interactive init says so in those words before saving it. When stdin contains clip text, password prompts read from the controlling terminal, never stdin. platform.OpenConsole and OpenConsoleOutput exist for exactly this. **Deferred:** the daemon retains the history password, or an equivalent capability sufficient to derive keys for arbitrary future salts, in process memory. It must not claim to retain only one derived key. Memory is cleared on orderly shutdown on a best-effort basis; swapping and crash dumps are documented platform risks. ### 6.5 Token input Because tokens are secrets, init supports: - interactive no-echo entry; - CLIPMAN_TOKEN; - --connection-file, reading a Clipman Server .clpconf or connection .txt; - --token-file; and - --token, with a process-list warning. --connection-file cannot be combined with --token or --token-file. The file is size-capped at 64 KiB and may be either the JSON connection object or the Key: value text form; both are parsed by server.ConnectionProfile, which also extracts an optional ca_cert_pem. A JSON object carrying the clipman marker must declare "server-connection" version 1. When no address key is present, one is synthesized from host and port, choosing https when the object names a certificate and key file. An interactive init with no source flags at all first asks whether the user has a connection file, because that is the path that also carries the private certificate authority. Only init persists a token. Logs never contain the token, password, full database ID, plaintext clip text, or authorization headers; --verbose prints only an 8-character bucket fingerprint. On Unix the canonical token lives in the owner-only config as plaintext. On Windows it is stored as a DPAPI CurrentUser blob in token_protected and the plaintext token key is not written. --- ## 7. Command-line contract ### 7.1 General syntax ~~~text clipman-cli [global options] [command options] ~~~ No arguments is an alias for menu only when a controlling terminal exists. Otherwise it is a usage error. Global options are defined once: | Option | Environment | Meaning | |---|---|---| | --config PATH | CLIPMAN_CONFIG | Select config | | --server URL | — | Override server for this invocation | | --password VALUE | CLIPMAN_PASSWORD | Override password | | --ca-cert FILE | — | Trust an additional PEM authority | | --insecure | — | Disable TLS verification (warns every run) | | --json | — | Use the command's documented JSON output | | --quiet, -q | — | Suppress nonessential diagnostics | | --verbose | — | Redacted debug diagnostics | | --version | — | Version, GOOS, and GOARCH | | --help, -h | — | Help | Global options are parsed by hand before the command word and must precede it; an option after the command is parsed by that command's flag set. An unrecognized leading option says so and points at that rule. Within a command, operands are permuted behind options, so get 3 --json and get --json 3 are equivalent, and -- protects an operand that begins with a hyphen. For init, the effective global server/password/TLS values are values to save or test. They are not separate shadowing flags. Command summary: | Command | Purpose | Principal options | |---|---|---| | init | Configure and test a profile | token/connection source, password storage, machine, non-interactive, force | | get | Emit one entry | selector, kind, newline, touch, first, raw | | put | Add or reuse text | file/text/stdin, metadata, template, duplicate mode | | list | Browse entries | count, filters, kind, JSON, porcelain | | rm | Delete exactly one entry | selector, kind, yes, case-sensitive | | menu | Interactive line manager | count, kind, pinned-first | | pick | Select and emit one entry | count, kind, pinned-first | | sync | Download and validate history | JSON, quiet | | status | Inspect remote state | JSON, refresh | | help | Print general or per-command usage | — | **Deferred:** daemon run/start/stop. ### 7.2 Exit codes | Code | Meaning | |---|---| | 0 | Success | | 1 | Local processing, data-format, or unspecified failure | | 2 | Invalid usage, ambiguous non-interactive selection, or confirmation required | | 3 | Authentication, protected-file permission, ACL, or local peer-authorization failure | | 4 | Network failure, timeout, TLS failure, HTTP 413/5xx, or server unavailable | | 5 | Password required, HMAC/decrypt failure, or password cannot open an existing bucket | | 6 | Entry or bucket content not found after a successful read | | 130 | Interrupted by the user where the platform supports this convention | HTTP 401/403 map to 3; 409/412 are handled internally until retries are exhausted, then 1; missing database is an empty history, and selection from it returns 6. Broken output pipes terminate quietly using the platform's normal pipeline convention. ~~Known gap:~~ **Fixed.** mapRuntimeError used to fall through to 4 for anything it did not recognize, so a local encode or decode failure surfacing through a network call path reported a network code — telling a script to retry what retrying cannot fix. It now falls through to 1, and claims 4 only for a failure that is the network's or the server's: net.Error (timeouts, refused connections, DNS, and the *url.Error the HTTP client wraps transport and TLS handshake failures in), an unwrapped certificate-trust failure, or a server.StatusError reporting 413 or 5xx. A 4xx that is not one of the sentinels is the request's problem and exits 1. That distinction needed a typed error: server.StatusError keeps the status code rather than only formatting it into a message, because recovering it by parsing the message back out would be worse. ### 7.3 History-kind filter Commands that view or address entries accept: ~~~text --kind history|templates|all ~~~ Default is history, which excludes IsTemplate=true. templates includes only templates. all combines both. Filtering occurs before indexing. Every displayed index is valid only within the exact filtered view printed by that command. ### 7.4 Canonical ordering Within the selected kind and filters: 1. LastUsedUnixMs descending; 2. CreatedUnixMs descending; and 3. Id ascending using ordinal comparison. Pinned entries do not float by default, so index 0 means most recently used. --pinned-first or config enables the desktop-style pinned view. ### 7.5 init ~~~text clipman-cli [--ca-cert FILE | --insecure] init [--connection-file PATH | --token-file PATH | --token VALUE] [--save-password none|config] [--machine NAME] [--non-interactive] [--force] ~~~ Initialization: 1. resolves and canonicalizes URL and token, optionally from a connection file; 2. resolves a nonblank password; 3. resolves TLS trust, offering the fingerprint prompt of section 3.4; 4. derives the bucket ID; 5. checks health and authenticated download access; 6. decrypts the bucket if it exists; 7. clearly reports when a missing bucket prevents password validation; 8. creates protected platform directories; 9. writes canonical config atomically; and 10. optionally stores the password protected for this user. Non-interactive mode requires server, token, and a nonblank password. Existing config requires --force. A failed connectivity test does not overwrite existing config, because the config write happens only after health and download checks pass. Without an explicit --save-password, an interactive run asks whether to save the password and states the protection the platform actually gives it. With --json, initialization emits one object containing server, bucket_fingerprint, bucket_exists, password_validated, and config_path. ### 7.6 Selectors The following selectors are mutually exclusive: - positional index; - --id ID; - --name NAME; and - --search TEXT. Index is zero-based after kind and other filters. ID is exact. Name and search are Unicode case-insensitive by default; --case-sensitive changes that. Search considers Name and Text in canonical order. If a name or search expected to identify one entry produces multiple matches: - read-only get uses the first canonical match only when --first is supplied; - interactive commands ask the user to select one; and - non-interactive commands fail with code 2 and print candidate indices/IDs to stderr or a structured error under --json. ### 7.7 get ~~~text clipman-cli get [INDEX] clipman-cli get --id ID clipman-cli get --name NAME [--first] clipman-cli get --search TEXT [--first] ~~~ Options: - common selector and kind options; - --newline, -n; - --touch; - --raw; - --pinned-first; and - --case-sensitive. Default output is exact UTF-8 Text with no added bytes, except that a template entry has its variables resolved. --raw suppresses that resolution and emits the stored text. --newline ensures the output ends in one LF byte; it does not add another LF when one is already present. --touch records one write operation that updates only LastUsedUnixMs, matching desktop MarkUsed behavior. It does not change SourceMachine. A concurrent delete wins; touch does not resurrect an entry. With --json, stdout is one JSON entry object plus Index, and a resolved template additionally carries ResolvedText; plaintext Text is not separately emitted. ### 7.8 put ~~~text command | clipman-cli put clipman-cli put --file PATH clipman-cli put --text VALUE ~~~ Exactly one input source is used. --file and --text are mutually exclusive; otherwise stdin is read. TTY stdin is allowed but help explains that input ends at EOF. Options: - --name NAME; - --group GROUP; - --pin; - --template; - --duplicate ignore|movetotop|keep, default movetotop; and - --file or --text. Input must be valid UTF-8, nonempty, and within the text limit. Whitespace, line endings, and NUL characters that are valid UTF-8 are preserved, matching Windows desktop text behavior. No implicit trimming occurs. Duplicate behavior matches the desktop: - ignore: return the existing ID without writing; - movetotop: update only LastUsedUnixMs and SourceMachine on the existing exact-Text match; metadata flags do not modify that existing entry; - keep: create a new entry with a new ID. Duplicate lookup is restricted to the requested kind. This is a deliberate CLI adaptation of the desktop's separate history/template workflows: an explicit put --template must not silently move a non-template history entry instead of creating or updating a template. Within one kind, the desktop duplicate behavior is reproduced exactly. New entries receive current creation/last-used times and ManualOrder=max(existing)+1. The database is not pruned for maximum entry count or age, but tombstones are normalized as required by section 9. Name and Group flag values are trimmed like desktop metadata editors; Text is never trimmed. Default stdout is empty. The resulting ID is informational stderr unless quiet. With --json, the resulting entry and whether it was created, moved, or ignored are emitted on stdout. ### 7.9 list ~~~text clipman-cli list [-n COUNT | --all] [--group GROUP] [--search TEXT] [--kind history|templates|all] [--pinned-first] [--json | --porcelain] ~~~ Default count is 20. Human output contains index, P/T flags, age, source machine, name/group, and a one-line escaped preview. Width-aware truncation never splits a UTF-8 sequence. JSON output is an array of wire-shaped entries with an added numeric Index. Porcelain output is: ~~~text indexidage_mssourcenamepreview ~~~ Backslash, tab, LF, CR, and other control bytes in every textual column use C-style escapes. One physical output line always represents one entry. ### 7.10 rm ~~~text clipman-cli rm INDEX clipman-cli rm --id ID clipman-cli rm --name NAME clipman-cli rm --search TEXT ~~~ Options include common selector/kind options, --yes, and --case-sensitive. Exactly one entry must be selected. Multiple name/search matches open a single-selection prompt on a controlling terminal. Without a terminal, the command fails with code 2 and requires an exact ID or index. There is no implicit bulk-delete mode. Deletion requires confirmation unless --yes. That flag skips confirmation only after one entry is identified. It never changes selection scope. The operation removes the entry and adds/updates a tombstone with ID, exact text hash, current time, and this CLI machine name. Default stdout is empty; a concise deletion result goes to stderr unless quiet. With --json, stdout contains the removed entry ID, its former filtered index, kind, and the written tombstone. ### 7.11 Interactive commands ~~~text clipman-cli menu [-n COUNT | --all] [--kind ...] [--pinned-first] clipman-cli pick [-n COUNT | --all] [--kind ...] [--pinned-first] ~~~ Only the line renderer exists. **Deferred:** --renderer line|tui, the --tui and --line aliases, and config-driven renderer selection all arrive with section 8.2. Until then renderer in config accepts line only, and neither command takes positional arguments. menu rejects --json: it is a conversation, not a data format. All prompts and interface drawing use the controlling terminal. Stdout is reserved for a selected clip's Text. Therefore these remain composable: ~~~sh clipman-cli pick | ssh web-02 'cat > /tmp/snippet' clipman-cli menu | some_command ~~~ If no controlling terminal exists, interactive commands fail with code 2 instead of mixing prompts with payload output. ### 7.12 Synchronization and status ~~~text clipman-cli sync [--json] clipman-cli status [--json] [--refresh] ~~~ **Shipped.** sync downloads, decodes, and validates the current database, then reports the entry count or says plainly that no database exists for this token and history password — because an absent bucket usually means a mistyped password rather than an empty history, and "history is current" would hide that. Its JSON object carries revision, database_exists, entries, and uploaded, which is always false today. status reports the server URL, the 8-character bucket fingerprint, whether the database exists, its revision and length, and the server health object. --refresh additionally downloads and counts entries; without it, entries stays null in JSON so a consumer cannot mistake "not counted" for zero. Unlike the original design, status always makes a network request, since there is no local cache to report on. **Deferred with the daemon:** ~~~text clipman-cli daemon run clipman-cli daemon start clipman-cli daemon stop ~~~ daemon run stays in the foreground for systemd, launchd-like supervision where supported, or Windows service wrappers. daemon start launches a per-user background process. Once the daemon exists, sync forces immediate download, merge, pending-operation replay, and upload; if work remains because the server is unreachable it exits 4 without discarding pending work, and its JSON gains pending count, freshness, and any redacted error. status gains daemon reachability, cache freshness, last successful sync, last error, pending operation count, and oldest pending age, and stops requiring a network request unless --refresh is supplied. ### 7.13 rules — sync channels ~~~text clipman-cli rules show [--json] clipman-cli rules enable [--json] clipman-cli rules disable [--json] clipman-cli rules channel add [--group G]... [--source-device D]... [--kind richtextimages] [--json] clipman-cli rules channel remove [--json] clipman-cli rules device set --channels [--json] ~~~ **Shipped.** Manages the machine-scoped sync rules of sync-rules-spec.md, which is normative for every derivation, schema, and algorithm below; this section documents only the CLI surface. rules show prints the document in effect — channels with a plain-language route summary, then registered devices with their subscriptions — or "Sync rules are not enabled." With --json it emits the raw document object (or null). A document written by a newer format version is shown with a note that only what this version understands is applied; every mutating subcommand refuses such a document, because a client that cannot fully evaluate the rules must not rewrite them. rules enable and rules disable flip the document's global Enabled switch, creating the rules bucket on first enable with If-None-Match: *. Disabling never moves or deletes entries; every client simply returns to single-bucket behavior. rules channel add creates a channel whose route ANDs the given conditions (repeat --group and --source-device for lists). Names are validated against the channel-key grammar and reserved names of sync-rules-spec.md section 3 before anything is uploaded. rules channel remove implements the spec's channel-deletion algorithm: it first re-routes the channel's resident entries under a transitional document (they fall through to the next matching rule or to core, via the ordinary gain-before-lose two-phase upload), verifies the re-route actually committed and left the channel empty, and only then removes the channel from the rules document. A concurrent newer rules edit aborts the removal rather than risking entries stranded in an unlisted channel. rules device set replaces one device's subscription list, adding the device to the registry if it is not yet listed. --channels takes channel names or the single value * meaning every channel; core is always implicit. Mixing * with named channels is rejected. Channel-aware behavior of the other commands: list, get, put, rm, sync, pick, and menu operate on the merged view of core plus the channels the configured machine name subscribes to, through the engine's ReadView/MutateView. With no rules document, or a disabled one, behavior is byte-for-byte the pre-rules single-bucket flow. A capture that routes to an unsubscribed channel is written through to that channel's bucket and reported on one line; if the write-through fails, the entry is parked in a pending store beside the configuration file and retried by later commands. The last-seen rules document is cached beside the configuration file as well (spec section 4, Caching); neither file carries a secret. A future-version document is cached for display only and is never re-uploaded by the 404 fallback. --- ## 8. Interactive accessibility ### 8.1 Line renderer Line mode is the only mode. It lives in internal/ui/line behind a Store and a Console interface, so the whole interface is drivable from a test that records every announced line. ~~~text Clipman line interface. Type ? for commands. Clipman history: 42 entries. Page 1 of 3, entries 0 to 19. 0. kubectl get pods -A; group ops; from win-desktop; used 3m ago 1. deploy runbook; pinned; group ops; from web-01; used 1h ago Command (? for help): ~~~ Each row is a complete labeled sentence with a relative time, because a row heard on its own carries no column headers. An empty field is announced as a dash rather than as silence. Counts agree with their nouns. Command set: NUMBER view, o NUMBER emit and exit, d NUMBER delete after confirmation, /TEXT search, / clear search, n/p page, a add, r reload, u (also ui, renderer) switch interface, v/w/x NUMBER read, save, and run (section 8.4), ? help, q quit. The search prefix is handled before the command is split on whitespace, so search text may contain spaces. A bare NUMBER opens the paged reader rather than announcing the whole clip. It used to call Console.Say(b.text(entry)), so a five-thousand-line clip arrived as one unstoppable announcement with no way to slow down, go back, or leave — the listening equivalent of a wall of text with no scrollbar, in the interface built for listening. Viewing, prompts, selection, and errors remain on the terminal. Only o or the final pick result writes payload text to stdout. A template is resolved before it is viewed or emitted. **The view is loaded once.** A network round trip per command turns list navigation into a stall on every keystroke, which is worst for exactly the users this renderer exists for. Store.Load runs when the browser opens and on explicit r; a delete or add updates the in-memory list and re-sorts it through operation.SortView rather than downloading the database to observe a change the client just made. Indices are positions in the filtered view, not in the page, so a number stays valid across paging. -n sets the page size for menu rather than truncating the list — the previous behavior made entries past the limit unreachable — and --all disables paging. New-entry input terminates on a lone ., escapes a literal period line as .., and cancels on !cancel. The clip joins the kind being browsed. **Still deferred within the line renderer:** save-to-file (s). If added it must refuse overwrite unless explicitly confirmed. Any pipe-to-command action must avoid implicit shell parsing; omit it rather than define an injection-prone mini-shell. ### 8.2 TUI renderer — shipped The TUI uses tcell and remains pure Go. It provides a scrollable list, live filter, preview, kind switching, and accessible action labels. It lives in internal/ui/tui and is a second renderer over the same model, not a second program: rows come from output.Describe and it drives the same Store, so an entry is described identically in both renderers. The Store interface is redeclared locally rather than imported from line, so neither renderer depends on the other. Run owns the terminal lifecycle — Init, deferred Fini, panic recovery — and delegates to an unexported loop that runs against an already-initialized screen. That split exists so behavior can be asserted while the screen is still readable; finalizing it clears both its contents and its size. Keys: arrows, Page Up/Down, Home/End navigate; g goes to an entry by number; Enter emits and exits; / filters live with Escape to clear; Tab cycles history → templates → all; d deletes after confirmation and is refused under pick; r reloads; u switches to the line interface after a y confirmation and is refused under pick; v reads the whole clip, w saves it, x runs a program on it (section 8.4); ? shows keys; q or Escape quits. Layout rows are heading, status, preview label, preview, then the list from row four. A diagnostic row above the list was removed once the caret was proven and the file trace existed: it cost one entry row on every session for something almost nobody enables. Prompts are editable rather than backspace-only. promptEditor holds the typed line and a caret offset within it, promptParts returns that offset, and caretPosition uses it, so the caret rests on the character being edited and moving back through a line reads it out. Left, Right, Home, End, Delete, Ctrl+U, Ctrl+A, and Ctrl+E work in every prompt through one shared editPrompt. Correcting a forty-character command line by destroying everything after the mistake is the cost this removes. ### 8.3 Switching interface, and saying which one you are in Two interchangeable interfaces shipped with nothing naming the one you were in, so a user had no reason to suspect the other existed. That is a discoverability defect and it cost a debugging session: the line interface's prompt cursor was mistaken for a full-screen caret bug, and the fix was attempted in the renderer that was not running. Top-level help asserting menu — Open the accessible line-based history manager was the proximate cause; it was wrong whenever renderer = "tui". Each interface therefore names itself on entry — Clipman line interface in the greeting, Full-screen interface on the status row — and status reports the configured one as Interface: and as interface under --json, which answers the question without opening either. u moves between them, after a confirmation in both directions: tcell clears whatever the line interface printed the moment it initializes, so a message announced on the way out cannot be relied on to be heard, while a question that must be answered is certain to have been read. internal/ui/handoff carries {Selected, Filter, Kind} across, returned as an error because that is the only channel a browser has back to its caller and matched with errors.As at every interception point. The whole screen changes under someone who cannot see it, so arriving at the top of an unfiltered list is how a person loses their place; the full-screen interface lands the caret on the carried row, and the line interface, having no caret to land, says You were on entry 12. instead. Kind travels because Tab can change it in one interface and the other cannot. Three failure modes are deliberate. The screen is created before the choice is saved, so a terminal that cannot run the full-screen interface costs no session and no setting. A configuration file that cannot be written costs the saved preference, not the session. pick refuses to switch on both sides: it offers one choice and exits, so switching out would strand the user in a picker with no way back, and saving a preference as a side effect of pick | ssh host would be a surprise write to a file holding a credential. The already-downloaded database is handed to the interface being started (cliStore.handOver, consumed once so r still reaches the server). Without it the screen is torn down and then goes silent through a download and a PBKDF2 decrypt, with nothing to explain the gap. **Cursor placement is one rule, revised after real-terminal testing.** The caret goes in whatever the user is moving through or typing into, because a screen reader reads the line the caret is on: - moving through the list keeps the caret at column zero of the selected row; - a question puts the caret at the end of the typed answer, on the **same line** as the question, so the line reads Go to entry number: 12 and the user hears both the question and their answer so far. `promptParts` is the single place that pairs a question with its answer, so every prompt inherits the behavior rather than re-deriving it. The first implementation instead handed the caret to the status line for one draw after any message, to get the message announced. On a real terminal that read as the caret being stuck outside the list: the opening heading is a message, so the caret started off the list, and every delete, filter change, and kind switch threw it off again. Knowing where you are in a list is worth more than hearing a confirmation. Selection is additionally carried in the row text as a -> marker, with unselected rows indented to match. Reverse video alone tells a screen reader nothing and is lost in review-mode navigation. **Layout order and redraw budget** are accessibility constraints, not cosmetics, and both came out of real-terminal testing. tcell buffers a frame and emits only changed cells, scanning top to bottom, so a line's position on screen — not the order of draw calls — decides when its text reaches the terminal. The heading, status line, and preview therefore sit *above* the list, which runs to the bottom of the window, making the entry rows the last text written on every frame. With the status line at the bottom, every redraw ended by writing it, which is the most likely explanation for it being read out repeatedly no matter where the caret was. Everything redrawn is resent to the terminal and so to a screen reader, so the renderer redraws as little as it can: - lines are rewritten individually and padded to the width instead of clearing the screen, since rewriting identical text costs nothing through tcell's diff while a screen-wide clear risks resending everything; - the selected row carries **no** reverse video. A style change across a row makes every character of it dirty, so one arrow key would resend two entire rows; only the three-cell marker is highlighted. An arrow key therefore changes the two markers and the preview line, and nothing else — a budget enforced by test rather than left to intent. Paging, scrolling, filtering, and reloading must repaint the list; the requirement there is only that the caret ends on the selected row afterwards. On every selection change: - the real terminal cursor moves to column zero of the selected row; - the cursor remains visible by default; and - each row is a complete spoken description rather than a visual-only marker. There is no claim of portable automatic screen-reader detection. Users select line mode explicitly through config, --line, or an environment setting documented for deployment. If tcell cannot initialize the requested terminal, the command explains the failure and offers line mode; it does not silently change output semantics. Simulation-screen tests assert cursor position, complete row text, focus order, and restoration of terminal state after exit or panic. --- ### 8.4 Reading, saving, and running a clip Both interfaces offer the same three things on the selected entry, and they are the same code underneath, so a clip behaves identically in each. **Reading.** The full-screen interface opens a viewer with v; the line interface opens a paged reader with a bare number or v NUMBER. Line motion is primary in the viewer even though Page Up/Down, space, and b page: a page key repaints the content area and resends a screenful of text to a screen reader, while an arrow rewrites two markers. Rows are numbered by logical line so position is spoken as part of the row the caret lands on rather than announced separately, and continuation rows of a wrapped line use + instead of .. Position is held as a logical line, never a row index, so re-wrapping on a resize leaves the reader on the same text. Help renders through the same viewer, which is what stopped it being clipped once the key list outgrew a standard terminal. Clip text passes through output.PlainLines in both interfaces: line endings normalised so a stray carriage return cannot move the cursor mid-announcement, tabs expanded because a terminal's own tab handling moves the cursor without the program knowing, and control characters shown in caret notation rather than dropped — dropping them would make what is displayed disagree with what w writes and Enter emits. **Saving** (w) writes verbatim through internal/clipfile: no added newline, mode 0600 because a clip may be a credential, a leading ~/ expanded because there is no shell to do it, and a directory refused with a sentence rather than the operating system's message. A new path is not confirmed — it was typed, and typing is deliberation — but an existing one is, because that is the only branch which destroys something the user did not name. **Running** (x) has no shell anywhere. internal/clipexec splits the typed line into argv, the clip replaces whole arguments that are never re-split, and os/exec runs the result. A clip is untrusted data: substituting it into a command string for sh -c would mean a clip containing ; rm -rf ~ ran when the user asked to echo it. Unquoted shell operators, and the spellings {}, {clip}, $clip, %clip%, are refused with an explanation rather than passed through, because quoting is a clean escape hatch so a refusal fires only on the real mistake. The placeholder is @clip and must be an argument on its own; with none present the clip is piped to standard input. A child process never receives os.Stdout. That stream may be a pipe belonging to the user — pick --tui | ssh host 'cat > f' has one — and child output written into it would be injected into the payload and corrupt the file at the far end. Output is captured, capped at 1 MiB, and presented in the viewer, because the alternate screen is repainted on return and anything printed to the terminal would be gone: one unrepeatable pass at output with no scrollback. The exit status is always reported, including for a command that printed nothing, which must not read as never having run. The clip never travels in an environment variable. In the full-screen interface the command runs on a goroutine and reports back by posting an event, so the loop keeps turning and Escape stops a program that will not finish; a still-running command says so every ten seconds with a changing number, since an identical line would be redrawn into identical cells and never spoken again. The line interface runs it synchronously — there is no event loop to keep alive, and Ctrl+C is the terminal's own way out. **Announcing completion.** The caret returns to the row being read, by design, so a status line written on the way past is very likely never spoken. That is tolerable for "reloaded" and not for "was my file written" or "was that entry deleted". Both are held in a mode whose prompt is the message, which puts the caret on it and costs one keypress on the branches that matter. **pick allows v and refuses w and x.** The principle is that pick has exactly one output and its caller chose it. Confirming which clip is about to go down the pipe is what pick most needs; writing a file the pipeline knows nothing about is not that output, and an arbitrary-program affordance reachable from inside a pipeline stage is a wider surface than the same key in an interactive menu. **Windows.** With no shell, a .cmd or .bat target needs its interpreter named (x cmd /c mytask.cmd), and Go 1.19+ removed the working directory from LookPath, so a program there needs an explicit path. Both are documented rather than left to fail cryptically. ## 9. Merge and normalization ### 9.1 Tombstones Desktop-compatible normalization: - discard null markers and markers with blank IDs; - trim marker IDs; - retain markers no older than 90 days; - group by ID and keep the greatest DeletedUnixMs; - fill a missing TextHash from another marker for the same ID; - normalize null TextHash and SourceMachine to empty strings; and - sort deterministically by DeletedUnixMs descending, then ID. The Windows and macOS clients differ on a zero DeletedUnixMs. The CLI follows macOS and assigns the normalization time, because retaining zero forever defeats the documented 90-day policy. This is a deliberate, tested exception. After tombstone union, remove any entry whose exact ID or lowercase exact TextHash is covered. ### 9.2 Entry union For each incoming nonempty-Text entry not covered by a tombstone: 1. match an existing entry by ID, using case-insensitive comparison because generated IDs are hexadecimal and casing must not create duplicates; 2. otherwise match by exact Text; 3. otherwise clone the incoming entry, including IsTemplate and unknown fields. Case-insensitive ID matching is a deliberate robustness choice where Windows and macOS currently differ. When IDs match but Text differs, preserve the target Text, matching desktop merge behavior. Text editing is not silently inferred from timestamp metadata. ### 9.3 Metadata merge For a matched entry: ~~~text incoming_wins = incoming.LastUsedUnixMs >= existing.LastUsedUnixMs incoming_created_wins = incoming.CreatedUnixMs > existing.CreatedUnixMs ~~~ - LastUsedUnixMs becomes the larger value. - CreatedUnixMs follows the desktop condition: use incoming when it is positive and existing is zero, incoming is newer, or incoming is older while the existing side wins LastUsed. - A nonblank incoming Name or Group replaces the target only when incoming wins. - A nonblank incoming SourceMachine replaces the target when incoming wins or incoming_created_wins. - Pinned is sticky true. - IsTemplate is not changed on a matched entry, reproducing shared Windows/macOS behavior. Android/iOS currently make it sticky; that divergence is covered by fixtures and called out in release notes. - ManualOrder becomes the smaller positive value, treating nonpositive as unset. - Unknown fields are unioned without overwriting a known field; on unknown-field conflicts, the more recently used side wins. Blank metadata does not clear an older nonblank desktop value. ### 9.4 Final normalization After merging: - apply tombstones again; - remove entries with empty Text; - repair IDs and zero timestamps; - retain relative manual ordering, with unset values last and CreatedUnixMs as tie-breaker; - renumber ManualOrder from 1; - set Version to at least 1 while preserving a higher source value; - set UpdatedUnixMs to the operation time; and - preserve deterministic output ordering independent of map iteration. --- ## 10. Synchronization ### 10.1 Stateless read GET, size-limit, decode, normalize in memory without writing, filter, and emit. A missing bucket is empty history. Read-only commands never upload normalization changes merely because they observed older data. ### 10.2 Operation intents **Partly deferred.** The shipped engine represents a mutation as a Go closure passed to syncengine.Engine.Mutate, re-executed against freshly downloaded state on each conflict retry. That gives retries the same reapplied semantics without a serializable intent: put preassigns its new ID before the first attempt, and one mutation timestamp is captured before the loop and reused by every retry. The serializable form below is required only once operations must outlive the process, which is a daemon concern. Every mutation is represented as an immutable operation intent with: - random operation ID; - kind: put-ignore, put-movetotop, put-keep, touch, or delete; - original mutation timestamp; - target ID and/or exact text/hash as required; - new entry ID for keep/new operations; - requested metadata; and - originating machine. Retries reapply the same intent and timestamp. They do not create another ID, advance time, or broaden a selection. Delivery metadata is separate from the immutable intent. In particular, a delete that remains pending beyond the desktop's 90-day tombstone window is not silently discarded: before its next upload attempt, the daemon durably assigns one refreshed replay tombstone timestamp and reuses that value for all retries. The original deletion time remains recorded for status and recovery. ### 10.2.1 Shipped retry behavior Engine.Mutate reads, normalizes, applies the mutation, normalizes again, encodes, and uploads. A mutation reporting no change returns without a PUT. On ErrConflict it sleeps 30 + attempt*40 milliseconds and retries up to Retries times, default 3. Exhaustion returns "database changed repeatedly; operation was not committed" wrapping the conflict, which surfaces as exit code 1. The backoff is fixed, not jittered; adding jitter is a reasonable small improvement. ### 10.3 Existing-bucket write 1. GET current blob and revision R. 2. Decode and normalize. 3. Apply the operation intent. 4. If the intent is a no-op, return success without PUT. 5. Encode and PUT with If-Match: "R". 6. On success, verify the returned revision and finish. 7. On 409/412, back off with jitter, GET the new revision, reapply the same intent, and retry up to the configured limit. 8. Exhaustion returns code 1 and preserves a daemon pending operation. Delete and touch do not resurrect a remotely deleted entry. A keep/new operation uses its preassigned ID across retries. Move-to-top re-finds exact Text. ### 10.4 Missing-bucket write **Shipped, conditional branch.** A write against a bucket that did not exist at read time sends If-None-Match: * and no If-Match. The server answers 412 if another client created it first, which the client maps to ErrConflict and rebases exactly like an ordinary revision conflict. The compatibility branch is not implemented and is not needed. ### 10.5 Daemon local-first writes — deferred Sections 10.5 through 10.7 describe unbuilt work. No cache, pending queue, IPC endpoint, or CLIPQ container exists in the tree. The pending queue uses a private, versioned container: ~~~text CLIPQ01 = ASCII "CLIPQ01" || gzip(UTF-8 PendingQueue JSON) CLIPQ02 = ASCII "CLIPQ02" || the CLIPDB2 version/salt/IV/ciphertext/HMAC layout ~~~ CLIPQ02 uses the same password, PBKDF2 parameters, authentication order, and limits as CLIPDB2. Since every profile now has a password, CLIPQ02 is the only form the daemon should write; the CLIPQ01 container is reserved but unused. PendingQueue contains Version, AccountId, UpdatedUnixMs, and an ordered Operations array of the immutable intents from section 10.2. Unknown queue versions or a mismatched AccountId fail closed. For a daemon-backed mutation: 1. validate and authorize the local request; 2. atomically rewrite the protected pending queue with the added intent and durably flush the file and containing directory; 3. apply it to the local cache; 4. atomically save and durably flush the cache; 5. only then acknowledge success; 6. attempt network synchronization immediately; and 7. retain the intent until a server state containing it is confirmed, then atomically rewrite the queue without that intent. If a crash occurs after queue flush but before cache save, startup replay repairs the cache. If cache save completed but acknowledgement was lost, the same request ID retrieves the recorded result. Request and operation IDs make all replay idempotent. Cache and pending-queue data are authenticated-encrypted with the history password, in addition to owner-only filesystem protection. A password or token change produces a different account identity. Pending work is never silently sent to the new account. The CLI requires the old account to sync, or an explicit reviewed migration/export operation, before removing its queue and cache. ### 10.6 Daemon reads and freshness Daemon reads serve the local committed view. The daemon HEAD-polls while online and records the last verified revision and time. Default freshness target is two seconds while active, with backoff after failures. Offline or stale reads still succeed from cache but emit a concise stderr freshness warning unless quiet. JSON output includes freshness metadata in a wrapper rather than corrupting the entry object. ### 10.7 Daemon discovery and fallback The client performs a handshake containing protocol version, account identity, config fingerprint, and request ID. It uses a daemon only for the exact effective profile. Per-command server/password overrides bypass a mismatched daemon. Safe read-only requests may fall back to stateless mode after connection failure. A mutation whose completion is uncertain must not be replayed statelessly; the client reconnects with the same request ID or reports an indeterminate local daemon error. The newline-delimited JSON socket example in the earlier draft is replaced by a versioned, length-limited protocol: ~~~text uint32 big-endian payload length UTF-8 JSON payload ~~~ The default payload limit is 1 MiB; large clip bodies use a separately bounded stream frame rather than unbounded JSON allocation. Every request contains ProtocolVersion, AccountId, RequestId, Operation, and typed arguments. Every response repeats RequestId and contains Ok, a stable local error code, freshness/pending metadata where relevant, and typed result data. Unsupported protocol versions fail before processing. The complete schema and compatibility policy are committed with the daemon implementation. --- ## 11. Project layout Actual layout, with deferred packages marked: ~~~text ClipmanCli/ cmd/clipman-cli/main.go + main_test.go cmd/clipman-cli/rules.go + rules_test.go — rules commands, caches internal/model/ model.go, file_history.go internal/clipdb/ CLIPDB1/CLIPDB2 codec, raw rules-blob container internal/identity/ bucket ID derivation (database, channel, rules) internal/config/ restricted TOML subset internal/server/ HTTP client, URL/token/CA parsing internal/merge/ merge, normalization, ID generation internal/operation/ kinds, selectors, put/delete/touch internal/rules/ sync rules document, validation, routing internal/syncengine/ read + optimistic mutate; channels.go multi-channel view internal/template/ template variable resolution internal/platform/ paths, console, DPAPI; _unix/_windows files VERSION build.sh Build.ps1 go.mod go.sum Manual.html clipman-cli.1 internal/output/ pure formatting, no I/O internal/ui/line/ line browser behind Store/Console interfaces internal/ui/tui/ tcell renderer, same Store and row text internal/daemon/ deferred with section 10.5 testdata/ deferred with section 14.1 ~~~ One departure worth noting: platform code is split by build-tagged files in a single internal/platform package rather than unix/ and windows/ subpackages. internal/output is deliberately pure — every function takes the current time as an argument and returns a string, so the exact bytes a screen reader announces are assertable. internal/ui/line depends on Store and Console interfaces rather than on the sync engine and the terminal, which is what makes the transcript tests possible and what a second renderer would reuse. internal/model/file_history.go and the DecodeFileHistory/EncodeFileHistory codec functions are inherited from the ClipmanLinuxBackend fork and are unreachable from any command. File-list history remains a non-goal. Keep them in step with the backend or delete them deliberately; do not let them rot silently. The Go directive is 1.25.0 and would be raised only if a pinned tcell release required it. Prefer standard crypto/pbkdf2; HMAC-SHA1 is passed explicitly. Current dependencies are golang.org/x/term, golang.org/x/sys, and github.com/gdamore/tcell/v2 for the TUI, which brings gdamore/encoding, lucasb-eyer/go-colorful, rivo/uniseg, and golang.org/x/text indirectly. None uses cgo. The TOML library and secret-store package the original draft anticipated were avoided by hand-writing the config parser and dropping keyring support. UUID generation uses the standard random source directly. No dependency may introduce cgo without revisiting the build matrix — every target builds with CGO_ENABLED=0. --- ## 12. Build and packaging ### 12.1 Supported targets Seven targets ship, each in its own directory so the binary keeps its plain name on every platform: | Target | Artifact | |---|---| | Windows amd64 | windows-amd64/clipman-cli.exe | | Windows arm64 | windows-arm64/clipman-cli.exe | | Linux amd64 | linux-amd64/clipman-cli | | Linux armv7 | linux-armv7/clipman-cli | | Linux arm64 | linux-arm64/clipman-cli | | macOS amd64 | macos-amd64/clipman-cli | | macOS arm64 | macos-arm64/clipman-cli | This supersedes the original four-target flat list, which named binaries clipman-cli-<os>-<arch>. macOS and Windows arm64 were promoted out of section 18's deferred list. WSL uses Linux amd64. Raspberry Pi hardware older than ARMv7 requires a separately advertised GOARM=6 artifact; the ARMv7 label must not claim support for every Raspberry Pi. Running locally under Android/Termux is not implied by the Linux artifacts and requires separate testing. Both build scripts run go test ./... and go vet ./... before compiling. The PowerShell entry point is Build.ps1, capital B, not build.ps1. ### 12.2 Correct Bash pattern ~~~sh #!/usr/bin/env bash set -euo pipefail ver=$(git describe --tags --always 2>/dev/null || echo dev) ldflags="-s -w -X main.version=$ver" mkdir -p dist build() { local goos="$1" goarch="$2" goarm="$3" output="$4" if [[ -n "$goarm" ]]; then env CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" GOARM="$goarm" \ go build -trimpath -ldflags "$ldflags" -o "dist/$output" ./cmd/clipman-cli else env CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ go build -trimpath -ldflags "$ldflags" -o "dist/$output" ./cmd/clipman-cli fi } build linux amd64 "" clipman-cli-linux-amd64 build linux arm 7 clipman-cli-linux-armv7 build linux arm64 "" clipman-cli-linux-arm64 build windows amd64 "" clipman-cli-windows-amd64.exe ( cd dist LC_ALL=C sha256sum \ clipman-cli-linux-amd64 \ clipman-cli-linux-armv7 \ clipman-cli-linux-arm64 \ clipman-cli-windows-amd64.exe | LC_ALL=C sort > SHA256SUMS ) ~~~ Do not generate an assignment through parameter expansion; Bash treats the expanded GOARM=7 word as a command rather than an environment assignment. ### 12.3 PowerShell build The PowerShell script uses scoped environment restoration and the same explicit target list. The central pattern is: ~~~powershell $ErrorActionPreference = 'Stop' $version = (git describe --tags --always 2>$null) if (-not $version) { $version = 'dev' } $ldflags = "-s -w -X main.version=$version" $targets = @( @{ os='linux'; arch='amd64'; arm=''; out='clipman-cli-linux-amd64' }, @{ os='linux'; arch='arm'; arm='7'; out='clipman-cli-linux-armv7' }, @{ os='linux'; arch='arm64'; arm=''; out='clipman-cli-linux-arm64' }, @{ os='windows'; arch='amd64'; arm=''; out='clipman-cli-windows-amd64.exe' } ) New-Item -ItemType Directory -Force dist | Out-Null $saved = @{ CGO_ENABLED = $env:CGO_ENABLED GOOS = $env:GOOS GOARCH = $env:GOARCH GOARM = $env:GOARM } try { foreach ($target in $targets) { $env:CGO_ENABLED = '0' $env:GOOS = $target.os $env:GOARCH = $target.arch if ($target.arm) { $env:GOARM = $target.arm } else { Remove-Item Env:GOARM -ErrorAction SilentlyContinue } & go build -trimpath -ldflags $ldflags -o "dist/$($target.out)" ./cmd/clipman-cli if ($LASTEXITCODE -ne 0) { throw "build failed: $($target.os)/$($target.arch)" } } } finally { foreach ($name in $saved.Keys) { if ($null -eq $saved[$name]) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } else { Set-Item "Env:$name" $saved[$name] } } } $records = foreach ($target in $targets) { $hash = (Get-FileHash "dist/$($target.out)" -Algorithm SHA256).Hash.ToLowerInvariant() "$hash $($target.out)" } $lf = [char]10 $manifest = (($records | Sort-Object) -join $lf) + $lf [IO.File]::WriteAllText( (Join-Path (Resolve-Path dist) 'SHA256SUMS'), $manifest, [Text.UTF8Encoding]::new($false) ) ~~~ It fails immediately on any nonzero Go exit code. Production scripts build into a fresh staging directory under dist and publish only after all targets succeed; the compact example above focuses on correct target/environment handling. Checksums are computed from an explicit ordered artifact list. Existing SHA256SUMS, stale binaries, directories, and unrelated files are never included. Both scripts write lowercase hashes, two spaces, filename, LF, and a final LF so the checksum manifest is reproducible. ### 12.4 Release gates For every target: - cross-build succeeds with CGO_ENABLED=0; - the binary starts and prints version/help on the target OS or emulator; - codec and identity fixtures pass on the target; - Windows ACL, credential, console, and IPC tests pass on Windows; - the binary has no unintended dynamic runtime dependency; - generated checksums verify; and - help/manual command tables match. --- ## 13. Security requirements - Authenticate encrypted data before decryption or decompression. - Use constant-time MAC comparisons and OS cryptographic randomness. - Enforce compressed, decompressed, JSON, entry, text, IPC, and HTTP limits. - Never log secrets, bucket IDs, clip text, journal contents, or auth headers. - Treat --password, --token, and --text as process-list exposure risks in help. - Protect Unix files by ownership and mode and Windows files by DACL. - Reject unsafe symlink/reparse-point paths. - Authenticate daemon peers and namespace endpoints by user and account (deferred with the daemon). - Use request IDs and durable queue-before-cache ordering before acknowledging offline writes (deferred with the daemon). - Bound conflict retries and network backoff. - Refuse to follow a redirect that changes scheme or host, so bearer credentials never reach another origin. - Warn on every run when TLS verification is disabled, and require an out-of-band fingerprint comparison before trusting a private authority interactively. - Explain that the token/password combination provides confidentiality and bucket discovery, while stolen authorization or bucket metadata may still enable corruption or denial of service. - Do not provide an interactive arbitrary shell-command action without a separately reviewed execution and quoting design. --- ## 14. Testing Current state: Go unit tests cover the codec, identity, merge, operation, config, server client, sync engine, template resolver, line UI, output formatting, and CLI argument parsing, and all pass. The Windows half of 14.1 is done; most of 14.3–14.6 is outstanding. ### 14.1 Interoperability — Windows done, others outstanding The corpus lives in ClipmanCli/testdata/fixtures/<generator>/ and is documented by its own README. Generators are discovered rather than listed, so adding a directory brings it into every test with no Go change, and tests skip cleanly when the corpus is absent. Done: - CLIPDB1 and CLIPDB2 fixtures from Windows, generated by tools/ClipmanFixtures compiled against src/ so the blobs come from the canonical ClipDatabaseFile. - Identity vectors for ASCII, Unicode token, Unicode password, whitespace requiring trimming, and the empty-token and empty-password cases that have no bucket. - Windows decrypts CLI-written blobs, via the generator's verify mode and go test -clipman-export. - Awkward-text coverage: CRLF, LF, no trailing newline, trailing whitespace, NUL, Unicode, emoji, quotes, backslashes, and embedded JSON and HTML. - Unknown-field preservation, proven with a Windows rich-text payload the CLI has no field for. Compared by meaning, not bytes: preserving a field does not oblige a client to reproduce another's whitespace or key order. - Wrong-password and single-bit-tamper rejection on every encrypted fixture. - Live-server round trip: the bucket directory the server creates matches the client-derived ID, and Windows reads the blob the CLI uploaded through HTTP. - The sync-rules set (go/): a rules-document blob, per-channel blobs, channel and rules identity vectors, and the merged view a subscribed device must assemble, generated by the Go reference and asserted by the Go engine, the Windows regression harness, and Mac and Android unit tests. See the corpus README's "The sync-rules set" section. Outstanding: - macOS, Android, and iOS generators. Blocked on tooling, not design. - Quoted and JSON token input vectors. - Connection-file parsing: JSON and Key: value forms, version marker rejection, and private-authority validation failures. - Desktop merge fixtures for timestamps, blank metadata, pins, templates, manual order, tombstones, and 90-day boundaries. The corpus carries tombstone *blobs* but does not yet assert merge *outcomes* across clients. - Explicit tests and release notes for known desktop/mobile divergence. ### 14.2 Negative and resource tests - Short container, unsupported version, empty/misaligned ciphertext, bad padding behind a valid/invalid MAC, corrupt gzip, invalid UTF-8, deep JSON, oversized download, decompression bomb, excessive entry count, and huge text. - Fuzz container parsing, model normalization, porcelain escaping, and IPC framing. - Ensure unauthenticated ciphertext never reaches AES/gzip processing. ### 14.3 Synchronization and concurrency - Two writers against an existing revision retain both operations. - Simultaneous initial creation proves no lost operation under the conditional branch. There is no second branch to test. - Conflict retries preserve new entry ID and mutation timestamp. - Concurrent delete beats touch and does not resurrect content. - Duplicate modes match desktop behavior exactly. - 401, 403, 404, 409, 412, 413, timeout, TLS, and 5xx mappings. ### 14.4 CLI and accessibility - Byte-exact put/get UTF-8 round trips, including whitespace, CRLF, LF, NUL, and no-final-newline cases. - --newline behavior with and without an existing LF. - stdout contains payload only; terminal prompts never enter a pipe. - JSON schemas and porcelain escapes are golden-tested. - Ambiguous selectors and one-entry delete selection. - History/template/all indices and pinned-first ordering. - Line renderer screen-reader transcript tests. - TUI cursor tracking, focus, terminal restoration, and panic recovery. ### 14.5 Filesystem, daemon, and platform — daemon items deferred - Unix ownership/mode/symlink attacks and Windows ACL/reparse-point attacks. - Alternate configs create distinct daemon endpoints. - Mismatched overrides bypass the daemon. - Queue-before-cache acknowledgement crash points and idempotent recovery. - Offline put/delete, later merge/upload, pending status, and password rotation. - A delete pending beyond 90 days receives one durable replay timestamp and still converges without changing ID or selection. - Lost daemon response does not duplicate a keep operation. - Windows named-pipe peer restrictions and Console/ConPTY behavior. ### 14.6 Build and documentation - Execute all build targets in CI. - Verify staged builds contain no stale artifacts. - Generate and verify deterministic SHA256SUMS twice. - Lint Markdown fences and links. - Compare built-in help, manual, man page, and command metadata for drift. --- ## 15. Phased implementation plan ### Phase 0 — decision closure and fixtures — **partly done** - ~~Resolve conditional initial creation.~~ Done: server supports If-None-Match: *; see section 2.3. - **Outstanding:** freeze golden identity, codec, merge, and tombstone fixtures generated by the Windows, macOS, Android, and iOS clients. - ~~Record approved desktop exceptions and mobile divergence.~~ Recorded in sections 9.1 and 9.3. ### Phase 1 — portable format and platform core — **done** Model, codec, identity, normalization, limits; Unix and Windows protected config paths and DPAPI secret protection; HTTP client with redaction, limits, timeouts, and status mapping. TLS trust for private authorities (section 3.4) was added here after the fact. ### Phase 2 — stateless CLI — **done** init get put list rm sync status, selectors, kind filters, output contracts, merge, optimistic uploads, and conditional initial create. Outstanding from this phase's original exit gate: cross-client live-server interoperability and concurrency tests. Go unit tests pass; nothing exercises a real server against a real desktop client. ### Phase 3 — accessible interaction — **done, reduced scope** Line menu and pick, controlling-terminal I/O separation, template/history switching, single-entry delete selection. Search, paging, multiline new-entry input, and later reading, saving, and running a clip were all subsequently built. Nothing from this phase remains deferred: the save-to-file affordance shipped as w, and the pipe-to-command one as x with no shell (section 8.4). See section 8.1. ### Phase 4 — TUI — **done** tcell renderer with physical-cursor tracking, kind filters, preview, and actions; explicit renderer selection without screen-reader detection; renderer = "tui" accepted in config; --renderer/--tui/--line added. The shared formatting seam was extracted in Phase 3, which is what made the second renderer cheap. Exit gate met for simulation and terminal restoration: 21 SimulationScreen tests assert cursor position after navigation, cursor visibility, complete row text, focus order, payload-only stdout, and exactly one Fini on both normal exit and panic. All seven targets still cross-build with CGO_ENABLED=0, and no dependency in the graph uses cgo. Outstanding from the gate: the renderer has not been exercised on a real Linux terminal or Windows console, only against a simulation screen. ### Phase 5 — daemon and offline synchronization — **not started** - Per-account Unix socket and Windows IPC. - Peer authorization and versioned request protocol. - Protected cache, pending queue, idempotent request IDs, offline mutation, freshness polling, crash recovery, and enhanced status. - Foreground/background lifecycle and service documentation. - Serializable operation intents replacing today's closures (section 10.2). Exit gate: crash-point, offline convergence, multi-account, and lost-response tests pass. ### Phase 6 — packaging and documentation — **partly done** - ~~Bash and PowerShell build pipelines.~~ Done, seven targets. - ~~HTML manual, man page, built-in help.~~ Done and currently in sync. - **Outstanding:** supported-target smoke tests, deterministic checksum verification, CI execution of all build targets, help/manual drift checks, and installation packages. ### Recommended next steps Ordered by value per unit of risk, for whoever picks this up next: 1. **Fixture corpus** (Phase 0 remainder). It is the one gap that can silently break cross-device sync, and every later phase is safer behind it. 2. ~~**Narrow the exit-code default** in mapRuntimeError from 4 to 1 (section 7.2).~~ Done; see section 7.2. 3. ~~**Split main.go** into internal/output and internal/ui/line.~~ Done; it is what made the second renderer cheap. 4. ~~**Menu completeness** (section 8.1): search and paging.~~ Done, along with multiline add. Save-to-file remains deferred. 5. **CI** for build targets and checksums (Phase 6 remainder). 6. **Phase 4 or Phase 5**, whichever the users are actually asking for. They are independent; neither blocks the other. Phases 2 and 3 are independently useful without a daemon, which is what ships today. --- ## 16. Documentation deliverables One command metadata source drives built-in help and reference tables. Narrative Markdown drives the accessible HTML manual and man page. CI detects drift rather than relying on manual synchronization. Required artifacts: 1. ClipmanCli/Manual.html, matching project visual style while meeting semantic-heading, landmark, keyboard, contrast, zoom, and focus requirements. 2. clipman-cli.1 with NAME, SYNOPSIS, DESCRIPTION, COMMANDS, OPTIONS, ENVIRONMENT, FILES, EXIT STATUS, EXAMPLES, SECURITY, and SEE ALSO. 3. Complete --help and per-command help. Commands and output are real selectable pre/code text, never terminal screenshots. The manual must explain: - that a nonblank history password is required, and that the server stores only blobs it cannot read; - the inability to validate a password for a missing bucket; - conditional initial create, which carries no residual race; - Windows DPAPI versus Unix file-permission secret storage; - trusting a privately issued server certificate, and what --insecure gives up; - template filters, variable resolution, --raw, and index scoping; - process-list exposure of secret-bearing flags; and - exact exit-code handling. Once the daemon ships, add offline success and pending synchronization. Until then the manual must not imply that any command works without the server. --- ## 17. Corrected example bank ### 17.1 Put and get ~~~sh printf '%s' 'kubectl rollout restart deploy/api -n prod' | clipman-cli put clipman-cli put --file ./deploy-runbook.md --name deploy-runbook --group ops clipman-cli put --text 'Hello' --template --name greeting clipman-cli get clipman-cli get 3 > snippet.txt clipman-cli get --name deploy-runbook clipman-cli get --search kubectl --first clipman-cli get --kind templates --name greeting clipman-cli get -n > with-final-lf.txt ~~~ ### 17.2 Browse and select ~~~sh clipman-cli list clipman-cli list --kind templates clipman-cli list --kind all --search postgres clipman-cli list --porcelain clipman-cli pick | ssh web-02 'cat > /tmp/snippet' ~~~ ### 17.3 Delete one entry ~~~sh clipman-cli rm 5 clipman-cli rm --id 0123456789abcdef0123456789abcdef --yes clipman-cli rm --search 'old token' ~~~ If the search has several matches, the last command opens a one-choice selector. In a script, use the selected ID and --yes. ### 17.4 Correct exit-code handling ~~~sh set +e text=$(clipman-cli get --name build-flag 2>/dev/null) rc=$? set -e case "$rc" in 0) printf 'flag is: %s\n' "$text" ;; 6) printf '%s\n' 'no build-flag clip yet' ;; *) printf 'clipman failed with exit %s\n' "$rc" >&2; exit "$rc" ;; esac ~~~ Do not pipe a clip into source /dev/stdin expecting it to modify the calling shell; a pipeline normally runs that side in a subshell. If changing the current shell is truly intended, review the clip and source a protected temporary file explicitly. ### 17.5 Non-interactive initialization ~~~sh CLIPMAN_TOKEN='token' CLIPMAN_PASSWORD='password' \ clipman-cli --server 'https://clipman.example:52731' init \ --non-interactive --save-password config ~~~ A password is always required; there is no passwordless form. From a connection file, which also carries the server's private certificate authority when it has one: ~~~sh CLIPMAN_PASSWORD='password' \ clipman-cli init --non-interactive \ --connection-file ~/clipman-server-connection.clpconf ~~~ With a separately obtained CA certificate: ~~~sh CLIPMAN_TOKEN='token' CLIPMAN_PASSWORD='password' \ clipman-cli --ca-cert ./clipman-ca.pem \ --server 'https://clipman.example:52731' init --non-interactive ~~~ ### 17.6 Offline daemon workflow — deferred Not available. Every command reaches the server; there is no local queue, and a mutation attempted offline fails with exit code 4 rather than committing locally. The intended workflow once section 10.5 ships: ~~~sh clipman-cli daemon start printf '%s' 'queued while offline' | clipman-cli put clipman-cli status clipman-cli sync ~~~ The put would succeed after durable local commit, and status would show it as pending until a server state containing the operation is confirmed. --- ## 18. Deferred items Deferred but planned, with a design in this document: - The daemon, local cache, pending queue, and offline writes (sections 10.5–10.7). - Line-renderer save-to-file (section 8.1). - The cross-client fixture corpus (section 14.1). Deferred with no current plan to build: - Native binary/base64 clip types. - File-list history and secrets database. The inherited file_history model and codec are unused; see section 11. - Desktop clipboard monitoring or bridging. - Bulk deletion by search. - Arbitrary pipe-to-shell actions inside the interactive UI. - Server administration and backup restoration. - OS secret stores (Secret Service, Credential Manager), unless one can be added without cgo. Withdrawn — passwordless operation, in every form: CLIPDB1 writes, the empty-password bucket, --allow-passwordless, and password_mode = "passwordless". Clipman Server requires a nonblank history password. Shipped since the original draft, formerly deferred: macOS and Windows ARM64 release artifacts. Any future schema extension must preserve unknown fields and add cross-client fixtures before the CLI writes it.