Make SSM copy use host path syntax

ober

6aaed8fd95674584cf0448faa0f24386cdc29ff2

diff --git a/README.md b/README.md
index 3fbd0f7..664fada 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,9 @@ PSSM strips remote terminal controls in human output, supports
 `--literal-controls` for visible `\xNN` rendering, and honors `--no-color`.
 Use `--json` when lossless remote output is required.
 
-`jerboa-aws ssmcp` and `jerboa-aws ssm cp` recursively upload a local file or
-directory through SSM RunCommand using tar/base64 chunks. Targets must already
+`jerboa-aws ssmcp` and `jerboa-aws ssm cp` recursively copy with
+`host:path` syntax: `host:~/save ./foo` downloads and `./foo host:/tmp/foo`
+uploads through SSM RunCommand using tar/base64 chunks. Targets must already
 have the SSM agent plus `sh`, `tar`, `base64`, `mkdir`, and `find`.
 
 Credential handling, logging expectations, and release requirements are
diff --git a/src/jerboa-aws/cli/main.ss b/src/jerboa-aws/cli/main.ss
index 1a0d4ca..521a787 100644
--- a/src/jerboa-aws/cli/main.ss
+++ b/src/jerboa-aws/cli/main.ss
@@ -233,7 +233,7 @@ Services:
   sts          STS identity and session operations
   iam          IAM users, groups, roles, policies
   ssm          Systems Manager parameters and commands
-  ssmcp        Recursive upload over SSM SendCommand
+  ssmcp        Recursive copy over SSM SendCommand
 
 Global options:
   --profile, -p NAME     AWS profile name
