From 3ee97def30cc15466a869ff9f6bfc0c9348c4e0e Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 14:44:57 +0800 Subject: [PATCH] claude: a server or container without Claude Code can have magpie download Anthropic's own build, asked first and checked against its manifest (Jorben) A Claude subscription signs in and answers through the local `claude`, so on a server or in the Docker image, which has none, it couldn't be used (Jorben on Discord: could it be downloaded on first use, as Bun is). - internal/claudecode: Anthropic's native build, as claude.ai/install.sh takes it: downloads.claude.ai/claude-code-releases/latest names the version, its manifest.json the platform's SHA-256 and size (linux glibc/musl, darwin, win32). A build that doesn't match both is not kept. It goes to /claude-code//claude, one version kept. Find takes the machine's own claude first, then the download. - magpie claude-code [install [--yes]|remove]: says the version, platform, size, the URL it comes from, where it goes, that its checksum is checked and that it is Anthropic's software under Anthropic's terms, then asks [y/N]. magpie accounts add claude offers it when there is no Claude Code (not on Windows, where one in WSL may be the one). - the gateway's claudeBinary and the sign-in's claudeExecutable take claudecode.Find; their "not installed" errors name the command. - Docker docs (en, zh, ja): Claude sign-in needs `docker exec -it magpie magpie claude-code install` first; kept in /config/cache. The Dockerfile is unchanged: distroless cc-debian12 has glibc. Run: - go test -tags nogui ./... under a temp HOME with pinned caches: all ok - gofmt -l: none; go vet (darwin), GOOS=linux and GOOS=windows go vet -tags nogui ./...: clean; darwin, linux, windows builds - TestClaudeSubscriptionRunsTheDownloadedClaudeCode fails on the old proc.FindTool lookup ("Claude Code is not installed"); taking the checksum compare out fails TestInstallRefusesABuildTheManifestDoesntName - real: magpie claude-code install --yes downloaded Claude Code 2.1.296 darwin-arm64 (230 MB) into a sandbox HOME, checked; it ran --version - real, in gcr.io/distroless/cc-debian12:nonroot with the image's /config layout: install downloaded 2.1.296 linux-arm64 (245 MB) to /config/cache/magpie/claude-code, it ran `2.1.296 (Claude Code)` there, and magpie accounts add claude went on to print the claude.ai sign-in link and ask for the pasted address - the claudecode tests built for linux/arm64 pass in debian:12-slim --- accounts_cli.go | 9 + claude_code_cli.go | 144 +++++++++ claude_code_cli_test.go | 104 ++++++ docs/subsystems/claude-subscription-bridge.md | 20 +- internal/claudecode/claudecode.go | 306 ++++++++++++++++++ internal/claudecode/claudecode_test.go | 214 ++++++++++++ internal/gateway/claude_downloaded_test.go | 61 ++++ internal/gateway/claude_subscription.go | 7 +- internal/provider/account.go | 6 +- internal/provider/claude_signin.go | 3 +- main.go | 3 + site/public/docs/docker.html | 8 +- site/public/docs/ja/docker.html | 8 +- site/public/docs/zh/docker.html | 8 +- 14 files changed, 892 insertions(+), 9 deletions(-) create mode 100644 claude_code_cli.go create mode 100644 claude_code_cli_test.go create mode 100644 internal/claudecode/claudecode.go create mode 100644 internal/claudecode/claudecode_test.go create mode 100644 internal/gateway/claude_downloaded_test.go diff --git a/accounts_cli.go b/accounts_cli.go index 18b16276..d2927302 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -373,6 +373,15 @@ func addAccount(agentID string) error { if _, plug := provider.PluginOf(agentID); plug || provider.Moved(agentID) { return pluginLogin(context.Background(), agentID, "") } + if p, _ := claudeCode.downloaded(); agentID == "claude" && runtime.GOOS != "windows" && claudeCode.own() == "" && p == "" { + // a server or container: the sign-in runs Claude Code, which it + // has none of; downloading it is offered, and asked (Jorben). + // Not on Windows, where one in WSL may be the one signed in with. + fmt.Println(amber.Render("!"), "Claude Code isn't installed here: magpie signs in to Claude, and answers its requests, through it") + if err := installClaudeCode(false); err != nil { + return err + } + } st, err := provider.StartSignIn(agentID) if err != nil { return err diff --git a/claude_code_cli.go b/claude_code_cli.go new file mode 100644 index 00000000..188b9837 --- /dev/null +++ b/claude_code_cli.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/yetone/magpie/internal/claudecode" + "github.com/yetone/magpie/internal/proc" +) + +const claudeCodeUsage = "usage: magpie claude-code which Claude Code the Claude subscription runs\n" + + " magpie claude-code install [--yes] download Anthropic's own build of Claude Code for magpie (a server or container without one)\n" + + " magpie claude-code remove delete the Claude Code magpie downloaded" + +// claudeCodeCmd: magpie claude-code [install [--yes]|remove]. A Claude +// subscription is answered by Claude Code, and signed in to through it; a +// server or a container has none (Jorben on Discord), so magpie downloads +// Anthropic's build when asked, as Anthropic's installer does. +func claudeCodeCmd(args []string) error { + if len(args) == 0 { + return claudeCodeStatus() + } + switch args[0] { + case "install": + yes := false + for _, a := range args[1:] { + switch a { + case "--yes", "-y": + yes = true + default: + return fmt.Errorf("magpie claude-code install: unknown %q\n%s", a, claudeCodeUsage) + } + } + return installClaudeCode(yes) + case "remove", "rm": + path, v := claudeCode.downloaded() + if path == "" { + fmt.Println(muted.Render("magpie hasn't downloaded Claude Code")) + return nil + } + if err := claudecode.Remove(); err != nil { + return err + } + fmt.Println(green.Render("✓"), "removed Claude Code", v, muted.Render("("+claudecode.Root()+")")) + return nil + case "-h", "--help", "help": + fmt.Println(claudeCodeUsage) + return nil + } + return fmt.Errorf("magpie claude-code: unknown %q\n%s", args[0], claudeCodeUsage) +} + +// claudeCode is what the command reads of the machine; tests stand in for it. +var claudeCode = struct { + own func() string + downloaded func() (string, string) + latest func(context.Context) (claudecode.Release, error) + install func(context.Context, claudecode.Release, func(int64)) (string, error) +}{ + own: func() string { return proc.FindTool("claude") }, + downloaded: claudecode.Downloaded, + latest: claudecode.Latest, + install: claudecode.Install, +} + +func claudeCodeStatus() error { + own := claudeCode.own() + path, v := claudeCode.downloaded() + switch { + case own != "": + fmt.Println(green.Render("●"), "Claude Code", muted.Render(own), muted.Render("· this machine's own, which the Claude subscription runs")) + if path != "" { + fmt.Println(muted.Render(" magpie's download (" + v + ", " + path + ") isn't used while it is here · magpie claude-code remove deletes it")) + } + case path != "": + fmt.Println(green.Render("●"), "Claude Code", v, muted.Render(path), muted.Render("· downloaded by magpie")) + fmt.Println(muted.Render(" magpie claude-code install gets the newest · magpie claude-code remove deletes it")) + default: + fmt.Println(amber.Render("○"), "no Claude Code here: a Claude subscription can't be signed in to or answered") + fmt.Println(muted.Render(" install it (https://code.claude.com/docs), or magpie claude-code install downloads Anthropic's own build for magpie")) + } + return nil +} + +// installClaudeCode downloads the newest Claude Code after saying what, +// from where and to where, and asking (unless yes). +func installClaudeCode(yes bool) error { + ctx, stop := interruptContext() + defer stop() + lctx, cancel := context.WithTimeout(ctx, 30*time.Second) + r, err := claudeCode.latest(lctx) + cancel() + if err != nil { + return fmt.Errorf("asking for Claude Code's newest release: %w", err) + } + if path, v := claudeCode.downloaded(); v == r.Version { + fmt.Println(green.Render("✓"), "Claude Code", v, "is downloaded already", muted.Render("("+path+")")) + return nil + } + fmt.Println("Claude Code", bold.Render(r.Version), muted.Render("("+r.Platform+", "+mb(r.Size)+")")) + fmt.Println(muted.Render(" from " + r.URL)) + fmt.Println(muted.Render(" to " + claudecode.Root())) + fmt.Println(muted.Render(" Anthropic's own build, as its installer downloads it; its SHA-256 is checked against Anthropic's manifest")) + fmt.Println(muted.Render(" it is Anthropic's software, under Anthropic's terms; magpie runs it for the Claude subscription only")) + if own := claudeCode.own(); own != "" { + fmt.Println(amber.Render("!"), "this machine has a Claude Code of its own,", own+": it is the one run while it is there") + } + if !yes { + fmt.Print("Download it? [y/N] ") + var answer string + fmt.Scanln(&answer) + if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" { + return fmt.Errorf("download canceled") + } + } + last := time.Time{} + tty := isTerminal(os.Stdout) + path, err := claudeCode.install(ctx, r, func(done int64) { + if !tty || time.Since(last) < 200*time.Millisecond && done < r.Size { + return + } + last = time.Now() + fmt.Printf("\r %s of %s", mb(done), mb(r.Size)) + }) + if tty { + fmt.Println() + } + if err != nil { + return fmt.Errorf("downloading Claude Code %s: %w", r.Version, err) + } + fmt.Println(green.Render("✓"), "Claude Code", r.Version, "downloaded and checked", muted.Render("("+path+")")) + fmt.Println(muted.Render(" add a Claude account: magpie accounts add claude")) + return nil +} + +func mb(n int64) string { return fmt.Sprintf("%.0f MB", float64(n)/(1<<20)) } + +func isTerminal(f *os.File) bool { + st, err := f.Stat() + return err == nil && st.Mode()&os.ModeCharDevice != 0 +} diff --git a/claude_code_cli_test.go b/claude_code_cli_test.go new file mode 100644 index 00000000..3cb4393f --- /dev/null +++ b/claude_code_cli_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strings" + "testing" + + "github.com/yetone/magpie/internal/claudecode" +) + +// claudeCodeOut runs `magpie claude-code args…` and gives what it printed. +func claudeCodeOut(t *testing.T, args ...string) (string, error) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + var out strings.Builder + done := make(chan struct{}) + go func() { + b, _ := io.ReadAll(r) + out.Write(b) + close(done) + }() + err = claudeCodeCmd(args) + w.Close() + os.Stdout = old + <-done + r.Close() + return out.String(), err +} + +// `magpie claude-code install --yes` on a machine with no Claude Code (a +// container, Jorben on Discord) downloads the release's build, says from +// where and to where, and the subscription then runs it. +func TestClaudeCodeInstallDownloadsTheRelease(t *testing.T) { + platform, err := claudecode.Platform(runtime.GOOS, runtime.GOARCH, false) + if err != nil { + t.Skip(err) + } + if runtime.GOOS == "linux" { + if _, err := os.Stat("/lib/libc.musl-x86_64.so.1"); err == nil { + t.Skip("a musl Linux asks for the -musl build") + } + } + const body = "#!/bin/sh\necho '2.1.296 (Claude Code)'\n" + sum := sha256.Sum256([]byte(body)) + exe := map[bool]string{true: "claude.exe", false: "claude"}[runtime.GOOS == "windows"] + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/latest": + fmt.Fprint(w, "2.1.296") + case "/2.1.296/manifest.json": + fmt.Fprintf(w, `{"version":"2.1.296","manifestSignatureEnforcement":"flag","platforms":{%q:{"binary":%q,"checksum":%q,"size":%d}}}`, + platform, exe, hex.EncodeToString(sum[:]), len(body)) + case "/2.1.296/" + platform + "/" + exe: + fmt.Fprint(w, body) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + oldBase, oldOwn := claudecode.Base, claudeCode.own + claudecode.Base, claudeCode.own = srv.URL, func() string { return "" } + t.Cleanup(func() { claudecode.Base, claudeCode.own = oldBase, oldOwn; _ = claudecode.Remove() }) + + out, err := claudeCodeOut(t) + if err != nil || !strings.Contains(out, "magpie claude-code install") { + t.Fatalf("status before: %q, %v", out, err) + } + out, err = claudeCodeOut(t, "install", "--yes") + if err != nil { + t.Fatalf("install: %v\n%s", err, out) + } + for _, want := range []string{"2.1.296", srv.URL + "/2.1.296/" + platform + "/" + exe, claudecode.Root(), "SHA-256", "downloaded and checked"} { + if !strings.Contains(out, want) { + t.Errorf("install didn't say %q:\n%s", want, out) + } + } + if p, v := claudecode.Downloaded(); v != "2.1.296" || p == "" { + t.Fatalf("Downloaded() = %q, %q", p, v) + } + if out, _ := claudeCodeOut(t, "install", "--yes"); !strings.Contains(out, "downloaded already") { + t.Errorf("a second install: %q", out) + } + if out, _ := claudeCodeOut(t); !strings.Contains(out, "downloaded by magpie") { + t.Errorf("status after: %q", out) + } + if _, err := claudeCodeOut(t, "remove"); err != nil { + t.Fatal(err) + } + if p, _ := claudecode.Downloaded(); p != "" { + t.Errorf("remove left %s", p) + } +} diff --git a/docs/subsystems/claude-subscription-bridge.md b/docs/subsystems/claude-subscription-bridge.md index 21ae6ee5..49d64424 100644 --- a/docs/subsystems/claude-subscription-bridge.md +++ b/docs/subsystems/claude-subscription-bridge.md @@ -7,6 +7,23 @@ saved session, that had its last turn, and what a run leaves on disk. Tool calls within a turn (`match`, `continueWith`) are only touched on where they meet it. +## Which Claude Code runs + +`claudeBinary` (and the sign-in's `claudeExecutable`) take +`claudecode.Find`: the machine's own `claude` (`proc.FindTool`), else the +one `magpie claude-code install` downloaded into +`/claude-code//` (`internal/claudecode`), else, on Windows, +one in WSL. The download is Anthropic's native build from +`downloads.claude.ai/claude-code-releases`, the "latest" channel, as +`claude.ai/install.sh` takes it: the release's `manifest.json` gives the +platform's SHA-256 and size, and a build that doesn't match both is not +kept. It is only made when the user asks (`magpie claude-code install`, or +yes to `magpie accounts add claude`'s offer on a machine without one), for +a server or a container (Jorben on Discord). It is not on PATH and stands +in for no agent. It doesn't update itself (the bridge sets +`DISABLE_AUTOUPDATER`); installing again takes the newest and removes the +older one. + ## Why turns go on in one run A run started anew is told the whole conversation in one user message @@ -207,7 +224,8 @@ carries none. ## Verification ```sh -go test -tags nogui ./internal/gateway/ -run 'ClaudeRewritten|LostMedia|ImagesTakenOut|RunLetGoWhenTheClientRewrote|ClaudeSessionTempFiles|ClaudeLetGo|ClaudeSessionFiles|ClaudeOldSessions|ClaudeSubscriptionReplySaysTheAllowance|ClaudeAllowanceHeaders|ClaudeForksAnsweringTheLeadsCalls|ClaudeTurnGivenUpOn|ClaudeToolResultsGivenUpOn|ClaudePromptMarksEarlierTurns|ClaudeUnloadableSession|ClaudeEndedRunSaysWhy' -count=1 +go test -tags nogui ./internal/claudecode/ -count=1 +go test -tags nogui ./internal/gateway/ -run 'ClaudeSubscriptionRunsTheDownloaded|ClaudeSubscriptionWithoutClaudeCode|ClaudeRewritten|LostMedia|ImagesTakenOut|RunLetGoWhenTheClientRewrote|ClaudeSessionTempFiles|ClaudeLetGo|ClaudeSessionFiles|ClaudeOldSessions|ClaudeSubscriptionReplySaysTheAllowance|ClaudeAllowanceHeaders|ClaudeForksAnsweringTheLeadsCalls|ClaudeTurnGivenUpOn|ClaudeToolResultsGivenUpOn|ClaudePromptMarksEarlierTurns|ClaudeUnloadableSession|ClaudeEndedRunSaysWhy' -count=1 ``` `claude_rewritten_test.go` has a case for each relaxation and one for each diff --git a/internal/claudecode/claudecode.go b/internal/claudecode/claudecode.go new file mode 100644 index 00000000..1c990a64 --- /dev/null +++ b/internal/claudecode/claudecode.go @@ -0,0 +1,306 @@ +// Package claudecode finds the Claude Code magpie's Claude subscription +// runs, and downloads Anthropic's own build of it when the user asks, for a +// machine with none: a server or a container (Jorben on Discord). +// +// The build comes from where Anthropic's installer (claude.ai/install.sh) +// takes it: the release's manifest.json names each platform's SHA-256 and +// size, and the binary is checked against both before it is kept. It is +// kept in magpie's cache folder, not on PATH, so it stands in for no agent: +// it is only what the bridge and the sign-in run. +package claudecode + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + + "github.com/yetone/magpie/internal/appdir" + "github.com/yetone/magpie/internal/proc" +) + +// Base is where Anthropic publishes Claude Code's builds, as its installer +// downloads them; a var for tests. +var Base = "https://downloads.claude.ai/claude-code-releases" + +// Root is the folder magpie keeps the Claude Code it downloaded in, one +// folder per version. +func Root() string { return filepath.Join(appdir.Cache(), "claude-code") } + +func exeName(goos string) string { + if goos == "windows" { + return "claude.exe" + } + return "claude" +} + +var versionRe = regexp.MustCompile(`^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$`) + +// Downloaded is the Claude Code magpie downloaded and its version, or "" +// when there is none: the newest of Root's versions that has the binary. +func Downloaded() (path, version string) { + ents, err := os.ReadDir(Root()) + if err != nil { + return "", "" + } + for _, e := range ents { + v := e.Name() + if !e.IsDir() || !versionRe.MatchString(v) { + continue + } + p := filepath.Join(Root(), v, exeName(runtime.GOOS)) + if st, err := os.Stat(p); err != nil || st.IsDir() { + continue + } + if version == "" || newer(v, version) { + path, version = p, v + } + } + return path, version +} + +// newer is whether version a is after b (numbers compared one by one). +func newer(a, b string) bool { + pa, pb := strings.SplitN(strings.SplitN(a, "-", 2)[0], ".", 3), strings.SplitN(strings.SplitN(b, "-", 2)[0], ".", 3) + for i := range 3 { + x, _ := strconv.Atoi(pa[i]) + y, _ := strconv.Atoi(pb[i]) + if x != y { + return x > y + } + } + return a > b +} + +// Find is the Claude Code to run on this machine: the user's own (on PATH +// or where its installers put it, proc.FindTool), else the one magpie +// downloaded; "" when there is neither. +func Find() string { + if p := proc.FindTool("claude"); p != "" { + return p + } + p, _ := Downloaded() + return p +} + +// InstallHint is what an error about a missing Claude Code adds: how to +// have magpie download it. +const InstallHint = "or run `magpie claude-code install` to download Anthropic's own build for magpie" + +// Platform is the name Anthropic's manifest gives the build for goos/goarch +// (musl: a Linux whose C library is musl, as Alpine's). +func Platform(goos, goarch string, musl bool) (string, error) { + arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[goarch] + if arch == "" { + return "", fmt.Errorf("Claude Code has no build for %s/%s", goos, goarch) + } + switch goos { + case "darwin": + return "darwin-" + arch, nil + case "linux": + if musl { + return "linux-" + arch + "-musl", nil + } + return "linux-" + arch, nil + case "windows": + return "win32-" + arch, nil + } + return "", fmt.Errorf("Claude Code has no build for %s", goos) +} + +// isMusl tells a musl Linux as Anthropic's installer does: by musl's loader. +func isMusl() bool { + for _, p := range []string{"/lib/libc.musl-x86_64.so.1", "/lib/libc.musl-aarch64.so.1"} { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +// Release is a build of Claude Code for this machine, as the manifest +// names it. +type Release struct { + Version string + Platform string + URL string + Checksum string // SHA-256, hex + Size int64 +} + +// Latest asks for the newest release (the "latest" channel, Claude Code's +// own default) and its build for this machine. +func Latest(ctx context.Context) (Release, error) { + return latest(ctx, runtime.GOOS, runtime.GOARCH, runtime.GOOS == "linux" && isMusl()) +} + +func latest(ctx context.Context, goos, goarch string, musl bool) (Release, error) { + platform, err := Platform(goos, goarch, musl) + if err != nil { + return Release{}, err + } + b, err := get(ctx, Base+"/latest", 256) + if err != nil { + return Release{}, err + } + v := strings.TrimSpace(string(b)) + if !versionRe.MatchString(v) { + // an HTML page in its place: a region downloads.claude.ai doesn't serve + return Release{}, fmt.Errorf("%s/latest didn't name a version (got %q): downloads.claude.ai may not be reachable from here", Base, short(v)) + } + b, err = get(ctx, Base+"/"+v+"/manifest.json", 1<<20) + if err != nil { + return Release{}, err + } + var m struct { + Platforms map[string]struct { + Binary string `json:"binary"` + Checksum string `json:"checksum"` + Size int64 `json:"size"` + } `json:"platforms"` + } + if err := json.Unmarshal(b, &m); err != nil { + return Release{}, fmt.Errorf("Claude Code %s's manifest: %w", v, err) + } + p, ok := m.Platforms[platform] + if !ok { + return Release{}, fmt.Errorf("Claude Code %s has no %s build", v, platform) + } + if ok, _ := regexp.MatchString(`^[0-9a-f]{64}$`, p.Checksum); !ok { + return Release{}, fmt.Errorf("Claude Code %s's manifest has no checksum for %s", v, platform) + } + if p.Size <= 0 { + return Release{}, fmt.Errorf("Claude Code %s's manifest has no size for %s", v, platform) + } + bin := p.Binary + if bin == "" || strings.ContainsAny(bin, `/\`) { + bin = exeName(goos) + } + return Release{Version: v, Platform: platform, URL: Base + "/" + v + "/" + platform + "/" + bin, Checksum: p.Checksum, Size: p.Size}, nil +} + +func short(s string) string { + if len(s) > 60 { + return s[:60] + "…" + } + return s +} + +func get(ctx context.Context, url string, limit int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", url, res.Status) + } + b, err := io.ReadAll(io.LimitReader(res.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("%s: larger than expected", url) + } + return b, nil +} + +// Install downloads r into Root, checks its size and SHA-256 against the +// manifest's, and keeps it as the Claude Code magpie runs when the machine +// has none of its own; the versions downloaded before are removed. progress, +// when set, is told the bytes so far. It returns the binary's path. +func Install(ctx context.Context, r Release, progress func(done int64)) (string, error) { + dir := filepath.Join(Root(), r.Version) + if !versionRe.MatchString(r.Version) { + return "", fmt.Errorf("not a version: %q", r.Version) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + exe := filepath.Join(dir, exeName(runtime.GOOS)) + tmp := exe + ".part" + if err := download(ctx, r, tmp, progress); err != nil { + os.Remove(tmp) + return "", err + } + if err := os.Rename(tmp, exe); err != nil { + os.Remove(tmp) + return "", err + } + // one version kept: what a run takes is the newest, and each is ~240 MB + ents, _ := os.ReadDir(Root()) + for _, e := range ents { + if e.IsDir() && e.Name() != r.Version && versionRe.MatchString(e.Name()) { + _ = os.RemoveAll(filepath.Join(Root(), e.Name())) + } + } + return exe, nil +} + +func download(ctx context.Context, r Release, to string, progress func(int64)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.URL, nil) + if err != nil { + return err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%s: %s", r.URL, res.Status) + } + out, err := os.OpenFile(to, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + h := sha256.New() + n, err := io.Copy(io.MultiWriter(out, h, progressWriter(progress)), io.LimitReader(res.Body, r.Size+1)) + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + if n != r.Size { + return fmt.Errorf("the download is %d bytes, the manifest says %d", n, r.Size) + } + if got := hex.EncodeToString(h.Sum(nil)); got != r.Checksum { + return errors.New("the download's SHA-256 doesn't match the one in Anthropic's manifest") + } + return nil +} + +type progressFunc func(int64) + +func progressWriter(f func(int64)) io.Writer { + var done int64 + return progressFunc(func(n int64) { + done += n + if f != nil { + f(done) + } + }) +} + +func (f progressFunc) Write(p []byte) (int, error) { + f(int64(len(p))) + return len(p), nil +} + +// Remove deletes every Claude Code magpie downloaded. +func Remove() error { return os.RemoveAll(Root()) } diff --git a/internal/claudecode/claudecode_test.go b/internal/claudecode/claudecode_test.go new file mode 100644 index 00000000..449dabd0 --- /dev/null +++ b/internal/claudecode/claudecode_test.go @@ -0,0 +1,214 @@ +package claudecode + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/yetone/magpie/internal/testenv" +) + +func TestMain(m *testing.M) { testenv.Main(m) } + +// manifest is the shape of downloads.claude.ai's manifest.json for +// 2.1.287 (fields as Anthropic serves them), with this machine's build's +// checksum and size those of body. +func manifest(platform, body string) string { + sum := sha256.Sum256([]byte(body)) + return fmt.Sprintf(`{ + "version": "2.1.287", + "manifestSignatureEnforcement": "flag", + "commit": "3c446a1b98aceb99a6cdee0f84a8bea42f4a8937", + "buildDate": "2026-10-01T16:26:16Z", + "platforms": { + "darwin-arm64": {"binary": "claude", "checksum": "6eab8333fe2121553100d8f40bfada384a3e989b94f947e18ba6677a6fcb41ea", "size": 227827120}, + "linux-x64-musl": {"binary": "claude", "checksum": "ba22ce4b5744c6016e3076ccbd692a059fc4e9f1592f21361859a73938f4e722", "size": 238063704}, + %q: {"binary": %q, "checksum": %q, "size": %d} + }, + "sdkCompat": {"testedWrapperVersions": ["0.3.247"]} +}`, platform, exeName(runtime.GOOS), hex.EncodeToString(sum[:]), len(body)) +} + +// release serves a release as downloads.claude.ai does: latest, the +// manifest, and the build, served as served (body may differ from what the +// manifest says). +func release(t *testing.T, latest, manifestBody, served string) { + t.Helper() + platform, err := Platform(runtime.GOOS, runtime.GOARCH, false) + if err != nil { + t.Skip(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/latest": + fmt.Fprint(w, latest) + case "/2.1.287/manifest.json": + fmt.Fprint(w, manifestBody) + case "/2.1.287/" + platform + "/" + exeName(runtime.GOOS): + fmt.Fprint(w, served) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + old := Base + Base = srv.URL + t.Cleanup(func() { Base = old }) + t.Cleanup(func() { _ = Remove() }) +} + +func thisPlatform(t *testing.T) string { + p, err := Platform(runtime.GOOS, runtime.GOARCH, false) + if err != nil { + t.Skip(err) + } + return p +} + +func TestInstallKeepsTheCheckedBuild(t *testing.T) { + const body = "#!/bin/sh\necho 2.1.287 '(Claude Code)'\n" + release(t, "2.1.287\n", manifest(thisPlatform(t), body), body) + // a version downloaded before goes once the new one is in + old := filepath.Join(Root(), "2.1.200") + if err := os.MkdirAll(old, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, exeName(runtime.GOOS)), []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if p, v := Downloaded(); v != "2.1.200" || p == "" { + t.Fatalf("Downloaded() = %q, %q; want 2.1.200", p, v) + } + + r, err := Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if r.Version != "2.1.287" || r.Size != int64(len(body)) { + t.Fatalf("Latest() = %+v", r) + } + var told int64 + path, err := Install(context.Background(), r, func(n int64) { told = n }) + if err != nil { + t.Fatal(err) + } + if told != int64(len(body)) { + t.Errorf("progress told %d bytes, want %d", told, len(body)) + } + b, err := os.ReadFile(path) + if err != nil || string(b) != body { + t.Fatalf("kept %q (%v), want the build", b, err) + } + if runtime.GOOS != "windows" { + if st, _ := os.Stat(path); st.Mode()&0o100 == 0 { + t.Errorf("%s isn't executable: %v", path, st.Mode()) + } + } + if p, v := Downloaded(); p != path || v != "2.1.287" { + t.Errorf("Downloaded() = %q, %q; want %q, 2.1.287", p, v, path) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Errorf("the older download is still there: %v", err) + } +} + +func TestInstallRefusesABuildTheManifestDoesntName(t *testing.T) { + const body = "#!/bin/sh\necho 2.1.287\n" + for name, served := range map[string]string{ + "other bytes": strings.Replace(body, "2.1.287", "2.1.288", 1), + "cut short": body[:10], + "longer": body + "x", + "an error page": "Access denied", + } { + t.Run(name, func(t *testing.T) { + release(t, "2.1.287", manifest(thisPlatform(t), body), served) + r, err := Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := Install(context.Background(), r, nil); err == nil { + t.Fatal("a build that isn't the manifest's was kept") + } + if p, v := Downloaded(); p != "" { + t.Errorf("Downloaded() = %q, %q after a failed download", p, v) + } + left, _ := filepath.Glob(filepath.Join(Root(), "*", "*")) + if len(left) > 0 { + t.Errorf("left behind: %v", left) + } + }) + } +} + +func TestLatestSaysWhenNoVersionCame(t *testing.T) { + // what a region downloads.claude.ai doesn't serve gets in its place + release(t, "Unavailable", "", "") + _, err := Latest(context.Background()) + if err == nil || !strings.Contains(err.Error(), "didn't name a version") { + t.Fatalf("Latest() error = %v", err) + } +} + +func TestLatestWithoutThisPlatformsBuild(t *testing.T) { + release(t, "2.1.287", `{"version":"2.1.287","platforms":{"plan9-x64":{"binary":"claude","checksum":"`+strings.Repeat("a", 64)+`","size":1}}}`, "") + if _, err := Latest(context.Background()); err == nil || !strings.Contains(err.Error(), "has no") { + t.Fatalf("Latest() error = %v", err) + } +} + +func TestPlatformNames(t *testing.T) { + for _, c := range []struct { + goos, arch string + musl bool + want string + }{ + {"darwin", "arm64", false, "darwin-arm64"}, + {"darwin", "amd64", false, "darwin-x64"}, + {"linux", "amd64", false, "linux-x64"}, + {"linux", "arm64", false, "linux-arm64"}, + {"linux", "amd64", true, "linux-x64-musl"}, + {"linux", "arm64", true, "linux-arm64-musl"}, + {"windows", "amd64", false, "win32-x64"}, + {"windows", "arm64", false, "win32-arm64"}, + } { + if got, err := Platform(c.goos, c.arch, c.musl); err != nil || got != c.want { + t.Errorf("Platform(%s, %s, %v) = %q, %v; want %q", c.goos, c.arch, c.musl, got, err, c.want) + } + } + if _, err := Platform("linux", "386", false); err == nil { + t.Error("linux/386 named a build") + } +} + +func TestFindTakesTheMachinesOwnFirst(t *testing.T) { + dir := filepath.Join(Root(), "2.1.287") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = Remove() }) + got := filepath.Join(dir, exeName(runtime.GOOS)) + if err := os.WriteFile(got, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", t.TempDir()) + if p := Find(); p != got { + t.Errorf("with none of its own, Find() = %q, want the download %q", p, got) + } + if runtime.GOOS == "windows" { + return // a #! script isn't a program there + } + own := t.TempDir() + testenv.Program(t, filepath.Join(own, "claude"), "#!/bin/sh\n") + t.Setenv("PATH", own) + if p := Find(); p == got || p == "" { + t.Errorf("with its own on PATH, Find() = %q, want that one", p) + } +} diff --git a/internal/gateway/claude_downloaded_test.go b/internal/gateway/claude_downloaded_test.go new file mode 100644 index 00000000..5ea24c53 --- /dev/null +++ b/internal/gateway/claude_downloaded_test.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/yetone/magpie/internal/claudecode" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/testenv" +) + +// A machine with no Claude Code of its own (a server, a container) answers +// a Claude subscription's request with the one `magpie claude-code install` +// downloaded into magpie's cache (Jorben on Discord): before, the request +// failed "Claude Code is not installed" with that build right there. +func TestClaudeSubscriptionRunsTheDownloadedClaudeCode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("a shell script stands in for Claude Code") + } + t.Setenv("PATH", t.TempDir()) // no claude of the machine's own + exe := filepath.Join(claudecode.Root(), "2.1.296", "claude") + t.Cleanup(func() { _ = claudecode.Remove() }) + testenv.Program(t, exe, `#!/bin/sh +read -r line +echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"m","model":"claude-sonnet-5","usage":{"input_tokens":1}}}}' +echo '{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}' +echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"from the download"}}}' +echo '{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}}' +echo '{"type":"stream_event","event":{"type":"message_stop"}}' +`) + if c, err := claudeBinary(); err != nil || c.path != exe { + t.Fatalf("claudeBinary() = %+v, %v; want %s", c, err, exe) + } + s := New() + t.Cleanup(s.subscription.abortAll) + p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}} + body := `{"model":"claude-sonnet-5","max_tokens":100,"stream":true,"messages":[{"role":"user","content":"hi"}]}` + rec := httptest.NewRecorder() + var u Usage + s.serveClaudeSubscription(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(body)), provider.Anthropic, p, "claude-sonnet-5", []byte(body), &u) + if out := rec.Body.String(); !strings.Contains(out, "from the download") { + t.Fatalf("reply: %q", out) + } +} + +// With neither, the error says how to have magpie download it. +func TestClaudeSubscriptionWithoutClaudeCodeSaysHowToGetIt(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("WSL may have one") + } + t.Setenv("PATH", t.TempDir()) + _ = os.RemoveAll(claudecode.Root()) + _, err := claudeBinary() + if err == nil || !strings.Contains(err.Error(), "magpie claude-code install") { + t.Fatalf("claudeBinary() error = %v", err) + } +} diff --git a/internal/gateway/claude_subscription.go b/internal/gateway/claude_subscription.go index 10eb496f..b2321f2d 100644 --- a/internal/gateway/claude_subscription.go +++ b/internal/gateway/claude_subscription.go @@ -60,6 +60,7 @@ import ( "unicode/utf8" "github.com/yetone/magpie/internal/appdir" + "github.com/yetone/magpie/internal/claudecode" "github.com/yetone/magpie/internal/netproxy" "github.com/yetone/magpie/internal/proc" "github.com/yetone/magpie/internal/provider" @@ -428,14 +429,16 @@ type claudeCLI struct { wsl *wslrun.Tool } +// claudeBinary is this machine's Claude Code, or the one magpie downloaded +// for it (claudecode.Find), else one in WSL. func claudeBinary() (claudeCLI, error) { - if p := proc.FindTool("claude"); p != "" { + if p := claudecode.Find(); p != "" { return claudeCLI{path: p}, nil } if t, ok := wslrun.Find("claude"); ok { return claudeCLI{wsl: &t}, nil } - return claudeCLI{}, errors.New("Claude Code is not installed; install it and run `claude auth login`") + return claudeCLI{}, errors.New("Claude Code is not installed; install it and run `claude auth login`, " + claudecode.InstallHint) } func (c claudeCLI) command(ctx context.Context, args ...string) *exec.Cmd { diff --git a/internal/provider/account.go b/internal/provider/account.go index 4d4a584e..1fbce6e8 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -33,6 +33,7 @@ import ( "github.com/yetone/magpie/internal/appdir" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/claudecode" "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/proc" ) @@ -545,8 +546,9 @@ func saveClaudeCredential(loc claudeCredentialLocation, c claudeCredentials) err } // claudeExecutable finds the claude CLI (proc.FindTool: claude.exe in -// ~/.local/bin on Windows too, #839); a var so tests can fake it. -var claudeExecutable = func() string { return proc.FindTool("claude") } +// ~/.local/bin on Windows too, #839), else the one magpie downloaded +// (claudecode.Find); a var so tests can fake it. +var claudeExecutable = claudecode.Find // claudeIdentity asks Claude Code itself which account is active. Its credential // blob intentionally contains tokens and plan metadata but no display identity; diff --git a/internal/provider/claude_signin.go b/internal/provider/claude_signin.go index 3a84eddf..18d09437 100644 --- a/internal/provider/claude_signin.go +++ b/internal/provider/claude_signin.go @@ -23,6 +23,7 @@ import ( "sync/atomic" "time" + "github.com/yetone/magpie/internal/claudecode" "github.com/yetone/magpie/internal/netproxy" "github.com/yetone/magpie/internal/proc" "github.com/yetone/magpie/internal/wslrun" @@ -141,7 +142,7 @@ func (c claudeCLI) env(env []string) []string { func startClaudeSignIn(s *signInFlow) error { cli, ok := findClaudeCLI() if !ok { - return errors.New("install Claude Code first, the `claude` command line (the Claude desktop app is not it): magpie signs in to Claude through it") + return errors.New("install Claude Code first, the `claude` command line (the Claude desktop app is not it): magpie signs in to Claude through it; " + claudecode.InstallHint) } if err := os.MkdirAll(claudeDirsRoot(), 0o700); err != nil { return err diff --git a/main.go b/main.go index c57bbafb..fe842fb2 100644 --- a/main.go +++ b/main.go @@ -86,6 +86,7 @@ const usage = `magpie — one place to pick every agent's model magpie accounts refresh renew the saved ChatGPT sign-ins now (the gateway does it daily) magpie accounts checkin WorkBuddy's daily check-in (签到) for each WorkBuddy account, now (Settings can do it daily) magpie accounts project the Google Cloud project a Google account's requests go to + magpie claude-code [install [--yes]|remove] the Claude Code a Claude subscription runs; install downloads Anthropic's own build, checked against its manifest, for a server or container without one magpie plugin [add |rm|update|on|off|login |logout ] OpenCode provider plugins and pi packages: subscriptions signed in to, and served, through a plugin magpie plugin move|migrate run a built-in subscription's accounts on its community plugin @@ -340,6 +341,8 @@ func run(args []string) error { return s3Cmd(args[1:]) case "mcp": return imagemcp.Run(args[1:]) + case "claude-code": + return claudeCodeCmd(args[1:]) case "claude-mcp-helper": // internal: stdio MCP subprocess spawned by Claude Code return claudebridge.RunMCP(args[1:]) } diff --git a/site/public/docs/docker.html b/site/public/docs/docker.html index e6626073..2376e026 100644 --- a/site/public/docs/docker.html +++ b/site/public/docs/docker.html @@ -134,6 +134,12 @@ docker logs magpie
docker exec -it magpie magpie accounts add codex

