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 <cache>/claude-code/<version>/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
This commit is contained in:
1 parent
f9113626c7
commit
3ee97def30
14 files changed
+892
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`<cache>/claude-code/<version>/` (`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
|
||||
|
||||
@@ -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()) }
|
||||
@@ -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": "<html>Access denied</html>",
|
||||
} {
|
||||
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, "<!doctype html><title>Unavailable</title>", "", "")
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <gemini|antigravity> <email> <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 <package>|rm|update|on|off|login <provider>|logout <provider>]
|
||||
OpenCode provider plugins and pi packages: subscriptions signed in to, and served, through a plugin
|
||||
magpie plugin move|migrate <subscription> 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:])
|
||||
}
|
||||
|
||||
@@ -134,6 +134,12 @@ docker logs magpie</pre>
|
||||
<pre id="ex-signin">docker exec -it magpie magpie accounts add codex</pre>
|
||||
</div>
|
||||
<p>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.</p>
|
||||
<p>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 <code>/config/cache</code>; run it again for a newer one. <code>magpie accounts add claude</code> offers it too when Claude Code is missing.</p>
|
||||
<div class="code">
|
||||
<span class="lang">Shell</span>
|
||||
<button class="copy" data-copy="ex-claude-code">Copy</button>
|
||||
<pre id="ex-claude-code">docker exec -it magpie magpie claude-code install</pre>
|
||||
</div>
|
||||
|
||||
<h2 id="volume"><a class="anchor" href="#volume">What is kept</a></h2>
|
||||
<p>Everything magpie writes is under <code>/config</code>, so a restart, or a new container on the same volume, keeps your providers, keys, sign-ins and plugins.</p>
|
||||
@@ -141,7 +147,7 @@ docker logs magpie</pre>
|
||||
<tr><th>Path</th><th>Holds</th></tr>
|
||||
<tr><td><code>/config/magpie</code></td><td>magpie’s own files: <code>settings.json</code>, providers, gateway keys, routing groups</td></tr>
|
||||
<tr><td><code>/config/home</code></td><td>the container’s <code>HOME</code>: sign-ins kept where their agent keeps them (<code>~/.codex</code>, <code>~/.claude</code>…)</td></tr>
|
||||
<tr><td><code>/config/cache</code></td><td>the model catalog, and Bun, which plugins run on (downloaded the first time one is used)</td></tr>
|
||||
<tr><td><code>/config/cache</code></td><td>the model catalog, Bun, which plugins run on (downloaded the first time one is used), and the Claude Code <code>magpie claude-code install</code> downloaded</td></tr>
|
||||
<tr><td><code>/config/data</code>, <code>/config/state</code></td><td>the rest of what magpie keeps</td></tr>
|
||||
</table></div>
|
||||
<p>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.</p>
|
||||
|
||||
@@ -134,6 +134,12 @@ docker logs magpie</pre>
|
||||
<pre id="ex-signin">docker exec -it magpie magpie accounts add codex</pre>
|
||||
</div>
|
||||
<p>表示されたリンクを開いてサインインし、ブラウザが最後に表示したアドレスを貼り付けます。ブラウザ UI からはファイルからサインインを取り込むこともできます。</p>
|
||||
<p>Claude サブスクリプションのサインインとリクエストは Claude Code を通りますが、イメージには入っていません。先に magpie に Anthropic 公式のビルドをダウンロードさせます。バージョン、ダウンロード元、保存先を示して確認してからダウンロードし、Anthropic のマニフェストにある SHA-256 で検証します。<code>/config/cache</code> に保存され、もう一度実行すると新しいバージョンになります。Claude Code がないときは <code>magpie accounts add claude</code> もダウンロードを提案します。</p>
|
||||
<div class="code">
|
||||
<span class="lang">Shell</span>
|
||||
<button class="copy" data-copy="ex-claude-code">コピー</button>
|
||||
<pre id="ex-claude-code">docker exec -it magpie magpie claude-code install</pre>
|
||||
</div>
|
||||
|
||||
<h2 id="volume"><a class="anchor" href="#volume">保存されるもの</a></h2>
|
||||
<p>magpie が書くものはすべて <code>/config</code> の下にあるので、再起動しても、同じボリュームで新しいコンテナを作っても、プロバイダ、キー、サインイン、プラグインは残ります。</p>
|
||||
@@ -141,7 +147,7 @@ docker logs magpie</pre>
|
||||
<tr><th>パス</th><th>中身</th></tr>
|
||||
<tr><td><code>/config/magpie</code></td><td>magpie 自身のファイル:<code>settings.json</code>、プロバイダ、ゲートウェイキー、ルーティンググループ</td></tr>
|
||||
<tr><td><code>/config/home</code></td><td>コンテナの <code>HOME</code>:サインインは各エージェントが保存する場所(<code>~/.codex</code>、<code>~/.claude</code>…)に保存されます</td></tr>
|
||||
<tr><td><code>/config/cache</code></td><td>モデルカタログと、プラグインが動く Bun(初めてプラグインを使うときにダウンロード)</td></tr>
|
||||
<tr><td><code>/config/cache</code></td><td>モデルカタログ、プラグインが動く Bun(初めてプラグインを使うときにダウンロード)、<code>magpie claude-code install</code> がダウンロードした Claude Code</td></tr>
|
||||
<tr><td><code>/config/data</code>、<code>/config/state</code></td><td>magpie が保存するその他のもの</td></tr>
|
||||
</table></div>
|
||||
<p>古いイメージで作ったボリュームもそのまま使えます。magpie は起動時に足りないフォルダを作ります。古いイメージで行ったサインイン(ボリュームの外に保存されていたもの)だけはやり直しが必要です。</p>
|
||||
|
||||
@@ -134,6 +134,12 @@ docker logs magpie</pre>
|
||||
<pre id="ex-signin">docker exec -it magpie magpie accounts add codex</pre>
|
||||
</div>
|
||||
<p>打开它打印的链接并登录,再把浏览器最后停在的地址粘贴回去。Web 界面也能从文件导入登录。</p>
|
||||
<p>Claude 订阅的登录和请求都经过 Claude Code,而镜像里没有它。先让 magpie 下载 Anthropic 官方的构建:它会说明版本、下载来源和保存位置,问过你再下载,并用 Anthropic 清单里的 SHA-256 校验。它保存在 <code>/config/cache</code> 里;再运行一次可以更新到新版本。缺少 Claude Code 时,<code>magpie accounts add claude</code> 也会提出下载。</p>
|
||||
<div class="code">
|
||||
<span class="lang">Shell</span>
|
||||
<button class="copy" data-copy="ex-claude-code">复制</button>
|
||||
<pre id="ex-claude-code">docker exec -it magpie magpie claude-code install</pre>
|
||||
</div>
|
||||
|
||||
<h2 id="volume"><a class="anchor" href="#volume">保存了什么</a></h2>
|
||||
<p>magpie 写的所有东西都在 <code>/config</code> 下,所以重启、或者在同一个数据卷上新建容器,供应商、密钥、登录和插件都还在。</p>
|
||||
@@ -141,7 +147,7 @@ docker logs magpie</pre>
|
||||
<tr><th>路径</th><th>内容</th></tr>
|
||||
<tr><td><code>/config/magpie</code></td><td>magpie 自己的文件:<code>settings.json</code>、供应商、网关密钥、路由组</td></tr>
|
||||
<tr><td><code>/config/home</code></td><td>容器的 <code>HOME</code>:登录保存在对应 Agent 保存的位置(<code>~/.codex</code>、<code>~/.claude</code>……)</td></tr>
|
||||
<tr><td><code>/config/cache</code></td><td>模型目录,以及插件运行用的 Bun(第一次用插件时下载)</td></tr>
|
||||
<tr><td><code>/config/cache</code></td><td>模型目录、插件运行用的 Bun(第一次用插件时下载),以及 <code>magpie claude-code install</code> 下载的 Claude Code</td></tr>
|
||||
<tr><td><code>/config/data</code>、<code>/config/state</code></td><td>magpie 保存的其他内容</td></tr>
|
||||
</table></div>
|
||||
<p>旧镜像创建的数据卷照样能用:magpie 启动时会补上缺的目录。只有用旧镜像做的登录(当时存在数据卷之外)需要重新登录。</p>
|
||||
|
||||
Reference in new issue
Block a user