agent, library, sessions: a Claude Desktop installed as an MSIX package on Windows is found, wired, given MCP servers and skills, and its Cowork calls read, in the package's folders (Kilig on Discord, Windows 10 LTSC 21H2)

Desktop from its setup.exe there runs packaged, so Windows keeps what it
writes under %APPDATA% and %LOCALAPPDATA% in
%LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\{Roaming,Local}.
Nothing was in %APPDATA%\Claude or %LOCALAPPDATA%\Claude*, so magpie didn't
recognise Desktop at all. The packaged app reads its own copy before the
real folder's, so writing the usual folders wouldn't reach it either.

New internal/desktopdir is the one resolver for Desktop's folders: Data
(its own userData: %APPDATA%\Claude, the package's LocalCache\Roaming\Claude),
Mode (the Claude folder deploymentMode goes into, %LOCALAPPDATA%\Claude as
before) and ThreeP (Claude-3p). The package is found by globbing
Packages\Claude_* (the publisher id isn't assumed) and used unless Desktop
runs in the usual folders: it has run there (Chromium's "Local State"; an
%APPDATA%\Claude holding only another tool's claude_desktop_config.json
doesn't count) and not later in the package's. Every place that built
these paths goes through it:

- agent: desktopDirs (its 3p switch's writes, DesktopConfig3p, Claude
  Code's desktop check in claude.go) and detection, which also looks at
  Data (replacing the separate %APPDATA%\Claude check). windowsDesktopDir
  moved into desktopdir unchanged.
- library: apps() (Desktop's MCP file, was os.UserConfigDir) and
  desktopSkillRoots (Cowork's skills-plugin).
- sessions: desktopDataDirs, a copy of agent's resolver, now desktopdir's
  All(); it also adds %APPDATA%\Claude on a usual Windows install, where
  Desktop keeps 1p Cowork sessions (only %LOCALAPPDATA%'s were read). usage's
  request_page and session_provider read it through sessions.DesktopDataDirs.

Tests: TestFindMSIX/TestFindWindowsClassic/TestFindOthers (desktopdir),
TestClaudeDesktopMSIX (agent: detected, switch writes LocalCache\Local\Claude
and Claude-3p, nothing in the usual folders), TestClaudeDesktopMSIXLibrary
(MCP file and skills-plugin in LocalCache\Roaming\Claude),
TestCallsCoworkMSIX (sessions). With the call sites put back to origin/main
the last three fail: "an MSIX Claude Desktop isn't detected", "no Claude
Desktop target: <nil>", "dirs: [.../Claude .../Claude-3p]".

Run: go test -tags nogui ./... under a temp HOME with pinned caches (all
ok); GOOS=darwin|linux|windows go vet -tags nogui on desktopdir, agent,
library, sessions, usage; GOOS=windows go build, GOOS=linux go build -tags
nogui. On the Windows 11 box (Squirrel install, no MSIX package): the
windows test binaries pass every Desktop/MSIX/Cowork test; the full agent
and sessions binaries fail the same 16 and 76 tests (run outside their
testdata) as origin/main's; a read-only probe of desktopdir.Here() gives
the usual %APPDATA%\Claude, %LOCALAPPDATA%\Claude and Claude-3p, Package "".
Not tried against a real MSIX Desktop: none on the box, and Claude-3p's
place in the package (LocalCache\Local, as Local\Claude is in Kilig's
listing) is inferred, not seen.
This commit is contained in:
yetone committed 2026-10-08 12:47:48 +08:00
1 parent 69b6772794
commit 5d0c1dcad7
11 files changed
+452 -127

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ line; agents connected to magpie lose it when it quits.
| Agent | File | Fields |
| ------------ | --------------------------------- | --------------- |
| Claude Code | `~/.claude/settings.json` | provider, model, opus/sonnet/haiku/fable, sign-in (through magpie) |
| Claude Desktop | `Claude/` + `Claude-3p/configLibrary/` in `~/Library/Application Support` (`%LOCALAPPDATA%` on Windows, `~/.config` on Linux) | provider (its third-party gateway mode: Code and Cowork on magpie, no Anthropic sign-in; restart Desktop) |
| Claude Desktop | `Claude/` + `Claude-3p/configLibrary/` in `~/Library/Application Support` (`%LOCALAPPDATA%` on Windows, `~/.config` on Linux). A Desktop installed as an MSIX package on Windows keeps them in its package instead (`%LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\Local`, its own data in `LocalCache\Roaming\Claude`), and magpie uses those ([`desktopdir`](../internal/desktopdir/desktopdir.go)) | provider (its third-party gateway mode: Code and Cowork on magpie, no Anthropic sign-in; restart Desktop) |
| Codex | `~/.codex/config.toml` | provider, model, effort |
| Gemini CLI | `~/.gemini/settings.json`, `~/.gemini/.env` | auth, model |
| OpenCode | `~/.config/opencode/opencode.json(c)` (`$OPENCODE_CONFIG_DIR`) | model, small |
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/desktopdir"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
@@ -628,7 +629,7 @@ func claudeIn(at place) *Agent {
forget(capsKey)
return nil
}
desktop := at.id == "" && at.sys == nil && desktopWired(desktopPathsOf(desktopDirs(runtime.GOOS, at.home, os.Getenv)))
desktop := at.id == "" && at.sys == nil && desktopWired(desktopPathsOf(desktopDirs(desktopdir.OS, at.home, os.Getenv)))
v := claudeCapabilities(models, desktop)
if v == "" {
return dropCaps()
+14 -60
View File
@@ -18,8 +18,9 @@ package agent
// "entries":[…,{"id":"<id>","name":"magpie"}]}
//
// as CC Switch writes them (claude_desktop_config.rs). The folders are in
// ~/Library/Application Support on macOS, %LOCALAPPDATA% on Windows and
// $XDG_CONFIG_HOME on Linux. With no inferenceModels Desktop lists the
// ~/Library/Application Support on macOS, %LOCALAPPDATA% on Windows (the
// MSIX package's LocalCache\Local for a packaged Desktop) and
// $XDG_CONFIG_HOME on Linux, as internal/desktopdir finds them. With no inferenceModels Desktop lists the
// gateway's /v1/models, which the gateway gives it by ids it keeps (see
// gateway/desktop.go). Desktop reads all this at start-up only.
@@ -30,12 +31,10 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/desktopdir"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
@@ -61,76 +60,31 @@ func desktopPathsOf(dir, dir3p string) desktopPaths {
}
}
// desktopDirs are Desktop's Claude and Claude-3p folders on goos.
// desktopDirs are Desktop's Claude and Claude-3p folders on goos: the ones
// magpie writes deploymentMode and its profile into (desktopdir.Find).
func desktopDirs(goos, home string, getenv func(string) string) (string, string) {
switch goos {
case "darwin":
d := filepath.Join(home, "Library", "Application Support")
return filepath.Join(d, "Claude"), filepath.Join(d, "Claude-3p")
case "windows":
d := getenv("LOCALAPPDATA")
if d == "" {
d = filepath.Join(home, "AppData", "Local")
}
return windowsDesktopDir(d, false), windowsDesktopDir(d, true)
}
d := getenv("XDG_CONFIG_HOME")
if d == "" || !filepath.IsAbs(d) {
d = filepath.Join(home, ".config")
}
return filepath.Join(d, "Claude"), filepath.Join(d, "Claude-3p")
d := desktopdir.Find(goos, home, getenv)
return d.Mode, d.ThreeP
}
// DesktopConfig3p is the claude_desktop_config.json Claude Desktop reads in
// its 3p mode: there its whole userData is Claude-3p, its MCP servers too
// (%LOCALAPPDATA%\Claude-3p on Windows, Claude-3p beside Claude elsewhere).
func DesktopConfig3p(home string) string {
_, d := desktopDirs(runtime.GOOS, home, os.Getenv)
_, d := desktopDirs(desktopdir.OS, home, os.Getenv)
return filepath.Join(d, "claude_desktop_config.json")
}
// windowsDesktopDir is %LOCALAPPDATA%\Claude (or Claude-3p), else the first
// folder there named Claude… (with -3p in it or not), as CC Switch finds it.
func windowsDesktopDir(local string, threep bool) string {
name := "Claude"
if threep {
name = "Claude-3p"
}
exact := filepath.Join(local, name)
if _, err := os.Stat(exact); err == nil {
return exact
}
ents, _ := os.ReadDir(local)
var found []string
for _, e := range ents {
if n := e.Name(); e.IsDir() && strings.HasPrefix(n, "Claude") && strings.Contains(n, "-3p") == threep {
found = append(found, n)
}
}
if len(found) == 0 {
return exact
}
sort.Strings(found)
return filepath.Join(local, found[0])
}
func claudeDesktop(home string) *Agent {
p := desktopPathsOf(desktopDirs(runtime.GOOS, home, os.Getenv))
// %APPDATA%\Claude is where Desktop keeps its MCP servers on Windows
also := ""
if runtime.GOOS == "windows" {
if d := appdir.Getenv("APPDATA"); d != "" {
also = filepath.Join(d, "Claude")
}
}
dirs := desktopdir.Find(desktopdir.OS, home, os.Getenv)
p := desktopPathsOf(dirs.Mode, dirs.ThreeP)
return &Agent{
ID: "claude-desktop", Name: "Claude Desktop", Icon: "claude-color", Aliases: []string{"claude-app"},
Dir: p.dir, Path: p.config,
detect: func() bool {
for _, d := range []string{p.dir, p.dir3p, also} {
if d == "" {
continue
}
// its own data (%APPDATA%\Claude on Windows, or the MSIX
// package's), the folder magpie writes or Claude-3p
for _, d := range dirs.All() {
if isDir(d) {
return true
}
+46
View File
@@ -9,6 +9,7 @@ import (
"runtime"
"testing"
"github.com/yetone/magpie/internal/desktopdir"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
@@ -347,3 +348,48 @@ func TestClaudeDesktopTiers(t *testing.T) {
t.Fatalf("unset: %v %q", err, f.Get())
}
}
// Kilig on Discord: Claude Desktop from its setup.exe on Windows 10 LTSC
// runs as an MSIX package, its data in
// %LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\Roaming\Claude and
// nothing in %APPDATA%\Claude. It is detected, and magpie's switch writes
// the package's folders, which the packaged app reads, not the usual ones
// it doesn't see.
func TestClaudeDesktopMSIX(t *testing.T) {
home, _ := desktopSandbox(t)
old := desktopdir.OS
desktopdir.OS = "windows"
t.Cleanup(func() { desktopdir.OS = old })
local, roaming := filepath.Join(home, "AppData", "Local"), filepath.Join(home, "AppData", "Roaming")
os.MkdirAll(roaming, 0o755)
pkg := filepath.Join(local, "Packages", "Claude_pzs8sxrjxfjjc", "LocalCache")
data := filepath.Join(pkg, "Roaming", "Claude")
for _, d := range []string{"claude-code-sessions", "local-agent-mode-sessions", "Local Storage", "IndexedDB", "logs"} {
os.MkdirAll(filepath.Join(data, d), 0o755)
}
os.WriteFile(filepath.Join(data, "Local State"), []byte(`{}`), 0o644)
os.MkdirAll(filepath.Join(pkg, "Local", "Claude", "logs"), 0o755)
a := claudeDesktop(home)
if !a.Detected() {
t.Fatal("an MSIX Claude Desktop isn't detected")
}
if want := filepath.Join(pkg, "Local", "Claude", "claude_desktop_config.json"); a.Path != want {
t.Errorf("path %s, want %s", a.Path, want)
}
if err := a.Field("provider").Set("magpie"); err != nil {
t.Fatal(err)
}
p := desktopPathsOf(filepath.Join(pkg, "Local", "Claude"), filepath.Join(pkg, "Local", "Claude-3p"))
if !desktopWired(p) || a.Field("provider").Get() != magpieID {
t.Fatalf("not wired in the package's folders: %v", desktopTree(t, p))
}
if m := desktopJSON(t, p.config3p); m["deploymentMode"] != "3p" {
t.Errorf("Claude-3p: %v", m)
}
for _, d := range []string{filepath.Join(roaming, "Claude"), filepath.Join(local, "Claude"), filepath.Join(local, "Claude-3p")} {
if _, err := os.Stat(d); err == nil {
t.Errorf("%s written, which the packaged Desktop doesn't read", d)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
// Package desktopdir finds Claude Desktop's folders: the one place the
// agent, the library and the session readers ask.
//
// On macOS they are ~/Library/Application Support/Claude and Claude-3p, on
// Linux $XDG_CONFIG_HOME/Claude and Claude-3p. On Windows Desktop's own
// data (its claude_desktop_config.json, Cowork's sessions and skills) is
// %APPDATA%\Claude, its 3p mode's is %LOCALAPPDATA%\Claude-3p, and magpie
// writes deploymentMode into %LOCALAPPDATA%\Claude as CC Switch does.
//
// Desktop installed from the setup.exe that installs an MSIX package (as on
// Windows 10 LTSC, Kilig on Discord) runs packaged, and Windows keeps what
// it writes under %APPDATA% and %LOCALAPPDATA% in the package's own
// folders instead:
//
// %LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\Roaming\Claude
// %LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\Local\Claude(-3p)
//
// The packaged app reads its own copy before the real folder's, so a file
// another program writes into %APPDATA%\Claude is not seen once the app has
// one there. Such an install's folders are the package's: those are found,
// read and written in place of the usual ones, unless Desktop runs in the
// usual ones (it has run there, and not later in the package's).
package desktopdir
import (
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"time"
"github.com/yetone/magpie/internal/appdir"
)
// Dirs are Claude Desktop's folders on one computer.
type Dirs struct {
// Data is Desktop's userData when signed in with Anthropic: its
// claude_desktop_config.json with its MCP servers, Cowork's sessions
// and skills (%APPDATA%\Claude on Windows).
Data string
// Mode is the Claude folder magpie writes deploymentMode into: Data,
// except on Windows (%LOCALAPPDATA%\Claude, as CC Switch writes it).
Mode string
// ThreeP is Claude-3p, Desktop's whole userData in its 3p mode.
ThreeP string
// Package is the MSIX package's LocalCache folder Data, Mode and ThreeP
// are in, "" for Desktop not installed as a package.
Package string
}
// All are Data, Mode and ThreeP, each once.
func (d Dirs) All() []string {
var out []string
for _, p := range []string{d.Data, d.Mode, d.ThreeP} {
if p != "" && !slices.Contains(out, p) {
out = append(out, p)
}
}
return out
}
// OS is the system whose folders Here, and the agent's Desktop, look for:
// runtime.GOOS, set by a test to try another system's.
var OS = runtime.GOOS
// Here are Desktop's folders on this computer.
func Here() Dirs {
home, _ := os.UserHomeDir()
return Find(OS, home, appdir.Getenv)
}
// Find are Desktop's folders on goos for home, with getenv reading the
// environment (a folder in it that isn't absolute is passed over).
func Find(goos, home string, getenv func(string) string) Dirs {
env := func(k, def string) string {
if v := getenv(k); v != "" && filepath.IsAbs(v) {
return v
}
return def
}
switch goos {
case "darwin":
d := filepath.Join(home, "Library", "Application Support")
return Dirs{Data: filepath.Join(d, "Claude"), Mode: filepath.Join(d, "Claude"), ThreeP: filepath.Join(d, "Claude-3p")}
case "windows":
local := env("LOCALAPPDATA", filepath.Join(home, "AppData", "Local"))
roaming := env("APPDATA", filepath.Join(home, "AppData", "Roaming"))
classic := Dirs{Data: filepath.Join(roaming, "Claude"), Mode: windowsDir(local, false), ThreeP: windowsDir(local, true)}
pkg := packageDir(local)
if pkg == "" {
return classic
}
packaged := Dirs{
Data: filepath.Join(pkg, "Roaming", "Claude"),
Mode: filepath.Join(pkg, "Local", "Claude"),
ThreeP: filepath.Join(pkg, "Local", "Claude-3p"),
Package: pkg,
}
// the usual folders while Desktop runs there; the package's when
// only it has run in its own, or ran there last (a Desktop moved
// from the usual install onto the package leaves its old data)
c, p := lastRun(classic.Data, classic.ThreeP), lastRun(packaged.Data, packaged.ThreeP)
if !c.IsZero() && !p.After(c) {
return classic
}
return packaged
}
d := env("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
return Dirs{Data: filepath.Join(d, "Claude"), Mode: filepath.Join(d, "Claude"), ThreeP: filepath.Join(d, "Claude-3p")}
}
// lastRun is when Desktop last ran in any of dirs, its userData folders:
// the time Chromium's "Local State" there was written, zero for none. A
// folder holding only claude_desktop_config.json (another tool's MCP
// servers, or the library's) is not one Desktop has run in.
func lastRun(dirs ...string) time.Time {
var t time.Time
for _, d := range dirs {
if fi, err := os.Stat(filepath.Join(d, "Local State")); err == nil && fi.Mode().IsRegular() && fi.ModTime().After(t) {
t = fi.ModTime()
}
}
return t
}
// packageDir is the LocalCache folder of Desktop's MSIX package under
// local (%LOCALAPPDATA%): Packages\Claude_<publisher id>, the one Desktop
// has run in first, else the first there is. The publisher id is not
// assumed: it is the one of whoever signed the package.
func packageDir(local string) string {
found, _ := filepath.Glob(filepath.Join(local, "Packages", "Claude_*"))
var dirs []string
for _, p := range found {
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
dirs = append(dirs, filepath.Join(p, "LocalCache"))
}
}
if len(dirs) == 0 {
return ""
}
sort.Strings(dirs)
for _, d := range dirs {
if !lastRun(filepath.Join(d, "Roaming", "Claude"), filepath.Join(d, "Local", "Claude-3p")).IsZero() {
return d
}
}
return dirs[0]
}
// windowsDir is %LOCALAPPDATA%\Claude (or Claude-3p), else the first folder
// there named Claude… (with -3p in it or not), as CC Switch finds it.
func windowsDir(local string, threep bool) string {
name := "Claude"
if threep {
name = "Claude-3p"
}
exact := filepath.Join(local, name)
if _, err := os.Stat(exact); err == nil {
return exact
}
ents, _ := os.ReadDir(local)
var found []string
for _, e := range ents {
if n := e.Name(); e.IsDir() && strings.HasPrefix(n, "Claude") && strings.Contains(n, "-3p") == threep {
found = append(found, n)
}
}
if len(found) == 0 {
return exact
}
sort.Strings(found)
return filepath.Join(local, found[0])
}
+117
View File
@@ -0,0 +1,117 @@
package desktopdir
import (
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
// ran makes dir a userData Desktop has run in, at t.
func ran(tb testing.TB, dir string, t time.Time) {
tb.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
tb.Fatal(err)
}
p := filepath.Join(dir, "Local State")
if err := os.WriteFile(p, []byte(`{}`), 0o644); err != nil {
tb.Fatal(err)
}
if err := os.Chtimes(p, t, t); err != nil {
tb.Fatal(err)
}
}
// Desktop from the setup.exe that installs its MSIX package (Kilig on
// Discord, Windows 10 LTSC 21H2) keeps its data in the package's
// LocalCache, nothing in %APPDATA%\Claude: those are its folders, whatever
// the package's publisher id.
func TestFindMSIX(t *testing.T) {
for _, pub := range []string{"pzs8sxrjxfjjc", "q1w2e3r4t5y6z"} {
home := t.TempDir()
local, roaming := filepath.Join(home, "AppData", "Local"), filepath.Join(home, "AppData", "Roaming")
pkg := filepath.Join(local, "Packages", "Claude_"+pub, "LocalCache")
ran(t, filepath.Join(pkg, "Roaming", "Claude"), time.Now())
os.MkdirAll(filepath.Join(pkg, "Local", "Claude", "logs"), 0o755)
os.MkdirAll(filepath.Join(local, "Packages", "Microsoft.WindowsStore_8wekyb3d8bbwe"), 0o755)
os.MkdirAll(roaming, 0o755)
got := Find("windows", home, env(map[string]string{"LOCALAPPDATA": local, "APPDATA": roaming}))
want := Dirs{Data: filepath.Join(pkg, "Roaming", "Claude"), Mode: filepath.Join(pkg, "Local", "Claude"),
ThreeP: filepath.Join(pkg, "Local", "Claude-3p"), Package: pkg}
if got != want {
t.Errorf("%s:\n got %+v\nwant %+v", pub, got, want)
}
if all := got.All(); !reflect.DeepEqual(all, []string{want.Data, want.Mode, want.ThreeP}) {
t.Errorf("%s: all %v", pub, all)
}
// without the variables: the profile's own AppData
if got := Find("windows", home, env(nil)); got != want {
t.Errorf("%s, no variables: %+v", pub, got)
}
}
}
// The usual install (Squirrel: %APPDATA%\Claude, %LOCALAPPDATA%\Claude-3p)
// keeps its folders, and so does a machine with no Desktop at all. An
// %APPDATA%\Claude holding only claude_desktop_config.json (another tool's
// MCP servers) is not a Desktop that runs there; when both have run, the
// one that ran last is Desktop's.
func TestFindWindowsClassic(t *testing.T) {
home := t.TempDir()
local, roaming := filepath.Join(home, "Local"), filepath.Join(home, "Roaming")
e := env(map[string]string{"LOCALAPPDATA": local, "APPDATA": roaming})
classic := Dirs{Data: filepath.Join(roaming, "Claude"), Mode: filepath.Join(local, "Claude"), ThreeP: filepath.Join(local, "Claude-3p")}
if got := Find("windows", home, e); got != classic {
t.Errorf("nothing installed: %+v", got)
}
ran(t, filepath.Join(roaming, "Claude"), time.Now().Add(-time.Hour))
if got := Find("windows", home, e); got != classic {
t.Errorf("usual install: %+v", got)
}
pkg := filepath.Join(local, "Packages", "Claude_pzs8sxrjxfjjc", "LocalCache")
os.MkdirAll(pkg, 0o755)
if got := Find("windows", home, e); got != classic {
t.Errorf("package never run: %+v", got)
}
ran(t, filepath.Join(pkg, "Roaming", "Claude"), time.Now().Add(-2*time.Hour))
if got := Find("windows", home, e); got != classic {
t.Errorf("package ran before the usual one: %+v", got)
}
ran(t, filepath.Join(pkg, "Roaming", "Claude"), time.Now())
if got := Find("windows", home, e); got.Package != pkg || got.Data != filepath.Join(pkg, "Roaming", "Claude") {
t.Errorf("package ran last: %+v", got)
}
// another tool's MCP file alone in %APPDATA%\Claude
home2 := t.TempDir()
local2, roaming2 := filepath.Join(home2, "Local"), filepath.Join(home2, "Roaming")
os.MkdirAll(filepath.Join(roaming2, "Claude"), 0o755)
os.WriteFile(filepath.Join(roaming2, "Claude", "claude_desktop_config.json"), []byte(`{"mcpServers":{}}`), 0o644)
pkg2 := filepath.Join(local2, "Packages", "Claude_pzs8sxrjxfjjc", "LocalCache")
ran(t, filepath.Join(pkg2, "Roaming", "Claude"), time.Now())
if got := Find("windows", home2, env(map[string]string{"LOCALAPPDATA": local2, "APPDATA": roaming2})); got.Package != pkg2 {
t.Errorf("only an MCP file in %%APPDATA%%\\Claude: %+v", got)
}
}
// macOS and Linux have one Claude folder for both, and no package.
func TestFindOthers(t *testing.T) {
home := t.TempDir()
as := filepath.Join(home, "Library", "Application Support")
if got := Find("darwin", home, env(nil)); got != (Dirs{Data: filepath.Join(as, "Claude"), Mode: filepath.Join(as, "Claude"), ThreeP: filepath.Join(as, "Claude-3p")}) {
t.Errorf("darwin: %+v", got)
} else if len(got.All()) != 2 {
t.Errorf("darwin all: %v", got.All())
}
for _, x := range []string{"", "relative/dir"} {
c := filepath.Join(home, ".config")
if got := Find("linux", home, env(map[string]string{"XDG_CONFIG_HOME": x})); got != (Dirs{Data: filepath.Join(c, "Claude"), Mode: filepath.Join(c, "Claude"), ThreeP: filepath.Join(c, "Claude-3p")}) {
t.Errorf("linux %q: %+v", x, got)
}
}
}
+2 -5
View File
@@ -30,7 +30,7 @@ import (
"github.com/tidwall/gjson"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/desktopdir"
"github.com/yetone/magpie/internal/edit"
)
@@ -40,10 +40,7 @@ const desktopManifest = "manifest.json"
// Desktop has, in its own folder and Claude-3p's alike: each one with a
// manifest.json or the plugin's plugin.json.
func desktopSkillRoots(desktopDir string) []string {
dirs := []string{desktopDir, filepath.Dir(agent.DesktopConfig3p(home()))}
if d, err := os.UserConfigDir(); err == nil {
dirs = append(dirs, filepath.Join(d, "Claude"), filepath.Join(d, "Claude-3p"))
}
dirs := append([]string{desktopDir}, desktopdir.Here().All()...)
var out []string
seen := map[string]bool{}
for _, d := range dirs {
+31
View File
@@ -7,6 +7,8 @@ import (
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/desktopdir"
)
// desktopAccount makes a skills-plugin for one of Claude Desktop's
@@ -230,3 +232,32 @@ func TestClaudeDesktopSkills(t *testing.T) {
t.Errorf("the entry's keys moved:\n%s", read(t, m))
}
}
// Kilig on Discord: an MSIX Claude Desktop (Windows 10 LTSC) has its
// claude_desktop_config.json and Cowork's skills in the package's
// LocalCache\Roaming\Claude, and doesn't see a file written into
// %APPDATA%\Claude: the library's MCP servers and skills go there.
func TestClaudeDesktopMSIXLibrary(t *testing.T) {
h := sandbox(t)
old := desktopdir.OS
desktopdir.OS = "windows"
t.Cleanup(func() { desktopdir.OS = old })
local, roaming := filepath.Join(h, "AppData", "Local"), filepath.Join(h, "AppData", "Roaming")
t.Setenv("LOCALAPPDATA", local)
t.Setenv("APPDATA", roaming)
os.MkdirAll(roaming, 0o755)
data := filepath.Join(local, "Packages", "Claude_pzs8sxrjxfjjc", "LocalCache", "Roaming", "Claude")
write(t, filepath.Join(data, "Local State"), `{}`)
root := desktopAccount(t, data, "org", "acct", `{"lastUpdated": "2026-10-01T00:00:00.000Z", "skills": []}`)
tg := targetByID("claude-desktop")
if tg == nil || tg.MCP == nil {
t.Fatalf("no Claude Desktop target: %+v", tg)
}
if want := filepath.Join(data, "claude_desktop_config.json"); tg.MCP.Path != want {
t.Errorf("MCP file %s, want %s", tg.MCP.Path, want)
}
if !slices.Equal(tg.Desktop, []string{root}) {
t.Errorf("skills-plugin roots %v, want %s", tg.Desktop, root)
}
}
+9 -9
View File
@@ -9,6 +9,7 @@ import (
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/desktopdir"
)
// Target is where one agent keeps each of the three: an empty path is
@@ -347,15 +348,14 @@ func wslTargetOf(a *agent.Agent) *Target {
// apps are what the library can give MCP servers to that aren't agents
// magpie sets up: known by the folder they keep their settings in.
func apps() []*agent.Agent {
d, err := os.UserConfigDir()
if err != nil {
return nil
}
// Desktop only ever run in its 3p mode has no Claude folder, only
// Claude-3p: its file is the one there
dir := filepath.Join(d, "Claude")
if p := agent.DesktopConfig3p(home()); !isDir(dir) && isDir(filepath.Dir(p)) {
dir = filepath.Dir(p)
// Desktop's own folder (%APPDATA%\Claude on Windows, the MSIX
// package's for a packaged Desktop, which doesn't see a file written
// into %APPDATA%). Desktop only ever run in its 3p mode has no Claude
// folder, only Claude-3p: its file is the one there
d := desktopdir.Here()
dir := d.Data
if !isDir(dir) && isDir(d.ThreeP) {
dir = d.ThreeP
}
return []*agent.Agent{
{ID: "claude-desktop", Name: "Claude Desktop", Icon: "claude-color", Dir: dir, Path: filepath.Join(dir, "claude_desktop_config.json")},
+6 -49
View File
@@ -10,12 +10,11 @@ import (
"maps"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/desktopdir"
)
// Call is one model call as an agent's own session file records it.
@@ -221,53 +220,11 @@ func callSources() []file {
return append(out, workbuddyCallFiles()...)
}
// desktopDataDirs are Claude Desktop's Claude and Claude-3p folders on this
// computer, found as desktopDirs in internal/agent's claudedesktop.go does
// (that package is not one to import from here).
func desktopDataDirs() []string {
home, _ := os.UserHomeDir()
switch runtime.GOOS {
case "darwin":
d := filepath.Join(home, "Library", "Application Support")
return []string{filepath.Join(d, "Claude"), filepath.Join(d, "Claude-3p")}
case "windows":
d := appdir.Getenv("LOCALAPPDATA")
if d == "" {
d = filepath.Join(home, "AppData", "Local")
}
return []string{windowsClaudeDir(d, false), windowsClaudeDir(d, true)}
}
d := appdir.Getenv("XDG_CONFIG_HOME")
if d == "" || !filepath.IsAbs(d) {
d = filepath.Join(home, ".config")
}
return []string{filepath.Join(d, "Claude"), filepath.Join(d, "Claude-3p")}
}
// windowsClaudeDir is %LOCALAPPDATA%\Claude (or Claude-3p), else the first
// folder there named Claude… (with -3p in it or not).
func windowsClaudeDir(local string, threep bool) string {
name := "Claude"
if threep {
name = "Claude-3p"
}
exact := filepath.Join(local, name)
if _, err := os.Stat(exact); err == nil {
return exact
}
ents, _ := os.ReadDir(local)
var found []string
for _, e := range ents {
if n := e.Name(); e.IsDir() && strings.HasPrefix(n, "Claude") && strings.Contains(n, "-3p") == threep {
found = append(found, n)
}
}
if len(found) == 0 {
return exact
}
sort.Strings(found)
return filepath.Join(local, found[0])
}
// desktopDataDirs are Claude Desktop's folders on this computer that
// hold sessions: its own (%APPDATA%\Claude on Windows, or the MSIX
// package's), the Claude folder magpie writes and Claude-3p, as
// desktopdir finds them.
func desktopDataDirs() []string { return desktopdir.Here().All() }
// headLen is how much of a file's start is kept to know it again.
const headLen = 256
+49 -2
View File
@@ -4,11 +4,13 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/agentenv"
"github.com/yetone/magpie/internal/desktopdir"
)
var callT0 = time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
@@ -436,11 +438,24 @@ func TestDesktopDataDirs(t *testing.T) {
t.Setenv("HOME", "/home/u")
t.Setenv("USERPROFILE", "/home/u")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("LOCALAPPDATA", filepath.Join(t.TempDir(), "Local"))
dir := t.TempDir()
t.Setenv("LOCALAPPDATA", filepath.Join(dir, "Local"))
t.Setenv("APPDATA", filepath.Join(dir, "Roaming"))
ds := desktopDataDirs()
if len(ds) != 2 || filepath.Base(ds[0]) != "Claude" || filepath.Base(ds[1]) != "Claude-3p" {
// on Windows Desktop's own data is %APPDATA%\Claude, beside
// %LOCALAPPDATA%'s Claude and Claude-3p
want := []string{"Claude", "Claude-3p"}
if runtime.GOOS == "windows" {
want = []string{filepath.Join("Roaming", "Claude"), filepath.Join("Local", "Claude"), filepath.Join("Local", "Claude-3p")}
}
if len(ds) != len(want) {
t.Fatalf("dirs: %v", ds)
}
for i, w := range want {
if !strings.HasSuffix(ds[i], string(filepath.Separator)+w) {
t.Fatalf("dirs: %v, want %v", ds, want)
}
}
}
// Claude Code's thinking effort is on the lines of its replies: the
@@ -575,3 +590,35 @@ func TestCallsWorkflowAgents(t *testing.T) {
}
}
}
// Kilig on Discord: an MSIX Claude Desktop (Windows 10 LTSC) keeps Cowork's
// sessions in its package's LocalCache\Roaming\Claude, nothing in
// %APPDATA%\Claude; their calls are read from there.
func TestCallsCoworkMSIX(t *testing.T) {
d := setupCalls(t)
old := desktopdir.OS
desktopdir.OS = "windows"
callDesktopDirs = desktopDataDirs
t.Cleanup(func() { desktopdir.OS = old })
home := filepath.Join(t.TempDir(), "home")
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
local, roaming := filepath.Join(home, "AppData", "Local"), filepath.Join(home, "AppData", "Roaming")
t.Setenv("LOCALAPPDATA", local)
t.Setenv("APPDATA", roaming)
os.MkdirAll(roaming, 0o755)
data := filepath.Join(local, "Packages", "Claude_pzs8sxrjxfjjc", "LocalCache", "Roaming", "Claude")
writeLines(t, filepath.Join(data, "Local State"), `{}`)
if ds := desktopDataDirs(); len(ds) != 3 || ds[0] != data {
t.Fatalf("dirs: %v", ds)
}
cw := filepath.Join(data, "local-agent-mode-sessions", "acct", "org", "local_abc", ".claude", "projects", "-sessions-x")
writeLines(t, filepath.Join(cw, "cw1.jsonl"), swap(claudeMsg("c1", "claude-sonnet-5-5", 5, 6, 7, 8, 3), `"entrypoint":"cli"`, `"entrypoint":"local-agent"`))
writeLines(t, filepath.Join(d.claude, "projects", "-p", "sess1.jsonl"), claudeMsg("m1", "claude-opus-5-5", 1, 2, 3, 4, 1))
Reset()
cs := Calls(time.Time{})
if len(cs) != 2 || cs[0].Agent != "claude-desktop" || cs[0].Tokens != (Tokens{5, 6, 7, 8, 0}) || cs[1].Agent != "claude" {
t.Fatalf("calls: %+v", cs)
}
}