Open the link it prints, sign in, then paste the address the browser ended on. The browser UI can also import sign-ins from a file.

+

A Claude subscription is signed in to and answered through Claude Code, which the image doesn’t have. Have magpie download Anthropic’s own build first: it says the version, where it comes from and where it goes, asks, and checks the download’s SHA-256 against Anthropic’s manifest. It is kept in /config/cache; run it again for a newer one. magpie accounts add claude offers it too when Claude Code is missing.

+
+ Shell + +
docker exec -it magpie magpie claude-code install
+

What is kept

Everything magpie writes is under /config, so a restart, or a new container on the same volume, keeps your providers, keys, sign-ins and plugins.

@@ -141,7 +147,7 @@ docker logs magpie PathHolds /config/magpiemagpie’s own files: settings.json, providers, gateway keys, routing groups /config/homethe container’s HOME: sign-ins kept where their agent keeps them (~/.codex, ~/.claude…) - /config/cachethe model catalog, and Bun, which plugins run on (downloaded the first time one is used) + /config/cachethe model catalog, Bun, which plugins run on (downloaded the first time one is used), and the Claude Code magpie claude-code install downloaded /config/data, /config/statethe rest of what magpie keeps

A volume made by an older image keeps working: magpie adds the folders it lacks when it starts. Only sign-ins made with that older image, which lived outside the volume, have to be made again.