@@ -778,8 +778,10 @@ For low-level API commands use 's3api':
   ;; ---- SSM subcommands ----
 
   (define (ssm-dispatch action args profile region output-fmt)
-    (let ((client (make-ssm-client profile region)))
-      (cond
+    (if (string=? action "cp")
+      (apply ssmcp-main (args-with-profile profile args))
+      (let ((client (make-ssm-client profile region)))
+        (cond
         ((string=? action "put-parameter")
          (let ((name (or (get-opt args "--name")
                          (error 'ssm "put-parameter requires --name")))
@@ -823,8 +825,6 @@ For low-level API commands use 's3api':
                'document-name: doc
                'comment: comment)
              output-fmt)))
-        ((string=? action "cp")
-         (apply ssmcp-main (args-with-profile profile args)))
         ((string=? action "get-command-invocation")
          (let ((cmd-id (or (get-opt args "--command-id")
                            (error 'ssm "get-command-invocation requires --command-id")))
@@ -841,11 +841,11 @@ For low-level API commands use 's3api':
   delete-parameter             --name NAME
   describe-instance-information
   send-command                 --instance-ids IDS --command CMD [--document-name DOC] [--comment TEXT]
-  cp                           [options] <pattern> <local-src> <remote-dest-dir>
+  cp                           [options] HOST:PATH LOCAL | LOCAL HOST:PATH
   get-command-invocation       --command-id ID --instance-id ID
 
-For parallel SSM execution, use the 'pssm' command. For recursive SSM upload,
-use 'jerboa-aws ssm cp' or 'jerboa-aws ssmcp'.
+For parallel SSM execution, use the 'pssm' command. For single-host recursive
+SSM copy, use 'jerboa-aws ssm cp' or 'jerboa-aws ssmcp'.
 
 For SecureString values, --value-stdin is strongly recommended. Input is read
 exactly (including a trailing newline) and is limited to 8192 UTF-8 bytes.
@@ -853,7 +853,7 @@ exactly (including a trailing newline) and is limited to 8192 UTF-8 bytes.
          (exit 0))
         (else
          (display (format "Unknown ssm action: ~a\nRun 'jerboa-aws ssm help' for available commands.\n" action))
-         (exit 1)))))
+         (exit 1))))))
 
   ;; ---- Main entry point ----
 
diff --git a/src/jerboa-aws/ssmcp.ss b/src/jerboa-aws/ssmcp.ss
index 246058b..a268fe1 100644
--- a/src/jerboa-aws/ssmcp.ss
+++ b/src/jerboa-aws/ssmcp.ss
@@ -1,12 +1,15 @@
-;;; (jerboa-aws ssmcp) -- recursive upload over AWS SSM SendCommand
+;;; (jerboa-aws ssmcp) -- recursive file copy over AWS SSM SendCommand
 
 (export ssmcp-main
         parse-ssmcp-args
         ssmcp-shell-quote)
 (import (chezscheme)
-        (jerboa-aws pssm))
+        (jerboa-aws json)
+        (jerboa-aws ssm api)
+        (jerboa-aws ssm operations))
 
 (define SSMCP-CHUNK-SIZE 6000)
+(define SSMCP-B64-PREFIX "__JERBOA_SSMCP_B64__")
 
 (define (cfg-ref cfg key . default)
   (hashtable-ref cfg key (if (pair? default) (car default) #f)))
@@ -24,6 +27,12 @@
   (newline (current-error-port))
   (exit 1))
 
+(define (condition->string e)
+  (if (condition? e)
+    (call-with-string-output-port
+      (lambda (p) (display-condition e p)))
+    (format "~a" e)))
+
 (define (ssmcp-shell-quote text)
   (let ([out (open-output-string)])
     (put-char out #\')
@@ -43,6 +52,25 @@
       [(char=? (string-ref text i) target) i]
       [else (loop (- i 1))])))
 
+(define (string-index text target)
+  (let ([len (string-length text)])
+    (let loop ([i 0])
+      (cond
+        [(= i len) #f]
+        [(char=? (string-ref text i) target) i]
+        [else (loop (+ i 1))]))))
+
+(define (string-prefix? prefix text)
+  (let ([n (string-length prefix)])
+    (and (<= n (string-length text))
+         (string=? prefix (substring text 0 n)))))
+
+(define (string-suffix? suffix text)
+  (let ([n (string-length suffix)]
+        [m (string-length text)])
+    (and (<= n m)
+         (string=? suffix (substring text (- m n) m)))))
+
 (define (trim-trailing-slashes path)
   (let loop ([n (string-length path)])
     (cond
@@ -67,10 +95,27 @@
       (substring clean (+ slash 1) (string-length clean))
       clean)))
 
+(define (local-expand-path path)
+  (cond
+    [(string=? path "~") (getenv "HOME")]
+    [(string-prefix? "~/" path)
+     (string-append (getenv "HOME") (substring path 1 (string-length path)))]
+    [else path]))
+
+(define (local-dest-directory? path)
+  (let ([p (local-expand-path path)])
+    (or (string-suffix? "/" p)
+        (and (file-exists? p) (file-directory? p)))))
+
 (define (read-file-string path)
   (call-with-input-file path
     (lambda (port) (get-string-all port))))
 
+(define (write-file-string path text)
+  (call-with-output-file path
+    (lambda (port) (put-string port text))
+    'replace))
+
 (define (delete-file/quiet path)
   (guard (e [#t #f])
     (delete-file path)))
@@ -82,13 +127,155 @@
   (let ([t (current-time 'time-utc)])
     (format "~a-~a" (time-second t) (time-nanosecond t))))
 
-(define (local-payload-path)
-  (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64"
+(define (tmp-path suffix)
+  (format "/tmp/jerboa-aws-ssmcp-~a-~a.~a"
     (get-process-id)
-    (current-time-token)))
+    (current-time-token)
+    suffix))
+
+(define (parse-remote-spec operand)
+  (let ([colon (string-index operand #\:)])
+    (and colon
+         (> colon 0)
+         (< (+ colon 1) (string-length operand))
+         (cons (substring operand 0 colon)
+               (substring operand (+ colon 1) (string-length operand))))))
+
+(define (remote-path-shell path)
+  (cond
+    [(string=? path "~") "$HOME"]
+    [(string-prefix? "~/" path)
+     (string-append "$HOME/"
+       (ssmcp-shell-quote (substring path 2 (string-length path))))]
+    [else (ssmcp-shell-quote path)]))
+
+(define (remote-dir-shell path)
+  (remote-path-shell (path-dirname path)))
+
+(define (remote-base-shell path)
+  (ssmcp-shell-quote (path-basename path)))
+
+(define (default-cache-file)
+  (string-append (getenv "HOME") "/.aws-ec2-cache.json"))
+
+(define (ht-ref ht key . default)
+  (if (hashtable? ht)
+    (guard (e [#t (if (pair? default) (car default) #f)])
+      (hashtable-ref ht key (if (pair? default) (car default) #f)))
+    (if (pair? default) (car default) #f)))
+
+(define (ht-ref/str ht key)
+  (let ([v (ht-ref ht key "")])
+    (if (string? v) v "")))
 
-(define (local-tar-command source output-path)
-  (let ([src (trim-trailing-slashes source)])
+(define (load-instance-cache path)
+  (guard (e [#t (ssmcp-error "cannot read cache: ~a\nHint: run pssm --refresh or provide --cache" path)])
+    (let ([data (call-with-input-file path read-json)])
+      (filter (lambda (inst)
+                (string=? (ht-ref/str inst "state") "running"))
+        (cond
+          [(list? data) data]
+          [(vector? data) (vector->list data)]
+          [else '()])))))
+
+(define (host-exact-match? host inst)
+  (or (string=? host (ht-ref/str inst "name"))
+      (string=? host (ht-ref/str inst "fqdn"))
+      (string=? host (ht-ref/str inst "host"))
+      (string=? host (ht-ref/str inst "private_ip"))
+      (string=? host (ht-ref/str inst "public_ip"))
+      (string=? host (ht-ref/str inst "instance_id"))))
+
+(define (glob-match? pattern text)
+  (let match ([pat (string->list pattern)] [str (string->list text)])
+    (cond
+      [(and (null? pat) (null? str)) #t]
+      [(null? pat) #f]
+      [(char=? (car pat) #\*)
+       (or (and (null? (cdr pat)) #t)
+           (match (cdr pat) str)
+           (and (pair? str) (match pat (cdr str))))]
+      [(null? str) #f]
+      [(char=? (char-downcase (car pat)) (char-downcase (car str)))
+       (match (cdr pat) (cdr str))]
+      [else #f])))
+
+(define (host-glob-match? host inst)
+  (glob-match? host (ht-ref/str inst "name")))
+
+(define (resolve-single-host host cache-file)
+  (let* ([instances (load-instance-cache cache-file)]
+         [exact (filter (lambda (inst) (host-exact-match? host inst)) instances)]
+         [matches (if (pair? exact)
+                    exact
+                    (filter (lambda (inst) (host-glob-match? host inst)) instances))])
+    (cond
+      [(null? matches) (ssmcp-error "no running cached host matches: ~a" host)]
+      [(pair? (cdr matches))
+       (display "ssmcp: host matched multiple cached instances:\n" (current-error-port))
+       (for-each
+         (lambda (inst)
+           (fprintf (current-error-port) "  ~a  ~a  ~a\n"
+             (ht-ref/str inst "name")
+             (ht-ref/str inst "instance_id")
+             (ht-ref/str inst "region")))
+         matches)
+       (ssmcp-error "ambiguous host: ~a" host)]
+      [else (car matches)])))
+
+(define (current-seconds)
+  (let ([t (current-time 'time-utc)])
+    (+ (time-second t)
+       (/ (time-nanosecond t) 1000000000.0))))
+
+(define (sleep-seconds n)
+  (let ([secs (exact (floor n))]
+        [nsecs (exact (floor (* (- n (floor n)) 1000000000)))])
+    (sleep (make-time 'time-duration nsecs secs))))
+
+(define (make-ssm-client inst profile)
+  (apply SSMClient
+    (append
+      (list 'region: (ht-ref/str inst "region"))
+      (if profile (list 'profile: profile) '()))))
+
+(define (complete-status? status)
+  (member status '("Success" "Failed" "Cancelled" "TimedOut")))
+
+(define (run-single-ssm inst config command)
+  (let* ([client (make-ssm-client inst (cfg-ref config "profile"))]
+         [inst-id (ht-ref/str inst "instance_id")]
+         [timeout (cfg-ref config "timeout")]
+         [poll-interval (cfg-ref config "poll-interval")]
+         [start (current-seconds)]
+         [resp (send-command client (list inst-id) command
+                 'document-name: "AWS-RunShellScript")]
+         [cmd (ht-ref resp "Command")]
+         [cmd-id (if cmd (ht-ref/str cmd "CommandId") "")])
+    (when (string=? cmd-id "")
+      (ssmcp-error "SSM SendCommand returned no command id"))
+    (let loop ()
+      (when (> (- (current-seconds) start) timeout)
+        (ssmcp-error "SSM command timed out on ~a" (ht-ref/str inst "name")))
+      (sleep-seconds poll-interval)
+      (guard (e [#t (loop)])
+        (let* ([out (get-command-invocation client cmd-id inst-id)]
+               [status (ht-ref/str out "Status")])
+          (if (complete-status? status)
+            (let ([stdout (or (ht-ref out "StandardOutputContent") "")]
+                  [stderr (or (ht-ref out "StandardErrorContent") "")]
+                  [code (or (ht-ref out "ResponseCode") -1)])
+              (unless (and (string=? status "Success") (= code 0))
+                (ssmcp-error "remote command failed on ~a: ~a~a~a"
+                  (ht-ref/str inst "name")
+                  status
+                  (if (string=? stderr "") "" ": ")
+                  stderr))
+              stdout)
+            (loop)))))))
+
+(define (local-package-command source output-path)
+  (let ([src (trim-trailing-slashes (local-expand-path source))])
     (if (file-directory? src)
       (format "tar -C ~a -czf - . | base64 | tr -d '\\n' > ~a"
         (ssmcp-shell-quote src)
@@ -99,17 +286,18 @@
         (ssmcp-shell-quote output-path)))))
 
 (define (make-local-payload source)
-  (unless (file-exists? source)
-    (ssmcp-error "source does not exist: ~a" source))
-  (let ([payload-path (local-payload-path)])
-    (delete-file/quiet payload-path)
-    (let ([command (local-tar-command source payload-path)])
-      (unless (system-ok? command)
-        (delete-file/quiet payload-path)
-        (ssmcp-error "failed to package source with tar/base64: ~a" source)))
-    (let ([payload (read-file-string payload-path)])
+  (let ([src (local-expand-path source)])
+    (unless (file-exists? src)
+      (ssmcp-error "local source does not exist: ~a" source))
+    (let ([payload-path (tmp-path "b64")])
       (delete-file/quiet payload-path)
-      payload)))
+      (let ([command (local-package-command src payload-path)])
+        (unless (system-ok? command)
+          (delete-file/quiet payload-path)
+          (ssmcp-error "failed to package local source: ~a" source)))
+      (let ([payload (read-file-string payload-path)])
+        (delete-file/quiet payload-path)
+        payload))))
 
 (define (split-string text chunk-size)
   (let ([len (string-length text)])
@@ -119,56 +307,170 @@
         (let ([end (min len (+ start chunk-size))])
           (loop end (cons (substring text start end) chunks)))))))
 
-(define (remote-payload-path)
-  (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64"
-    (get-process-id)
-    (current-time-token)))
-
-(define (remote-init-command remote-tmp)
+(define (remote-upload-init-command remote-tmp)
   (let ([qtmp (ssmcp-shell-quote remote-tmp)])
     (format "set -eu; rm -f ~a; : > ~a; chmod 600 ~a" qtmp qtmp qtmp)))
 
-(define (remote-append-command remote-tmp chunk)
+(define (remote-upload-append-command remote-tmp chunk)
   (format "printf %s ~a >> ~a"
     (ssmcp-shell-quote chunk)
     (ssmcp-shell-quote remote-tmp)))
 
-(define (remote-final-command remote-tmp remote-dest delete?)
-  (let ([qtmp (ssmcp-shell-quote remote-tmp)]
-        [qdest (ssmcp-shell-quote remote-dest)])
+(define (remote-upload-final-command remote-tmp local-source remote-dest delete?)
+  (let* ([qtmp (ssmcp-shell-quote remote-tmp)]
+         [qdest (remote-path-shell remote-dest)]
+         [qparent (remote-dir-shell remote-dest)]
+         [qbase (remote-base-shell remote-dest)]
+         [local-dir? (file-directory? (local-expand-path local-source))])
+    (if local-dir?
+      (string-append
+        "set -eu; mkdir -p " qdest "; "
+        (if delete?
+          (string-append "find " qdest " -mindepth 1 -maxdepth 1 -exec rm -rf {} +; ")
+          "")
+        "base64 -d " qtmp " | tar -xzf - -C " qdest "; rm -f " qtmp)
+      (string-append
+        "set -eu; if [ -d " qdest " ]; then target=" qdest "; "
+        "else mkdir -p " qparent "; target=$(mktemp -d /tmp/jerboa-aws-ssmcp.XXXXXX); fi; "
+        "base64 -d " qtmp " | tar -xzf - -C $target; "
+        "if [ \"$target\" != " qdest " ]; then mv -f $target/" (remote-base-shell local-source) " " qdest "; rmdir $target; fi; "
+        "rm -f " qtmp))))
+
+(define (remote-download-prepare-command remote-source remote-tmp)
+  (let ([qsrc (remote-path-shell remote-source)]
+        [qdir (remote-dir-shell remote-source)]
+        [qbase (remote-base-shell remote-source)]
+        [qtmp (ssmcp-shell-quote remote-tmp)])
     (string-append
-      "set -eu; mkdir -p " qdest "; "
-      (if delete?
-        (string-append "find " qdest " -mindepth 1 -maxdepth 1 -exec rm -rf {} +; ")
-        "")
-      "base64 -d " qtmp " | tar -xzf - -C " qdest "; "
-      "rm -f " qtmp)))
-
-(define (pssm-args config command dry-run?)
-  (append
-    (if (cfg-ref config "profile")
-      (list "--profile" (cfg-ref/str config "profile"))
-      '())
-    (if (cfg-ref config "timeout")
-      (list "--timeout" (number->string (cfg-ref config "timeout")))
-      '())
-    (if (cfg-ref config "poll-interval")
-      (list "--poll" (number->string (cfg-ref config "poll-interval")))
-      '())
-    (if (cfg-ref config "cache-file")
-      (list "--cache" (cfg-ref/str config "cache-file"))
-      '())
-    (if (cfg-ref config "verbose") '("--verbose") '())
-    (if (cfg-ref config "no-color") '("--no-color") '())
-    (if (cfg-ref config "literal-controls") '("--literal-controls") '())
-    (if dry-run? '("--dry-run") '())
-    (list (cfg-ref/str config "pattern") command)))
-
-(define (run-pssm config command)
-  (apply pssm-main (pssm-args config command #f)))
-
-(define (run-pssm-dry-run config command)
-  (apply pssm-main (pssm-args config command #t)))
+      "set -eu; rm -f " qtmp "; "
+      "if [ -d " qsrc " ]; then "
+      "printf 'D\\n'; tar -C " qsrc " -czf - . | base64 | tr -d '\\n' > " qtmp "; "
+      "else printf 'F:%s\\n' " qbase "; "
+      "tar -C " qdir " -czf - " qbase " | base64 | tr -d '\\n' > " qtmp "; fi; "
+      "wc -c < " qtmp)))
+
+(define (remote-download-chunk-command remote-tmp chunk-size index)
+  (format "dd if=~a bs=~a skip=~a count=1 2>/dev/null"
+    (ssmcp-shell-quote remote-tmp)
+    chunk-size
+    index))
+
+(define (remote-rm-command path)
+  (format "rm -f ~a" (ssmcp-shell-quote path)))
+
+(define (first-line text)
+  (let ([nl (string-index text #\newline)])
+    (if nl (substring text 0 nl) text)))
+
+(define (second-line text)
+  (let ([nl (string-index text #\newline)])
+    (if nl
+      (let ([rest (substring text (+ nl 1) (string-length text))])
+        (first-line rest))
+      "")))
+
+(define (parse-kind line)
+  (cond
+    [(string=? line "D") 'directory]
+    [(string-prefix? "F:" line) 'file]
+    [else (ssmcp-error "remote packaging returned invalid header: ~a" line)]))
+
+(define (parse-size text)
+  (let ([line (second-line text)])
+    (parse-number-option "remote payload size" line)))
+
+(define (extract-local-payload payload-path kind file-name destination delete?)
+  (let* ([dest (local-expand-path destination)]
+         [decode (format "(base64 -d < ~a || base64 -D < ~a)"
+                   (ssmcp-shell-quote payload-path)
+                   (ssmcp-shell-quote payload-path))])
+    (if (eq? kind 'directory)
+      (let ([cmd (string-append
+                   "set -eu; mkdir -p " (ssmcp-shell-quote dest) "; "
+                   (if delete?
+                     (string-append "find " (ssmcp-shell-quote dest)
+                       " -mindepth 1 -maxdepth 1 -exec rm -rf {} +; ")
+                     "")
+                   decode " | tar -xzf - -C " (ssmcp-shell-quote dest))])
+        (unless (system-ok? cmd)
+          (ssmcp-error "failed to extract remote directory into: ~a" destination)))
+      (if (local-dest-directory? dest)
+        (let ([cmd (string-append
+                     "set -eu; mkdir -p " (ssmcp-shell-quote dest) "; "
+                     decode " | tar -xzf - -C " (ssmcp-shell-quote dest))])
+          (unless (system-ok? cmd)
+            (ssmcp-error "failed to extract remote file into: ~a" destination)))
+        (let ([tmpdir (tmp-path "dir")]
+              [parent (path-dirname dest)])
+          (let ([cmd (string-append
+                       "set -eu; rm -rf " (ssmcp-shell-quote tmpdir) "; "
+                       "mkdir -p " (ssmcp-shell-quote tmpdir) " " (ssmcp-shell-quote parent) "; "
+                       decode " | tar -xzf - -C " (ssmcp-shell-quote tmpdir) "; "
+                       "mv -f " (ssmcp-shell-quote (string-append tmpdir "/" file-name))
+                       " " (ssmcp-shell-quote dest) "; "
+                       "rmdir " (ssmcp-shell-quote tmpdir))])
+            (unless (system-ok? cmd)
+              (ssmcp-error "failed to extract remote file into: ~a" destination))))))))
+
+(define (upload-file inst config local-source remote-dest)
+  (let* ([payload (make-local-payload local-source)]
+         [chunks (split-string payload (cfg-ref config "chunk-size"))]
+         [remote-tmp (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64"
+                       (get-process-id) (current-time-token))])
+    (display (format "ssmcp: uploading ~a to ~a:~a (~a chunk~a)\n"
+              local-source
+              (ht-ref/str inst "name")
+              remote-dest
+              (length chunks)
+              (if (= (length chunks) 1) "" "s")))
+    (run-single-ssm inst config (remote-upload-init-command remote-tmp))
+    (for-each
+      (lambda (chunk)
+        (run-single-ssm inst config (remote-upload-append-command remote-tmp chunk)))
+      chunks)
+    (run-single-ssm inst config
+      (remote-upload-final-command remote-tmp local-source remote-dest (cfg-ref config "delete")))
+    (display "ssmcp: upload complete\n")))
+
+(define (download-file inst config remote-source local-dest)
+  (let* ([remote-tmp (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64"
+                       (get-process-id) (current-time-token))]
+         [header (run-single-ssm inst config
+                   (remote-download-prepare-command remote-source remote-tmp))]
+         [kind (parse-kind (first-line header))]
+         [file-name (if (eq? kind 'file) (path-basename remote-source) "")]
+         [size (parse-size header)]
+         [chunk-size (cfg-ref config "chunk-size")]
+         [chunks (+ (quotient size chunk-size)
+                    (if (> (remainder size chunk-size) 0) 1 0))]
+         [payload-path (tmp-path "download.b64")])
+    (display (format "ssmcp: downloading ~a:~a to ~a (~a chunk~a)\n"
+              (ht-ref/str inst "name")
+              remote-source
+              local-dest
+              chunks
+              (if (= chunks 1) "" "s")))
+    (delete-file/quiet payload-path)
+    (let loop ([i 0] [parts '()])
+      (if (= i chunks)
+        (write-file-string payload-path (apply string-append (reverse parts)))
+        (loop (+ i 1)
+          (cons (run-single-ssm inst config
+                  (remote-download-chunk-command remote-tmp chunk-size i))
+                parts))))
+    (guard (e [#t #f])
+      (run-single-ssm inst config (remote-rm-command remote-tmp)))
+    (extract-local-payload payload-path kind file-name local-dest (cfg-ref config "delete"))
+    (delete-file/quiet payload-path)
+    (display "ssmcp: download complete\n")))
+
+(define (parse-number-option name value)
+  (unless (decimal-digits? value)
+    (ssmcp-error "~a requires a positive decimal integer" name))
+  (let ([n (decimal-string->integer value)])
+    (unless (> n 0)
+      (ssmcp-error "~a requires a positive decimal integer" name))
+    n))
 
 (define (decimal-digits? text)
   (and (> (string-length text) 0)
@@ -187,30 +489,20 @@
                       (char->integer #\0))])
         (loop (+ i 1) (+ (* n 10) digit))))))
 
-(define (parse-number-option name value)
-  (unless (decimal-digits? value)
-    (ssmcp-error "~a requires a positive decimal integer" name))
-  (let ([n (decimal-string->integer value)])
-    (unless (> n 0)
-      (ssmcp-error "~a requires a positive decimal integer" name))
-    n))
-
 (define (parse-ssmcp-args args)
   (let ([config (make-hashtable string-hash string=?)])
     (put-cfg! config "timeout" 300)
     (put-cfg! config "poll-interval" 2)
     (put-cfg! config "chunk-size" SSMCP-CHUNK-SIZE)
+    (put-cfg! config "cache-file" (default-cache-file))
     (let loop ([rest args] [positional '()])
       (cond
         [(null? rest)
          (let ([pos (reverse positional)])
-           (when (pair? pos)
-             (put-cfg! config "pattern" (car pos)))
+           (when (pair? pos) (put-cfg! config "source" (car pos)))
            (when (and (pair? pos) (pair? (cdr pos)))
-             (put-cfg! config "source" (cadr pos)))
-           (when (and (pair? pos) (pair? (cdr pos)) (pair? (cddr pos)))
-             (put-cfg! config "destination" (caddr pos)))
-           (put-cfg! config "extra-positional" (if (> (length pos) 3) (cdddr pos) '()))
+             (put-cfg! config "destination" (cadr pos)))
+           (put-cfg! config "extra-positional" (if (> (length pos) 2) (cddr pos) '()))
            config)]
         [(or (string=? (car rest) "--help") (string=? (car rest) "-h"))
          (put-cfg! config "help" #t)
@@ -224,12 +516,6 @@
         [(or (string=? (car rest) "--verbose") (string=? (car rest) "-v"))
          (put-cfg! config "verbose" #t)
          (loop (cdr rest) positional)]
-        [(string=? (car rest) "--no-color")
-         (put-cfg! config "no-color" #t)
-         (loop (cdr rest) positional)]
-        [(string=? (car rest) "--literal-controls")
-         (put-cfg! config "literal-controls" #t)
-         (loop (cdr rest) positional)]
         [(and (or (string=? (car rest) "--profile") (string=? (car rest) "-p"))
               (pair? (cdr rest)))
          (put-cfg! config "profile" (cadr rest))
@@ -251,30 +537,29 @@
          (loop (cdr rest) (cons (car rest) positional))]))))
 
 (define (print-ssmcp-usage)
-  (display "ssmcp - recursive upload over AWS SSM SendCommand
+  (display "ssmcp - recursive copy over AWS SSM SendCommand
 
-Usage: ssmcp [options] <pattern> <local-src> <remote-dest-dir>
+Usage:
+  ssmcp [options] <host>:<remote-path> <local-path>
+  ssmcp [options] <local-path> <host>:<remote-path>
 
-Copies a local file or directory to every cached EC2 instance whose Name tag
-matches <pattern>. The transfer uses SSM RunCommand, so targets need a running
-SSM agent and shell tools: tar, base64, mkdir, and find.
+Exactly one operand must be remote. The host is resolved from the EC2 cache and
+must match exactly one running instance. Directory copies are recursive.
 
 Examples:
-  ssmcp 'web-*' ./site /opt/site
-  ssmcp --delete 'app-*' ./config /etc/myapp
-  ssmcp -p prod --timeout 600 'batch-*' ./scripts /tmp/scripts
+  ssmcp -p prod web-1:~/save ./foo
+  ssmcp ./foo web-1:/tmp/foo
+  ssmcp --delete app.example.com:/opt/app ./app
 
 Options:
   -p, --profile NAME    AWS profile name
   -t, --timeout SECS    Per-command timeout in seconds (default: 300)
   --poll SECS           Poll interval in seconds (default: 2)
-  --cache PATH          EC2 cache file (default from pssm)
+  --cache PATH          EC2 cache file (default: ~/.aws-ec2-cache.json)
   --chunk-size N        Base64 chars per SSM command chunk (default: 6000)
-  --delete              Delete remote destination contents before extract
-  --dry-run             Show target command without sending payload
-  -v, --verbose         Verbose pssm output
-  --no-color            Disable ANSI colors
-  --literal-controls    Show remote control bytes as visible \\xNN text
+  --delete              Delete destination contents before directory extract
+  --dry-run             Resolve and show direction without copying
+  -v, --verbose         Verbose progress
   -h, --help            Show this help
 "))
 
@@ -282,15 +567,12 @@ Options:
   (when (cfg-ref config "help")
     (print-ssmcp-usage)
     (exit 0))
-  (unless (cfg-ref config "pattern")
-    (print-ssmcp-usage)
-    (ssmcp-error "missing pattern"))
   (unless (cfg-ref config "source")
     (print-ssmcp-usage)
-    (ssmcp-error "missing local source"))
+    (ssmcp-error "missing source operand"))
   (unless (cfg-ref config "destination")
     (print-ssmcp-usage)
-    (ssmcp-error "missing remote destination directory"))
+    (ssmcp-error "missing destination operand"))
   (when (pair? (cfg-ref config "extra-positional"))
     (ssmcp-error "too many positional arguments")))
 
@@ -298,23 +580,27 @@ Options:
   (let ([config (parse-ssmcp-args args)])
     (validate-ssmcp-config config)
     (let* ([source (cfg-ref/str config "source")]
-           [dest (cfg-ref/str config "destination")]
-           [remote-tmp (remote-payload-path)]
-           [chunk-size (cfg-ref config "chunk-size")])
-      (if (cfg-ref config "dry-run")
-        (begin
-          (display (format "ssmcp dry-run: would upload ~a to ~a\n" source dest))
-          (run-pssm-dry-run config (remote-final-command remote-tmp dest (cfg-ref config "delete"))))
-        (let* ([payload (make-local-payload source)]
-               [chunks (split-string payload chunk-size)])
-          (display (format "ssmcp: uploading ~a chunk~a to ~a\n"
-                    (length chunks)
-                    (if (= (length chunks) 1) "" "s")
-                    (cfg-ref/str config "pattern")))
-          (run-pssm config (remote-init-command remote-tmp))
-          (for-each
-            (lambda (chunk)
-              (run-pssm config (remote-append-command remote-tmp chunk)))
-            chunks)
-          (run-pssm config (remote-final-command remote-tmp dest (cfg-ref config "delete")))
-          (display "ssmcp: done\n"))))))
+           [destination (cfg-ref/str config "destination")]
+           [source-remote (parse-remote-spec source)]
+           [dest-remote (parse-remote-spec destination)])
+      (cond
+        [(and source-remote dest-remote)
+         (ssmcp-error "remote-to-remote copy is not supported: ~a ~a" source destination)]
+        [(not (or source-remote dest-remote))
+         (ssmcp-error "one operand must be remote, in host:path form")]
+        [source-remote
+         (let* ([host (car source-remote)]
+                [remote-path (cdr source-remote)]
+                [inst (resolve-single-host host (cfg-ref/str config "cache-file"))])
+           (if (cfg-ref config "dry-run")
+             (display (format "ssmcp dry-run: download ~a:~a -> ~a\n"
+                       (ht-ref/str inst "name") remote-path destination))
+             (download-file inst config remote-path destination)))]
+        [else
+         (let* ([host (car dest-remote)]
+                [remote-path (cdr dest-remote)]
+                [inst (resolve-single-host host (cfg-ref/str config "cache-file"))])
+           (if (cfg-ref config "dry-run")
+             (display (format "ssmcp dry-run: upload ~a -> ~a:~a\n"
+                       source (ht-ref/str inst "name") remote-path))
+             (upload-file inst config source remote-path)))]))))
diff --git a/test/test-all.ss b/test/test-all.ss
index 9d25e0d..d2023ab 100644
--- a/test/test-all.ss
+++ b/test/test-all.ss
@@ -300,13 +300,11 @@
     (hashtable-ref config "literal-controls" #f) #t))
 
 (let ([config (parse-ssmcp-args
-                '("--delete" "--chunk-size" "4096" "web-*" "./site" "/opt/site"))])
-  (check "SSMCP parses pattern"
-    (hashtable-ref config "pattern" #f) "web-*")
-  (check "SSMCP parses source"
-    (hashtable-ref config "source" #f) "./site")
-  (check "SSMCP parses destination"
-    (hashtable-ref config "destination" #f) "/opt/site")
+                '("--delete" "--chunk-size" "4096" "web-1:~/save" "./foo"))])
+  (check "SSMCP parses remote source"
+    (hashtable-ref config "source" #f) "web-1:~/save")
+  (check "SSMCP parses local destination"
+    (hashtable-ref config "destination" #f) "./foo")
   (check "SSMCP parses --delete"
     (hashtable-ref config "delete" #f) #t)
   (check "SSMCP parses --chunk-size"