#:use-module (gnu packages tls)
#:use-module (gnu packages compression)
#:use-module (gnu packages bash)
- #:use-module (gnu packages elf)
#:export (claude-code))
(define settings-file
(let* ((out #$output)
(bin (string-append out "/bin"))
(etc (string-append out "/etc"))
- (binary (string-append bin "/claude"))
+ (libexec (string-append out "/libexec"))
+ (real-binary (string-append libexec "/claude"))
+ (wrapper (string-append bin "/claude"))
(api-helper (string-append etc "/get-api-key"))
- (patchelf #$(file-append patchelf "/bin/patchelf"))
+ (bash #$(file-append bash-minimal "/bin/bash"))
(ld-so #$(file-append glibc "/lib/ld-linux-x86-64.so.2"))
- (rpath (string-append #$glibc
- "/lib:"
- #$gcc:lib
- "/lib:"
- #$gcc:lib
- "/lib64:"
- #$openssl
- "/lib:"
- #$zlib
- "/lib")))
- ;; 1. Copy the binary and patch its ELF.
- (mkdir-p bin)
- (copy-file #$source binary)
- (chmod binary #o755)
- (invoke patchelf "--set-interpreter" ld-so binary)
- (invoke patchelf "--set-rpath" rpath binary)
- ;; 2. Copy and patch configuration files.
+ (lib-path (string-append #$glibc
+ "/lib:"
+ #$gcc:lib
+ "/lib:"
+ #$gcc:lib
+ "/lib64:"
+ #$openssl
+ "/lib:"
+ #$zlib
+ "/lib")))
+ ;; 1. Install the real binary in libexec.
+ (mkdir-p libexec)
+ (copy-file #$source real-binary)
+ (chmod real-binary #o755)
+ ;; 2. Copy and patch configuration.
(mkdir-p etc)
(copy-file #$instructions-file
(string-append etc "/CLAUDE.md"))
;; 3. Copy the API key helper.
(copy-file #$api-key-file api-helper)
(chmod api-helper #o755)
- ;; 4. Wrap the binary to set CLAUDE_CONFIG_DIR.
- ;; wrap-program needs bash in PATH for the shebang.
- (setenv "PATH"
- (string-append #$bash-minimal "/bin"))
- (wrap-program binary
- `("CLAUDE_CONFIG_DIR" =
- (,etc)))))))
+ ;; 4. Generate a wrapper that seeds a writable
+ ;; config directory in $HOME on first run,
+ ;; then invokes the binary through ld-linux.
+ (mkdir-p bin)
+ (with-output-to-file wrapper
+ (lambda ()
+ (format #t "#!~a~%" bash)
+ (format #t ": \"${HOME:=/root}\"~%")
+ (format #t "CONFIG_DIR=\"${HOME}/.config/claude\"~%")
+ (format #t "STORE_ETC=\"~a\"~%" etc)
+ (format #t "if [ ! -d \"${CONFIG_DIR}\" ]; then~%")
+ (format #t " mkdir -p \"${CONFIG_DIR}\"~%")
+ (format #t " cp \"${STORE_ETC}/settings.json\"")
+ (format #t " \"${CONFIG_DIR}/\"~%")
+ (format #t " cp \"${STORE_ETC}/CLAUDE.md\"")
+ (format #t " \"${CONFIG_DIR}/\"~%")
+ (format #t "fi~%")
+ (format #t "export CLAUDE_CONFIG_DIR=\"${CONFIG_DIR}\"~%")
+ (format #t "export LD_LIBRARY_PATH=\"~a:" lib-path)
+ (format #t "$LD_LIBRARY_PATH\"~%")
+ (format #t "exec ~a ~a \"$@\"~%" ld-so real-binary)))
+ (chmod wrapper #o755)))))
(inputs (list glibc
- `(,gcc "lib")
- openssl
- zlib
- bash-minimal
- patchelf))
+ `(,gcc "lib") openssl zlib bash-minimal))
(home-page "https://claude.ai")
(synopsis "Agentic coding tool that lives in your terminal")
- (description "Claude Code is a CLI tool that understands your codebase.")
+ (description "Claude Code is a CLI tool that understands your
+codebase.")
;; Claude Code is proprietary software, not end-user redistributable.
(license #f)))
-;;; Copyright (c) 2026 Jakub Czajka <jakub@ekhem.eu.org>
+;;; Copyright (c) 2025-2026 Jakub Czajka <jakub@ekhem.eu.org>
;;; License: GPL-3.0 or later.
;;;
;;; Claude Code test cases.
(define-module (tests claude-code)
+ #:use-module (conf home claude)
+ #:use-module (gnu packages base)
#:use-module (guix gexp)
#:use-module (tests common)
- #:use-module (conf home claude)
#:export (claude-code-test-cases))
+(define %claude-run-body
+ (let ((bin (file-append claude-code "/bin/claude"))
+ (timeout (file-append coreutils "/bin/timeout")))
+ `(begin
+ (use-modules (ice-9 popen)
+ (ice-9 rdelim))
+ (let* ((cmd (string-append ,timeout " 30 "
+ ,bin " --version 2>&1"))
+ (port (open-input-pipe cmd))
+ (out (read-string port))
+ (st (close-pipe port))
+ (sig (status:term-sig st))
+ (rc (status:exit-val st)))
+ (and (not sig)
+ (not (= rc 139)) #t)))))
+
+;; Verify the wrapper seeds writable config on first run.
+(define %config-seed-body
+ '(begin
+ (let* ((home (or (getenv "HOME") "/root"))
+ (settings (string-append home "/.config/claude/settings.json"))
+ (claude-md (string-append home "/.config/claude/CLAUDE.md")))
+ (and (file-exists? settings)
+ (file-exists? claude-md)))))
+
+(define %settings-proxy-check-body
+ (let ((settings (file-append claude-code "/etc/settings.json")))
+ `(begin
+ (use-modules (ice-9 rdelim))
+ (let ((content (call-with-input-file ,settings
+ read-string)))
+ (string-contains content "http://127.0.0.1:16890")))))
+
+;; HTTP GET / on the proxy health endpoint.
+(define %proxy-health-body
+ '(catch #t
+ (lambda ()
+ (let ((sock (socket PF_INET SOCK_STREAM 0)))
+ (connect sock
+ (make-socket-address AF_INET
+ (inet-pton AF_INET "127.0.0.1")
+ 16890))
+ (display "GET /health HTTP/1.0\nHost: 127.0.0.1\n\n" sock)
+ (force-output sock)
+ (let* ((buf (make-string 4096))
+ (n (catch #t
+ (lambda ()
+ (read-string!/partial buf sock 0 4096))
+ (lambda _
+ 0))))
+ (close sock)
+ (and (> n 0)
+ (string-contains (substring buf 0 n) "status")))) #t)
+ (lambda (key . args)
+ #f)))
+
+;; Send a POST through the proxy and verify it responds (the
+;; upstream returns 401 with a dummy key, but the proxy must not
+;; crash or hang).
+(define %proxy-post-body
+ '(catch #t
+ (lambda ()
+ (let* ((body "{\"model\":\"test\",\"messages\":[]}")
+ (content-length (string-length body))
+ (request (string-append "POST /v1/messages HTTP/1.0\r\n"
+ "Host: 127.0.0.1\r\n"
+ "Content-Type: application/json\r\n"
+ "x-api-key: test-key\r\n"
+ "Content-Length: "
+ (number->string content-length)
+ "\r\n\r\n"
+ body))
+ (sock (socket PF_INET SOCK_STREAM 0)))
+ (connect sock
+ (make-socket-address AF_INET
+ (inet-pton AF_INET "127.0.0.1")
+ 16890))
+ (display request sock)
+ (force-output sock)
+ (let* ((buf (make-string 4096))
+ (n (catch #t
+ (lambda ()
+ (read-string!/partial buf sock 0 4096))
+ (lambda _
+ 0))))
+ (close sock)
+ (> n 0)) #t))
+ (lambda (key . args)
+ #f)))
+
+;; Run claude -p with a dummy API key. Verifies the binary starts
+;; without crashing or firing the onboarding wizard. The dummy key
+;; causes a 401 from the upstream (or a timeout if the network is
+;; unreachable) — what matters is that the process does not crash
+;; and no interactive prompts appear.
+(define %claude-print-body
+ (let ((bin (file-append claude-code "/bin/claude"))
+ (timeout (file-append coreutils "/bin/timeout")))
+ `(begin
+ (use-modules (ice-9 popen)
+ (ice-9 rdelim))
+ (let* ((cmd (string-append "ANTHROPIC_API_KEY=test-key "
+ ,timeout " 60 "
+ ,bin " -p \"hello\" 2>&1"))
+ (port (open-input-pipe cmd))
+ (out (read-string port))
+ (st (close-pipe port))
+ (sig (status:term-sig st))
+ (rc (status:exit-val st)))
+ (if sig
+ (format (current-error-port) "claude -p killed by signal ~a~%"
+ sig))
+ (and (not sig)
+ (not (= rc 139))
+ (or (string-null? out)
+ (not (string-contains out "theme")))
+ (or (string-null? out)
+ (not (string-contains out "onboarding"))) #t)))))
+
(define (claude-code-test-cases marionette)
- "Return a gexp with Claude Code test assertion."
+ "Return a gexp with Claude Code test assertions."
#~(begin
#$(assert-binary-exists "claude: binary exists" claude-code "claude"
- marionette)))
+ marionette)
+
+ ;; Basic runtime — non-interactive command handling.
+ (test-assert "claude: --version runs without segfault"
+ (marionette-eval '#$%claude-run-body
+ #$marionette))
+
+ ;; Config seeding — the wrapper must create writable
+ ;; config in $HOME on first run (triggered by --version).
+ (test-assert "claude: seeds writable config on first run"
+ (marionette-eval '#$%config-seed-body
+ #$marionette))
+
+ (test-assert "claude: settings.json references proxy URL"
+ (marionette-eval '#$%settings-proxy-check-body
+ #$marionette))
+ (test-assert "claude: proxy health endpoint responds"
+ (marionette-eval '#$%proxy-health-body
+ #$marionette))
+
+ ;; Proxy forwarding — send a POST and verify the proxy
+ ;; responds without crashing or hanging.
+ (test-assert "claude: proxy forwards POST without hanging"
+ (marionette-eval '#$%proxy-post-body
+ #$marionette))
+
+ ;; End-to-end: claude -p contacts the proxy and gets a
+ ;; response. Uses a dummy API key; the upstream returns
+ ;; 401 but the local pipeline must complete.
+ (test-assert "claude: -p contacts proxy for API call"
+ (marionette-eval '#$%claude-print-body
+ #$marionette))))