diff --git a/site/public/docs/ja/docker.html b/site/public/docs/ja/docker.html index f2f386fe..ad151b15 100644 --- a/site/public/docs/ja/docker.html +++ b/site/public/docs/ja/docker.html @@ -134,6 +134,12 @@ docker logs magpie
docker exec -it magpie magpie accounts add codex

表示されたリンクを開いてサインインし、ブラウザが最後に表示したアドレスを貼り付けます。ブラウザ UI からはファイルからサインインを取り込むこともできます。

+

Claude サブスクリプションのサインインとリクエストは Claude Code を通りますが、イメージには入っていません。先に magpie に Anthropic 公式のビルドをダウンロードさせます。バージョン、ダウンロード元、保存先を示して確認してからダウンロードし、Anthropic のマニフェストにある SHA-256 で検証します。/config/cache に保存され、もう一度実行すると新しいバージョンになります。Claude Code がないときは magpie accounts add claude もダウンロードを提案します。

+
+ Shell + +
docker exec -it magpie magpie claude-code install
+

保存されるもの

magpie が書くものはすべて /config の下にあるので、再起動しても、同じボリュームで新しいコンテナを作っても、プロバイダ、キー、サインイン、プラグインは残ります。

@@ -141,7 +147,7 @@ docker logs magpie パス中身 /config/magpiemagpie 自身のファイル:settings.json、プロバイダ、ゲートウェイキー、ルーティンググループ /config/homeコンテナの HOME:サインインは各エージェントが保存する場所(~/.codex、~/.claude…)に保存されます - /config/cacheモデルカタログと、プラグインが動く Bun(初めてプラグインを使うときにダウンロード) + /config/cacheモデルカタログ、プラグインが動く Bun(初めてプラグインを使うときにダウンロード)、magpie claude-code install がダウンロードした Claude Code /config/data、/config/statemagpie が保存するその他のもの

