@edspencer/paddock 0.61.1 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -16
- package/package.json +2 -2
- package/packages/server/dist/app.js +48 -8
- package/packages/server/dist/claude-credentials.d.ts +116 -0
- package/packages/server/dist/claude-credentials.js +103 -0
- package/packages/server/dist/claude-home.d.ts +102 -30
- package/packages/server/dist/claude-home.js +437 -97
- package/packages/server/dist/claude-instructions.d.ts +121 -0
- package/packages/server/dist/claude-instructions.js +114 -0
- package/packages/server/dist/claude-mcp.d.ts +158 -0
- package/packages/server/dist/claude-mcp.js +389 -0
- package/packages/server/dist/claude-plugins.d.ts +131 -0
- package/packages/server/dist/claude-plugins.js +394 -0
- package/packages/server/dist/claude-settings.d.ts +159 -0
- package/packages/server/dist/claude-settings.js +159 -0
- package/packages/server/dist/cli/args.d.ts +1 -9
- package/packages/server/dist/cli/args.js +46 -19
- package/packages/server/dist/cli/paddock.js +20 -39
- package/packages/server/dist/config.d.ts +166 -30
- package/packages/server/dist/config.js +106 -26
- package/packages/server/dist/herdctl-agent-config.d.ts +17 -1
- package/packages/server/dist/herdctl-agent-config.js +69 -7
- package/packages/server/dist/herdctl-agent-names.d.ts +11 -0
- package/packages/server/dist/herdctl-agent-names.js +31 -0
- package/packages/server/dist/herdctl.d.ts +75 -13
- package/packages/server/dist/herdctl.js +77 -18
- package/packages/server/dist/instance-config.js +36 -0
- package/packages/server/dist/mcp-servers.d.ts +187 -0
- package/packages/server/dist/mcp-servers.js +399 -0
- package/packages/server/dist/routes/chats.js +10 -4
- package/packages/server/dist/transcripts.d.ts +61 -30
- package/packages/server/dist/transcripts.js +141 -46
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-BgNXqAox.js → InstanceConfigPage-CCk2hVT9.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DfD_UtqU.js → MarkdownRenderer-B6gST_0U.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-1EXdHXmO.js → ProjectRedirect-BCmKT4mf.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DHb5vNUp.js → ProjectView-CCN98MWu.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-Br-EhLZE.js → ProjectsGrid-CYzuN_yD.js} +1 -1
- package/packages/web/dist/assets/{arc-C2Jekujs.js → arc-BxvtMCZQ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CflCA-em.js → architectureDiagram-3BPJPVTR-Bp6ffJ_u.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Ds7GLNnC.js → blockDiagram-GPEHLZMM-BIY2X8nW.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D4WyGO6I.js → c4Diagram-AAUBKEIU-Br9stEJS.js} +1 -1
- package/packages/web/dist/assets/channel-CMdgNeFH.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-tQunO_M5.js → chunk-2J33WTMH-Djc7uqMV.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DT5dOWd2.js → chunk-4BX2VUAB-yEVMLksS.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CfTUqUEF.js → chunk-55IACEB6-tJaEdPZg.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-sK5bOAA0.js → chunk-727SXJPM-bWEqh672.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DcMgVe-P.js → chunk-AQP2D5EJ-B8U9fB-T.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-In3h_-7v.js → chunk-FMBD7UC4-BeEITYig.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DuaIeRaC.js → chunk-ND2GUHAM-L2rpLaoK.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DeT5mR5T.js → chunk-QZHKN3VN-qqzPMZL_.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +2 -0
- package/packages/web/dist/assets/{core-DgOgLvnM.js → core-CSaNbmLO.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CyO-rznJ.js → cose-bilkent-S5V4N54A-BRF7Nech.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-B_9Ihzgn.js → dagre-BM42HDAG-D10rJWOR.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-z5mvZ5YT.js → diagram-2AECGRRQ-DGjT5mAO.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Bsz0cgfn.js → diagram-5GNKFQAL-DaH3bxHh.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-B29k5f4q.js → diagram-KO2AKTUF-QrLW9Wn6.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CFrwsHoI.js → diagram-LMA3HP47-BX15wgYN.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-D9Sjc2HR.js → diagram-OG6HWLK6-DXQ_p63s.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C3vbkD7M.js → erDiagram-TEJ5UH35-C_ioRruE.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DkMIgntd.js → flowDiagram-I6XJVG4X-7d-xLXLM.js} +1 -1
- package/packages/web/dist/assets/{format-BFDqyFuw.js → format-BSDZezwD.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-nvHanjpW.js → ganttDiagram-6RSMTGT7-DZYx5xcj.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DbCNfOu7.js → gitGraphDiagram-PVQCEYII-dimo-osm.js} +1 -1
- package/packages/web/dist/assets/{index-DGCs1YIO.js → index-CUnFKMga.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-aJjH9x3D.js → infoDiagram-5YYISTIA-BzApY25x.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DoifwNnZ.js → ishikawaDiagram-YF4QCWOH-DtX47_9p.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Cz9kG9HN.js → journeyDiagram-JHISSGLW-C7fITKg1.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BoXnfr7A.js → kanban-definition-UN3LZRKU-BOrWLf_W.js} +1 -1
- package/packages/web/dist/assets/{linear-BlTTO9yR.js → linear-DuyAaBu6.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-YAG8eSPQ.js → mermaid.core-BQlzuVvO.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DtUBKfkr.js → mindmap-definition-RKZ34NQL-CmP748B5.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-B3Rwpmy0.js → pieDiagram-4H26LBE5-CRd5Pdfq.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B8eJtTyk.js → quadrantDiagram-W4KKPZXB-DpJm0Yw_.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CpAluiY9.js → requirementDiagram-4Y6WPE33-B0o2PRMC.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js → sankeyDiagram-5OEKKPKP-CBi3Ek3v.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CRpqKSGm.js → sequenceDiagram-3UESZ5HK-C1stxfQg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-h3h8gXv_.js → stateDiagram-AJRCARHV-lCkiETWK.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-PVr5V_pG.js → timeline-definition-PNZ67QCA-D3DkWJMC.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-De-4NPAX.js → vennDiagram-CIIHVFJN-CO2-yC7K.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Dtv10D2d.js → wardley-L42UT6IY-Dfjp0kqX.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-p2fJuQ6C.js → wardleyDiagram-YWT4CUSO-CIl0OPwS.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DyYF-k92.js → xychartDiagram-2RQKCTM6-CuvlQuup.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/server/dist/cli/claude-home-choice.d.ts +0 -44
- package/packages/server/dist/cli/claude-home-choice.js +0 -108
- package/packages/web/dist/assets/channel-B1K_gwAH.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +0 -2
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* READ-ONLY source. This module owns everything that follows from that:
|
|
16
16
|
*
|
|
17
17
|
* - **{@link ensureClaudeHome}** — create the home, bridge the user-level config
|
|
18
|
-
* that lives in `~/.claude`,
|
|
18
|
+
* that lives in `~/.claude`, put `claude.credentials` into the environment
|
|
19
|
+
* Claude Code runs in, and report anything an operator needs to know.
|
|
19
20
|
* - **{@link mirrorLegacyTranscriptFolders}** — keep adoption (#588) able to see
|
|
20
21
|
* the user's own CLI history, which by definition is in `~/.claude`.
|
|
21
22
|
*
|
|
@@ -30,39 +31,74 @@
|
|
|
30
31
|
* what a user wants; it is not paddock relocating their data behind their back.
|
|
31
32
|
* The thing #620 exists to stop — `ensureProjectChats` copying a user's
|
|
32
33
|
* transcripts out and then `fs.rm`-ing the originals, unprompted, inside a bare
|
|
33
|
-
* `catch {}` — is gone,
|
|
34
|
+
* `catch {}` — is gone: paddock's home is never the user's, so the directory it
|
|
35
|
+
* migrates can only be its own (#691, `resolveClaudeHome`).
|
|
36
|
+
*
|
|
37
|
+
* The one thing that DOES write under `~/.claude` is `claude.transcripts: host`,
|
|
38
|
+
* which creates the encoded folder its symlink points at. That is the user
|
|
39
|
+
* asking for their transcripts to be shared, by name, in a config file — see
|
|
40
|
+
* `ensureProjectChats`.
|
|
34
41
|
*/
|
|
35
42
|
import { promises as fs } from "node:fs";
|
|
43
|
+
import { createHash } from "node:crypto";
|
|
36
44
|
import path from "node:path";
|
|
37
45
|
import { encodePathForCli } from "@herdctl/core";
|
|
46
|
+
import { applyCredentialsMode, SECURE_STORAGE_DIR_VAR } from "./claude-credentials.js";
|
|
47
|
+
import { INSTRUCTION_ENTRIES } from "./claude-instructions.js";
|
|
48
|
+
import { planHostSettings, GENERATED_MARKER_FILE, SETTINGS_ENTRY, } from "./claude-settings.js";
|
|
49
|
+
/**
|
|
50
|
+
* The user's file-based Claude Code login, bridged in only under
|
|
51
|
+
* `claude.credentials: host` (#691).
|
|
52
|
+
*
|
|
53
|
+
* This is what the lever means on Linux and in the Docker image, where `claude
|
|
54
|
+
* login` writes a file rather than using a keychain: on darwin `credentials: host`
|
|
55
|
+
* is the `CLAUDE_SECURESTORAGE_CONFIG_DIR` empty-string trick, here it is this
|
|
56
|
+
* symlink, and one config key has to mean the same thing on both.
|
|
57
|
+
*
|
|
58
|
+
* Symlinked rather than copied — a copy would duplicate a live secret onto disk
|
|
59
|
+
* AND diverge the moment Claude Code refreshed the token.
|
|
60
|
+
*
|
|
61
|
+
* It was bridged unconditionally before the lever existed, which is why
|
|
62
|
+
* `credentials` defaulting to `host` keeps every existing install working
|
|
63
|
+
* unchanged; `own` is the new behaviour, and it has to also REMOVE a link a
|
|
64
|
+
* previous boot planted, or "isolated" would be a claim that only holds for
|
|
65
|
+
* instances that were never anything else.
|
|
66
|
+
*/
|
|
67
|
+
export const CREDENTIAL_ENTRY = ".credentials.json";
|
|
38
68
|
/**
|
|
39
|
-
* Entries of
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
69
|
+
* Entries of the user's Claude home that paddock can bridge into its own — the
|
|
70
|
+
* complete list, for callers that want to reason about the surface rather than
|
|
71
|
+
* about one lever.
|
|
72
|
+
*
|
|
73
|
+
* There is no longer an "always bridged" set: since #691 step 4, every entry here
|
|
74
|
+
* belongs to exactly one key of the `claude:` block, and the mapping is the whole
|
|
75
|
+
* point of the block.
|
|
76
|
+
*
|
|
77
|
+
* | Entry | Lever |
|
|
78
|
+
* |---|---|
|
|
79
|
+
* | `CLAUDE.md`, `agents/`, `commands/`, `plugins/` | `claude.instructions` |
|
|
80
|
+
* | `settings.json` (its `hooks` key) | `claude.hooks` |
|
|
81
|
+
* | `.credentials.json` | `claude.credentials` |
|
|
82
|
+
*
|
|
83
|
+
* `settings.json` appears under `hooks` because that is the half of it a lever
|
|
84
|
+
* governs; its OTHER keys — permissions, model, statusline, `enabledPlugins` —
|
|
85
|
+
* are inherited in every mode, which is why `hooks: own` writes a filtered file
|
|
86
|
+
* rather than declining to bridge one. See `claude-settings.ts`.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately NOT bridged in any mode: `projects/` (that's the whole point), and
|
|
89
|
+
* everything that is per-instance runtime state — `todos/`, `shell-snapshots/`,
|
|
90
|
+
* `statsig/`, `sessions/`. Those are exactly what should be per-instance.
|
|
91
|
+
*
|
|
92
|
+
* The fifth lever, `claude.mcpServers`, has NO row here on purpose: MCP servers
|
|
93
|
+
* are declared in `~/.claude.json`, which is a sibling of the home rather than an
|
|
94
|
+
* entry inside it, so it is not bridgeable at all — paddock reads it and passes
|
|
95
|
+
* the servers programmatically (`claude-mcp.ts`). That asymmetry is the reason
|
|
96
|
+
* MCP inheritance broke separately from everything in this table.
|
|
58
97
|
*/
|
|
59
|
-
export const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"agents",
|
|
64
|
-
"commands",
|
|
65
|
-
"plugins",
|
|
98
|
+
export const BRIDGEABLE_ENTRIES = [
|
|
99
|
+
SETTINGS_ENTRY,
|
|
100
|
+
...INSTRUCTION_ENTRIES,
|
|
101
|
+
CREDENTIAL_ENTRY,
|
|
66
102
|
];
|
|
67
103
|
/** Env vars that authenticate Claude Code without any on-disk credential. */
|
|
68
104
|
const TOKEN_ENV_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
|
@@ -74,9 +110,13 @@ const TOKEN_ENV_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROP
|
|
|
74
110
|
* home can already see. What we are looking for is the login made before paddock
|
|
75
111
|
* was involved.
|
|
76
112
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
113
|
+
* The first name is now **confirmed exact**, not guessed (#691): the SDK bundle
|
|
114
|
+
* builds it as `` `Claude Code${OAUTH_FILE_SUFFIX}${"-credentials"}${hash}` ``,
|
|
115
|
+
* with an empty production suffix and no hash when the config dir is unscoped —
|
|
116
|
+
* see `claude-credentials.ts`, which quotes the function. The second is kept as a
|
|
117
|
+
* cheap hedge against an older or future spelling; it costs one `security` call
|
|
118
|
+
* that fails fast, and {@link probeMacosKeychainLogin} is built so a miss is
|
|
119
|
+
* harmless either way.
|
|
80
120
|
*/
|
|
81
121
|
const KEYCHAIN_SERVICE_NAMES = ["Claude Code-credentials", "Claude Code"];
|
|
82
122
|
/**
|
|
@@ -90,15 +130,24 @@ const KEYCHAIN_SERVICE_NAMES = ["Claude Code-credentials", "Claude Code"];
|
|
|
90
130
|
* ## The asymmetry, which is deliberate
|
|
91
131
|
*
|
|
92
132
|
* A positive result is trustworthy: an entry under that service name is a login.
|
|
93
|
-
* A negative result is NOT —
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
133
|
+
* A negative result is NOT — `security` can fail for a dozen reasons that have
|
|
134
|
+
* nothing to do with the user, and the service name, though now read straight out
|
|
135
|
+
* of the SDK bundle rather than guessed, is still Claude Code's internal detail
|
|
136
|
+
* and can change. So a miss returns `unknown`, and the caller falls back to the
|
|
137
|
+
* existing, correct, generic warning. Being wrong about the name therefore costs
|
|
138
|
+
* nothing beyond the improvement not happening.
|
|
98
139
|
*
|
|
99
140
|
* Never prompts and never reads a secret: `find-generic-password` WITHOUT `-w`
|
|
100
141
|
* reports existence only (attributes, which are discarded), so nothing sensitive
|
|
101
142
|
* reaches a log, a transcript, or this process's memory.
|
|
143
|
+
*
|
|
144
|
+
* One known imprecision, left in on purpose: Claude Code's own lookup also passes
|
|
145
|
+
* `-a` (`process.env.USER`, falling back to the OS username), and this does not.
|
|
146
|
+
* So an entry filed under a DIFFERENT account still reads as `found` here while
|
|
147
|
+
* Claude Code would miss it — a service running under a different `USER` than the
|
|
148
|
+
* terminal that logged in. Matching that exactly would trade a rare false
|
|
149
|
+
* positive for a rarer false negative, and a false negative is the one that costs
|
|
150
|
+
* something: it downgrades a specific instruction back to the generic warning.
|
|
102
151
|
*/
|
|
103
152
|
export async function probeMacosKeychainLogin(platform = process.platform) {
|
|
104
153
|
if (platform !== "darwin")
|
|
@@ -124,18 +173,251 @@ async function exists(p) {
|
|
|
124
173
|
.catch(() => false);
|
|
125
174
|
}
|
|
126
175
|
/**
|
|
127
|
-
*
|
|
176
|
+
* Symlink one entry of the user's home into paddock's, if paddock's does not
|
|
177
|
+
* already have that name. Returns whether a link was planted THIS call.
|
|
178
|
+
*
|
|
179
|
+
* Non-clobbering by `lstat`, not `stat`: a bridge symlink planted on a previous
|
|
180
|
+
* boot whose target has since been removed still occupies the name, and
|
|
181
|
+
* re-linking it would be a no-op churn at best. Never throws — a race with
|
|
182
|
+
* another instance, or a read-only home, is not a reason to fail a boot.
|
|
183
|
+
*/
|
|
184
|
+
async function bridgeEntry(cfg, entry) {
|
|
185
|
+
const target = path.join(cfg.legacyClaudeHome, entry);
|
|
186
|
+
const link = path.join(cfg.claudeHome, entry);
|
|
187
|
+
const occupied = await fs
|
|
188
|
+
.lstat(link)
|
|
189
|
+
.then(() => true)
|
|
190
|
+
.catch(() => false);
|
|
191
|
+
if (occupied)
|
|
192
|
+
return false;
|
|
193
|
+
if (!(await exists(target)))
|
|
194
|
+
return false;
|
|
195
|
+
return fs.symlink(target, link).then(() => true, () => false);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Undo {@link bridgeEntry} for one entry: remove it IF it is a symlink pointing
|
|
199
|
+
* into the user's home. Returns whether anything was removed.
|
|
200
|
+
*
|
|
201
|
+
* Deliberately narrow. A real file at that name is this instance's own — an
|
|
202
|
+
* operator's, or the product of a `CLAUDE_CONFIG_DIR=<own home> claude login` —
|
|
203
|
+
* and removing it would be paddock deleting a credential nobody asked it to
|
|
204
|
+
* touch. A symlink pointing somewhere OTHER than the user's home is not ours
|
|
205
|
+
* either. Only a link paddock itself would have planted is withdrawn, and the
|
|
206
|
+
* withdrawal happens inside paddock's own home, so the "never write to
|
|
207
|
+
* `~/.claude`" invariant is untouched.
|
|
208
|
+
*/
|
|
209
|
+
async function unbridgeEntry(cfg, entry) {
|
|
210
|
+
const link = path.join(cfg.claudeHome, entry);
|
|
211
|
+
const target = await fs.readlink(link).catch(() => null);
|
|
212
|
+
if (target === null)
|
|
213
|
+
return false; // absent, or a real file: not ours
|
|
214
|
+
const resolved = path.resolve(path.dirname(link), target);
|
|
215
|
+
if (resolved !== path.resolve(cfg.legacyClaudeHome, entry))
|
|
216
|
+
return false;
|
|
217
|
+
return fs.unlink(link).then(() => true, () => false);
|
|
218
|
+
}
|
|
219
|
+
const sha256 = (content) => createHash("sha256").update(content, "utf8").digest("hex");
|
|
220
|
+
/** Read the entry → sha256 map of files paddock generated. Never throws. */
|
|
221
|
+
async function readGeneratedHashes(home) {
|
|
222
|
+
const raw = await fs.readFile(path.join(home, GENERATED_MARKER_FILE), "utf8").catch(() => null);
|
|
223
|
+
if (raw === null)
|
|
224
|
+
return {};
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(raw);
|
|
227
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
228
|
+
return {};
|
|
229
|
+
return parsed;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Record (or, with `null`, forget) the hash of a file paddock generated. */
|
|
236
|
+
async function recordGenerated(home, entry, hash) {
|
|
237
|
+
const hashes = await readGeneratedHashes(home);
|
|
238
|
+
if (hash === null) {
|
|
239
|
+
if (!(entry in hashes))
|
|
240
|
+
return;
|
|
241
|
+
delete hashes[entry];
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (hashes[entry] === hash)
|
|
245
|
+
return;
|
|
246
|
+
hashes[entry] = hash;
|
|
247
|
+
}
|
|
248
|
+
const marker = path.join(home, GENERATED_MARKER_FILE);
|
|
249
|
+
await fs.writeFile(marker, `${JSON.stringify(hashes, null, 2)}\n`, "utf8").catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Decide whether paddock may replace `<ownHome>/<entry>`.
|
|
253
|
+
*
|
|
254
|
+
* The `generated` case is the one that is new (#691 step 4) and the one the
|
|
255
|
+
* whole copy-and-filter mechanism rests on. A symlink needs no such question —
|
|
256
|
+
* paddock can always recognise its own by where it points — but a real file it
|
|
257
|
+
* wrote is indistinguishable from a real file a human wrote, and getting that
|
|
258
|
+
* wrong in either direction is bad: refuse to regenerate and the filtered copy
|
|
259
|
+
* is stale forever, regenerate anyway and paddock silently overwrites an
|
|
260
|
+
* operator's settings. Hashing what we wrote and comparing on the next boot
|
|
261
|
+
* answers it exactly, and answers `foreign` — the safe way — for any file we
|
|
262
|
+
* did not write or that has been edited since.
|
|
263
|
+
*/
|
|
264
|
+
async function classifyOwnEntry(cfg, entry) {
|
|
265
|
+
const own = path.join(cfg.claudeHome, entry);
|
|
266
|
+
const st = await fs.lstat(own).catch(() => null);
|
|
267
|
+
if (st === null)
|
|
268
|
+
return "absent";
|
|
269
|
+
if (st.isSymbolicLink()) {
|
|
270
|
+
const raw = await fs.readlink(own).catch(() => null);
|
|
271
|
+
if (raw === null)
|
|
272
|
+
return "foreign";
|
|
273
|
+
const resolved = path.resolve(path.dirname(own), raw);
|
|
274
|
+
return resolved === path.resolve(cfg.legacyClaudeHome, entry) ? "bridge" : "foreign";
|
|
275
|
+
}
|
|
276
|
+
const content = await fs.readFile(own, "utf8").catch(() => null);
|
|
277
|
+
if (content === null)
|
|
278
|
+
return "foreign";
|
|
279
|
+
const recorded = (await readGeneratedHashes(cfg.claudeHome))[entry];
|
|
280
|
+
return recorded !== undefined && recorded === sha256(content) ? "generated" : "foreign";
|
|
281
|
+
}
|
|
282
|
+
/** Remove a file paddock generated, and forget it. Returns whether it went. */
|
|
283
|
+
async function removeGenerated(home, entry) {
|
|
284
|
+
const ok = await fs.rm(path.join(home, entry), { force: true }).then(() => true, () => false);
|
|
285
|
+
if (ok)
|
|
286
|
+
await recordGenerated(home, entry, null);
|
|
287
|
+
return ok;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Put the right `settings.json` in paddock's home for the `claude.hooks` mode
|
|
291
|
+
* (#691 step 4).
|
|
292
|
+
*
|
|
293
|
+
* Four states have to compose, and all four are reachable by editing one config
|
|
294
|
+
* key on a running instance:
|
|
295
|
+
*
|
|
296
|
+
* | own home has | `hooks: host` | `hooks: own` |
|
|
297
|
+
* |---|---|---|
|
|
298
|
+
* | nothing | symlink the user's | symlink, or write the filtered copy |
|
|
299
|
+
* | paddock's bridge symlink | leave it | replace with the filtered copy |
|
|
300
|
+
* | paddock's generated file | delete it, symlink | regenerate from the user's |
|
|
301
|
+
* | a human's file | leave it | leave it, and say so if hooks were in play |
|
|
128
302
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
303
|
+
* The last row is the existing non-clobbering bridge behaviour, extended to a
|
|
304
|
+
* file rather than a name — with one addition, because silence there is
|
|
305
|
+
* dangerous: if the user's settings define hooks and paddock is not allowed to
|
|
306
|
+
* write the filtered copy, `hooks: own` is NOT in force for whatever that file
|
|
307
|
+
* says, so it warns rather than leaving an operator to believe a guarantee that
|
|
308
|
+
* is not holding.
|
|
309
|
+
*
|
|
310
|
+
* Never throws; a home it cannot write just keeps whatever it had.
|
|
311
|
+
*/
|
|
312
|
+
async function materializeSettings(cfg, hooks, report) {
|
|
313
|
+
const entry = SETTINGS_ENTRY;
|
|
314
|
+
const own = path.join(cfg.claudeHome, entry);
|
|
315
|
+
const state = await classifyOwnEntry(cfg, entry);
|
|
316
|
+
const raw = await fs.readFile(path.join(cfg.legacyClaudeHome, entry), "utf8").catch(() => null);
|
|
317
|
+
if (raw === null) {
|
|
318
|
+
// The user has no settings.json at all — including the case where they
|
|
319
|
+
// deleted one paddock had already bridged or filtered. Withdraw ours.
|
|
320
|
+
if (state === "bridge" && (await unbridgeEntry(cfg, entry)))
|
|
321
|
+
report.withdrawn.push(entry);
|
|
322
|
+
else if (state === "generated" && (await removeGenerated(cfg.claudeHome, entry)))
|
|
323
|
+
report.withdrawn.push(entry);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const plan = planHostSettings(hooks, raw);
|
|
327
|
+
if (state === "foreign") {
|
|
328
|
+
if (plan.action === "write") {
|
|
329
|
+
report.notices.push({
|
|
330
|
+
level: "warn",
|
|
331
|
+
message: `\`claude.hooks: own\` cannot take effect: ${own} is a file paddock did not write, ` +
|
|
332
|
+
`so it is left alone (as any entry of your own always is). Your ` +
|
|
333
|
+
`~/.claude/${entry} defines hooks, and whatever THAT file says is what runs. ` +
|
|
334
|
+
`Move or delete it to let paddock generate the filtered copy.`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (plan.action === "skip") {
|
|
340
|
+
// Fail closed: a settings.json that will not parse cannot be filtered, and
|
|
341
|
+
// linking it instead would hand over exactly the hooks `own` withholds.
|
|
342
|
+
if (state === "bridge" && (await unbridgeEntry(cfg, entry)))
|
|
343
|
+
report.withdrawn.push(entry);
|
|
344
|
+
else if (state === "generated" && (await removeGenerated(cfg.claudeHome, entry)))
|
|
345
|
+
report.withdrawn.push(entry);
|
|
346
|
+
report.notices.push({
|
|
347
|
+
level: "warn",
|
|
348
|
+
message: `not using your ~/.claude/${entry}: ${plan.reason}. Paddock could not filter it, and ` +
|
|
349
|
+
`\`claude.hooks: own\` means it must not be used unfiltered — so this instance runs ` +
|
|
350
|
+
`with no user-level settings at all. Fix the JSON and restart.`,
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (plan.action === "link") {
|
|
355
|
+
// Nothing to filter (no hooks, or `hooks: host`): the symlink is strictly
|
|
356
|
+
// better than a copy, so prefer it even under `own`. Only users who actually
|
|
357
|
+
// have hooks pay the staleness of a copy.
|
|
358
|
+
if (state === "generated")
|
|
359
|
+
await removeGenerated(cfg.claudeHome, entry);
|
|
360
|
+
if (await bridgeEntry(cfg, entry))
|
|
361
|
+
report.bridged.push(entry);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (state === "bridge")
|
|
365
|
+
await fs.unlink(own).catch(() => { });
|
|
366
|
+
const existing = state === "generated" ? await fs.readFile(own, "utf8").catch(() => null) : null;
|
|
367
|
+
if (existing !== plan.content) {
|
|
368
|
+
const ok = await fs.writeFile(own, plan.content, "utf8").then(() => true, () => false);
|
|
369
|
+
if (!ok) {
|
|
370
|
+
report.notices.push({
|
|
371
|
+
level: "warn",
|
|
372
|
+
message: `could not write ${own}, so \`claude.hooks: own\` is not in force — this instance ` +
|
|
373
|
+
`has no user-level settings rather than filtered ones.`,
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
report.generated.push(entry);
|
|
378
|
+
}
|
|
379
|
+
await recordGenerated(cfg.claudeHome, entry, sha256(plan.content));
|
|
380
|
+
report.notices.push({
|
|
381
|
+
level: "info",
|
|
382
|
+
message: `Claude hooks: your ~/.claude/${entry} defines ${plan.dropped.join(", ")}, which this ` +
|
|
383
|
+
`instance does NOT run (\`claude.hooks: own\`). Paddock writes its own ${entry} carrying ` +
|
|
384
|
+
`every other key of yours — permissions, model, statusline — and regenerates it at ` +
|
|
385
|
+
`startup, so restart paddock after editing yours. Set \`claude.hooks: host\` to run them.`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Create paddock's Claude home and put the user-level config into it that this
|
|
390
|
+
* instance's `claude:` levers say it should have (#691).
|
|
391
|
+
*
|
|
392
|
+
* Idempotent and non-clobbering: a real file the user put in paddock's home is
|
|
393
|
+
* left completely alone, so this is safe to run on every start. What it DOES
|
|
394
|
+
* maintain is what paddock itself planted — bridge symlinks, withdrawn when a
|
|
395
|
+
* lever flips to `own`, and the generated `settings.json`, regenerated from the
|
|
396
|
+
* user's current one. Ownership of a generated file is decided by hash
|
|
397
|
+
* ({@link classifyOwnEntry}), never by "it is at a name we use".
|
|
132
398
|
*
|
|
133
399
|
* Never throws. A home that cannot be created is not a reason to refuse to boot
|
|
134
400
|
* — Claude Code falls back to its own default and chat still works, which is the
|
|
135
401
|
* same failure mode `ensureProjectChats` has always had.
|
|
402
|
+
*
|
|
403
|
+
* **Mutates `env`** (`process.env` by default) to carry `claude.credentials` into
|
|
404
|
+
* the environment Claude Code runs in — see {@link applyCredentialsMode} for why
|
|
405
|
+
* that is the only seam available and why it is safe process-wide. Done here
|
|
406
|
+
* because this is already the one call that runs before the fleet starts, and
|
|
407
|
+
* because the credential warning below has to describe the environment it just
|
|
408
|
+
* set rather than the one it found.
|
|
136
409
|
*/
|
|
137
410
|
export async function ensureClaudeHome(cfg, env = process.env, probeKeychain = probeMacosKeychainLogin) {
|
|
138
|
-
const report = { bridged: [], notices: [] };
|
|
411
|
+
const report = { bridged: [], withdrawn: [], generated: [], notices: [] };
|
|
412
|
+
const credentials = cfg.claude.credentials;
|
|
413
|
+
const decision = applyCredentialsMode(credentials, env);
|
|
414
|
+
if (decision.action === "deferred") {
|
|
415
|
+
report.notices.push({
|
|
416
|
+
level: "info",
|
|
417
|
+
message: `${SECURE_STORAGE_DIR_VAR} is already set to "${decision.value}"; honouring it and ` +
|
|
418
|
+
`ignoring \`claude.credentials: ${credentials}\`. Unset it to let paddock choose.`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
139
421
|
try {
|
|
140
422
|
await fs.mkdir(path.join(cfg.claudeHome, "projects"), { recursive: true });
|
|
141
423
|
}
|
|
@@ -148,78 +430,122 @@ export async function ensureClaudeHome(cfg, env = process.env, probeKeychain = p
|
|
|
148
430
|
});
|
|
149
431
|
return report;
|
|
150
432
|
}
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
433
|
+
// No "is this home ours?" branch any more (#691): it always is, and a config
|
|
434
|
+
// that resolves the home to `~/.claude` never gets this far — `resolveClaudeHome`
|
|
435
|
+
// refuses to start. What remains is one lever per entry.
|
|
436
|
+
// The login — `claude.credentials`, step 3. Its file half on Linux/Docker.
|
|
437
|
+
if (credentials === "host") {
|
|
438
|
+
if (await bridgeEntry(cfg, CREDENTIAL_ENTRY))
|
|
439
|
+
report.bridged.push(CREDENTIAL_ENTRY);
|
|
440
|
+
}
|
|
441
|
+
else if (await unbridgeEntry(cfg, CREDENTIAL_ENTRY)) {
|
|
442
|
+
report.withdrawn.push(CREDENTIAL_ENTRY);
|
|
154
443
|
report.notices.push({
|
|
155
444
|
level: "info",
|
|
156
|
-
message: `
|
|
445
|
+
message: `removed the bridged ${CREDENTIAL_ENTRY} symlink into ${cfg.legacyClaudeHome}: ` +
|
|
446
|
+
`\`claude.credentials: own\` means this instance uses only its own login. A real ` +
|
|
447
|
+
`${CREDENTIAL_ENTRY} in ${cfg.claudeHome} is left alone.`,
|
|
157
448
|
});
|
|
158
|
-
return report;
|
|
159
449
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const occupied = await fs
|
|
167
|
-
.lstat(link)
|
|
168
|
-
.then(() => true)
|
|
169
|
-
.catch(() => false);
|
|
170
|
-
if (occupied)
|
|
171
|
-
continue;
|
|
172
|
-
if (!(await exists(target)))
|
|
173
|
-
continue;
|
|
174
|
-
try {
|
|
175
|
-
await fs.symlink(target, link);
|
|
176
|
-
report.bridged.push(entry);
|
|
450
|
+
// CLAUDE.md, agents, commands, plugins — `claude.instructions`, step 4.
|
|
451
|
+
const instructions = cfg.claude.instructions;
|
|
452
|
+
for (const entry of INSTRUCTION_ENTRIES) {
|
|
453
|
+
if (instructions === "host") {
|
|
454
|
+
if (await bridgeEntry(cfg, entry))
|
|
455
|
+
report.bridged.push(entry);
|
|
177
456
|
}
|
|
178
|
-
|
|
179
|
-
|
|
457
|
+
else if (await unbridgeEntry(cfg, entry)) {
|
|
458
|
+
report.withdrawn.push(entry);
|
|
180
459
|
}
|
|
181
460
|
}
|
|
461
|
+
if (instructions === "own") {
|
|
462
|
+
// Named rather than left to be discovered. `own` is the default and it is a
|
|
463
|
+
// real behaviour change for anyone with a curated `~/.claude/CLAUDE.md`:
|
|
464
|
+
// their agents quietly stop knowing things, with no error to search for. So
|
|
465
|
+
// paddock says which of their files it is not using, and which key turns
|
|
466
|
+
// them back on. Only when they HAVE some — otherwise it is noise.
|
|
467
|
+
const present = [];
|
|
468
|
+
for (const entry of INSTRUCTION_ENTRIES) {
|
|
469
|
+
if (await exists(path.join(cfg.legacyClaudeHome, entry)))
|
|
470
|
+
present.push(entry);
|
|
471
|
+
}
|
|
472
|
+
if (present.length > 0) {
|
|
473
|
+
report.notices.push({
|
|
474
|
+
level: "info",
|
|
475
|
+
message: `Claude instructions: this instance's own only (\`claude.instructions: own\`). Your ` +
|
|
476
|
+
`~/.claude ${present.join(", ")} ${present.length === 1 ? "is" : "are"} NOT loaded — ` +
|
|
477
|
+
`set \`claude.instructions: host\` (or PADDOCK_CLAUDE_INSTRUCTIONS=host) to share ` +
|
|
478
|
+
`them. Each project's own CLAUDE.md is unaffected either way.`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// settings.json — `claude.hooks`, step 4. Not a bridge decision: the file is a
|
|
483
|
+
// mixed bag, so `own` writes a filtered copy. See `materializeSettings`.
|
|
484
|
+
await materializeSettings(cfg, cfg.claude.hooks, report);
|
|
182
485
|
report.notices.push({
|
|
183
486
|
level: "info",
|
|
184
487
|
message: `Claude home: ${cfg.claudeHome} (paddock-owned; ~/.claude is read-only)` +
|
|
185
|
-
(report.bridged.length > 0 ? ` — bridged from ~/.claude: ${report.bridged.join(", ")}` : "")
|
|
488
|
+
(report.bridged.length > 0 ? ` — bridged from ~/.claude: ${report.bridged.join(", ")}` : "") +
|
|
489
|
+
(report.generated.length > 0 ? ` — generated: ${report.generated.join(", ")}` : "") +
|
|
490
|
+
(report.withdrawn.length > 0 ? ` — withdrawn: ${report.withdrawn.join(", ")}` : ""),
|
|
186
491
|
});
|
|
187
|
-
// The credential question (#620). Claude Code derives its secure-storage
|
|
492
|
+
// The credential question (#620, #683). Claude Code derives its secure-storage
|
|
188
493
|
// service name from whether `CLAUDE_CONFIG_DIR` is SET AT ALL — unset gets the
|
|
189
|
-
// plain name, set gets a path-hash suffix.
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// file-based login is bridged
|
|
193
|
-
// credential at all, so say so BEFORE the first turn
|
|
194
|
-
// rather than leaving the operator to work it out
|
|
195
|
-
// replies.
|
|
494
|
+
// plain name, set gets a path-hash suffix. Paddock always sets it, so a login
|
|
495
|
+
// held in the macOS Keychain under the plain name is invisible unless
|
|
496
|
+
// `credentials: host` un-scopes it above. A token in the environment is
|
|
497
|
+
// unaffected, and a file-based login is bridged (under `host`); if none of that
|
|
498
|
+
// applies we cannot see a credential at all, so say so BEFORE the first turn
|
|
499
|
+
// fails with "Not logged in" rather than leaving the operator to work it out
|
|
500
|
+
// from a chat that never replies.
|
|
196
501
|
const hasTokenEnv = TOKEN_ENV_VARS.some((v) => (env[v] ?? "").trim().length > 0);
|
|
197
|
-
const hasCredentialFile = await exists(path.join(cfg.claudeHome,
|
|
502
|
+
const hasCredentialFile = await exists(path.join(cfg.claudeHome, CREDENTIAL_ENTRY));
|
|
198
503
|
if (!hasTokenEnv && !hasCredentialFile) {
|
|
199
504
|
// #683: "you have no login" and "your login exists but I changed the service
|
|
200
|
-
// name it is filed under"
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// to the generic wording
|
|
505
|
+
// name it is filed under" were the same message, and only one of them is the
|
|
506
|
+
// user's problem to solve. On darwin we can tell them apart, and a hit either
|
|
507
|
+
// resolves the question outright (`host`) or upgrades a paragraph of
|
|
508
|
+
// explanation into an instruction (`own`). A miss is inconclusive by design
|
|
509
|
+
// (see probeMacosKeychainLogin), so it falls through to the generic wording
|
|
510
|
+
// rather than asserting anything.
|
|
205
511
|
const keychain = await probeKeychain();
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
`
|
|
214
|
-
`
|
|
215
|
-
`
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
512
|
+
if (keychain === "found" && credentials === "host") {
|
|
513
|
+
// Not a problem: this is the lever working. Said out loud anyway, because
|
|
514
|
+
// "paddock found no credentials of its own and is using yours" is exactly
|
|
515
|
+
// the kind of thing an operator should not have to deduce.
|
|
516
|
+
report.notices.push({
|
|
517
|
+
level: "info",
|
|
518
|
+
message: `Claude login: this machine's own, from the macOS Keychain ` +
|
|
519
|
+
`(\`claude.credentials: host\`). Paddock sets ${SECURE_STORAGE_DIR_VAR}="" so Claude ` +
|
|
520
|
+
`Code reads the same unsuffixed entry your own \`claude\` does, while its config dir ` +
|
|
521
|
+
`stays at ${cfg.claudeHome}. Set \`claude.credentials: own\` to use only a login of ` +
|
|
522
|
+
`this instance's.`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
report.notices.push({
|
|
527
|
+
level: "warn",
|
|
528
|
+
message: keychain === "found"
|
|
529
|
+
? `you ARE logged in to Claude Code, but paddock cannot see it. The login is in the ` +
|
|
530
|
+
`macOS Keychain, and Claude Code files it under a service name derived from ` +
|
|
531
|
+
`whether CLAUDE_CONFIG_DIR is set — paddock sets it, to ${cfg.claudeHome}, so it ` +
|
|
532
|
+
`looks under a different name. \`claude.credentials: own\` is what keeps it that ` +
|
|
533
|
+
`way. Fix it either way: drop that key (the default is \`host\`, which shares this ` +
|
|
534
|
+
`machine's login and writes nothing), or log in for this home too with ` +
|
|
535
|
+
`\`CLAUDE_CONFIG_DIR=${cfg.claudeHome} claude login\`.`
|
|
536
|
+
: `no Claude credentials found for ${cfg.claudeHome}: no ${CREDENTIAL_ENTRY} and no ` +
|
|
537
|
+
`${TOKEN_ENV_VARS.join("/")} in the environment` +
|
|
538
|
+
(credentials === "host"
|
|
539
|
+
? ` — and \`claude.credentials: host\` found nothing on this machine to share ` +
|
|
540
|
+
`either (no ~/.claude/${CREDENTIAL_ENTRY}, and no macOS Keychain login under ` +
|
|
541
|
+
`the name Claude Code uses). `
|
|
542
|
+
: `, and \`claude.credentials: own\` means this instance shares none of yours. ` +
|
|
543
|
+
`Claude Code scopes its credential store to CLAUDE_CONFIG_DIR, so a login in ` +
|
|
544
|
+
`the OS keychain against the default home will NOT be found here. `) +
|
|
545
|
+
`If turns fail with "Not logged in", re-run \`claude login\` with ` +
|
|
546
|
+
`CLAUDE_CONFIG_DIR=${cfg.claudeHome}, or put a token in the environment.`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
223
549
|
}
|
|
224
550
|
return report;
|
|
225
551
|
}
|
|
@@ -274,6 +600,14 @@ export function legacySourcePath(legacyHome, folderName) {
|
|
|
274
600
|
* mirroring them would re-offer paddock's own chats as "adoptable". That also
|
|
275
601
|
* makes the leftovers from before this change inert rather than confusing.
|
|
276
602
|
*
|
|
603
|
+
* A folder paddock's own home ALREADY links to directly is skipped for the same
|
|
604
|
+
* reason. That is the `claude.transcripts: host` case (#691): there the folder in
|
|
605
|
+
* the user's home is a real directory that paddock itself writes through, so
|
|
606
|
+
* mirroring it would offer every project's own live chats back as importable —
|
|
607
|
+
* the #658 mistake with the arrow reversed. (Adoption de-duplicates sources by
|
|
608
|
+
* resolved real path, so this was noise rather than breakage; it is skipped here
|
|
609
|
+
* because a link paddock plants and then mirrors is confusing to find on disk.)
|
|
610
|
+
*
|
|
277
611
|
* Idempotent and never throws. Returns every mirror it maintains, not only the
|
|
278
612
|
* ones planted this call, so a caller can rely on the mapping after any call.
|
|
279
613
|
*/
|
|
@@ -293,6 +627,12 @@ export async function mirrorLegacyTranscriptFolders(claudeHome, legacyHome) {
|
|
|
293
627
|
const st = await fs.lstat(legacyDir).catch(() => null);
|
|
294
628
|
if (!st?.isDirectory())
|
|
295
629
|
continue; // skips files AND paddock's old symlinks
|
|
630
|
+
// Already reachable directly from paddock's own home (`transcripts: host`)?
|
|
631
|
+
// Then it is not an outside source to import; it is where this instance's
|
|
632
|
+
// own chats live.
|
|
633
|
+
const direct = await fs.readlink(path.join(to, folderName)).catch(() => null);
|
|
634
|
+
if (direct !== null && path.resolve(to, direct) === path.resolve(legacyDir))
|
|
635
|
+
continue;
|
|
296
636
|
const engineCwd = legacySourcePath(legacyHome, folderName);
|
|
297
637
|
// encodePathForCli, not a local encoder: this name must be byte-identical
|
|
298
638
|
// to what the engine derives from `engineCwd`, including the truncate+hash
|