古いイメージで作ったボリュームもそのまま使えます。magpie は起動時に足りないフォルダを作ります。古いイメージで行ったサインイン(ボリュームの外に保存されていたもの)だけはやり直しが必要です。

diff --git a/site/public/docs/zh/docker.html b/site/public/docs/zh/docker.html index 2560e3d1..0eb3f95a 100644 --- a/site/public/docs/zh/docker.html +++ b/site/public/docs/zh/docker.html @@ -134,6 +134,12 @@ docker logs magpie
docker exec -it magpie magpie accounts add codex

打开它打印的链接并登录,再把浏览器最后停在的地址粘贴回去。Web 界面也能从文件导入登录。

+

Claude 订阅的登录和请求都经过 Claude Code,而镜像里没有它。先让 magpie 下载 Anthropic 官方的构建:它会说明版本、下载来源和保存位置,问过你再下载,并用 Anthropic 清单里的 SHA-256 校验。它保存在 /config/cache 里;再运行一次可以更新到新版本。缺少 Claude Code 时,magpie accounts add claude 也会提出下载。

+
+ Shell + +
docker exec -it magpie magpie claude-code install
+

保存了什么

magpie 写的所有东西都在 /config 下,所以重启、或者在同一个数据卷上新建容器,供应商、密钥、登录和插件都还在。

@@ -141,7 +147,7 @@ docker logs magpie 路径内容 /config/magpiemagpie 自己的文件:settings.json、供应商、网关密钥、路由组 /config/home容器的 HOME:登录保存在对应 Agent 保存的位置(~/.codex、~/.claude……) - /config/cache模型目录,以及插件运行用的 Bun(第一次用插件时下载) + /config/cache模型目录、插件运行用的 Bun(第一次用插件时下载),以及 magpie claude-code install 下载的 Claude Code /config/data、/config/statemagpie 保存的其他内容

旧镜像创建的数据卷照样能用:magpie 启动时会补上缺的目录。只有用旧镜像做的登录(当时存在数据卷之外)需要重新登录。