@edspencer/paddock 0.62.0 → 0.64.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 +32 -4
- package/package.json +2 -2
- package/packages/server/dist/adoptable.d.ts +18 -0
- package/packages/server/dist/adoptable.js +1 -1
- package/packages/server/dist/app.js +17 -2
- package/packages/server/dist/claude-home.js +9 -1
- package/packages/server/dist/claude-mcp.d.ts +16 -4
- package/packages/server/dist/claude-mcp.js +83 -61
- package/packages/server/dist/claude-plugins.d.ts +131 -0
- package/packages/server/dist/claude-plugins.js +394 -0
- package/packages/server/dist/cli/args.d.ts +1 -1
- package/packages/server/dist/cli/args.js +9 -0
- package/packages/server/dist/cli/import-chats.js +1 -1
- package/packages/server/dist/git.d.ts +44 -1
- package/packages/server/dist/git.js +79 -11
- package/packages/server/dist/herdctl-agent-config.d.ts +7 -1
- package/packages/server/dist/herdctl-agent-config.js +20 -4
- package/packages/server/dist/herdctl.d.ts +7 -1
- package/packages/server/dist/herdctl.js +9 -2
- package/packages/server/dist/management-ops.js +2 -1
- package/packages/server/dist/mcp-servers.d.ts +31 -12
- package/packages/server/dist/mcp-servers.js +127 -33
- package/packages/server/dist/project-paths.d.ts +72 -4
- package/packages/server/dist/project-paths.js +76 -4
- package/packages/server/dist/project-types.d.ts +109 -11
- package/packages/server/dist/projects.d.ts +161 -3
- package/packages/server/dist/projects.js +466 -64
- package/packages/server/dist/routes/git.js +6 -3
- package/packages/server/dist/routes/projects.js +16 -2
- package/packages/server/dist/self-mcp-types.d.ts +6 -4
- package/packages/server/dist/sweep.js +10 -5
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-Ca905oey.js → InstanceConfigPage-Blr1a-hx.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-ChlC1ezH.js → MarkdownRenderer-R6YzfI9Y.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CslqbhUb.js → ProjectRedirect-Cy-7Thc-.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DkNdilGo.js → ProjectView-bMX4GEau.js} +9 -9
- package/packages/web/dist/assets/{ProjectsGrid-BrlvLc-Y.js → ProjectsGrid-C9XHf4eS.js} +1 -1
- package/packages/web/dist/assets/{arc-CGqCI5nQ.js → arc-B_42F7Yk.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Cp4KTxE2.js → architectureDiagram-3BPJPVTR-BAXzSOyw.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-yFxaTv04.js → blockDiagram-GPEHLZMM-BdYEdA-3.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-59lhWjRt.js → c4Diagram-AAUBKEIU-Cc4o15R5.js} +1 -1
- package/packages/web/dist/assets/channel-BZnRb_8C.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-BrpXGnJg.js → chunk-2J33WTMH-CeuxHjIm.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-UxBDb5pi.js → chunk-4BX2VUAB-DnZfrAaV.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-D4ZJyUHW.js → chunk-55IACEB6-DFE6UYOT.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DWdPj_8E.js → chunk-727SXJPM-YVHQ5cHc.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BIpNbflY.js → chunk-AQP2D5EJ-5BYSbg-F.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-f6iJDfdI.js → chunk-FMBD7UC4-HBcelndR.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-Bz3TKIJk.js → chunk-ND2GUHAM-2AwEsdxJ.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-buQmkafK.js → chunk-QZHKN3VN-BPpaZ6ct.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +2 -0
- package/packages/web/dist/assets/{core-TyFI6p0n.js → core-Cu_4vw1U.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CIkdi0XF.js → cose-bilkent-S5V4N54A-DWkF0NrF.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BnLJCfNs.js → dagre-BM42HDAG-DTgNmO9I.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-D0OqfDB9.js → diagram-2AECGRRQ-DC28b7YS.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-DQ2spGrK.js → diagram-5GNKFQAL-_WvuLno6.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-RDIVQ_S3.js → diagram-KO2AKTUF-BEYxPfWq.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-DKoTfh2C.js → diagram-LMA3HP47-Biyev7x0.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-6k0OuSAL.js → diagram-OG6HWLK6-u47NLW3S.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-B-oQaYAC.js → erDiagram-TEJ5UH35-Bud2ISlK.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B7qpYe9C.js → flowDiagram-I6XJVG4X-D-18vLQ1.js} +1 -1
- package/packages/web/dist/assets/{format-PHdEBNOi.js → format-CQ2BDlby.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-SfBsqMmx.js → ganttDiagram-6RSMTGT7-CzyRX39N.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CuM4Rl_i.js → gitGraphDiagram-PVQCEYII-DLogfrjg.js} +1 -1
- package/packages/web/dist/assets/{index-D_1ogxg1.js → index-B0gJ0qWe.js} +14 -14
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-Dq-32Oj4.js → infoDiagram-5YYISTIA-OgNPt6RG.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BLFHCj_o.js → ishikawaDiagram-YF4QCWOH-CrM0t0_X.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CHP-Yu63.js → journeyDiagram-JHISSGLW-xbxfHfwR.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DRuehSqn.js → kanban-definition-UN3LZRKU-DpszBaZb.js} +1 -1
- package/packages/web/dist/assets/{linear-Bwz2vnH7.js → linear-BLujdbjQ.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-CeMMnCip.js → mermaid.core-DzEhy9Qq.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CEdqeoRW.js → mindmap-definition-RKZ34NQL-DaH3Lmoo.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BdDXBx87.js → pieDiagram-4H26LBE5-BxeW1kuf.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bovd1Zhx.js → quadrantDiagram-W4KKPZXB-BKs2CT0r.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DiyrKnJ2.js → requirementDiagram-4Y6WPE33-CVdO-hXD.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BuX7TRVw.js → sankeyDiagram-5OEKKPKP-yusj_kJC.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BjetZWJo.js → sequenceDiagram-3UESZ5HK-C0h-9c8Y.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-4AUBV6dF.js → stateDiagram-AJRCARHV-BSBY8Psb.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BpQ8g8Bm.js → timeline-definition-PNZ67QCA-DEFUdNb8.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Cj99BWER.js → vennDiagram-CIIHVFJN-DunhMqxK.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-S7AGTOWw.js → wardley-L42UT6IY-CXJsBGKl.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-LNXkUA7S.js → wardleyDiagram-YWT4CUSO-DfT_-dqX.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DGdTh4Xk.js → xychartDiagram-2RQKCTM6-BysTtULW.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/channel-CZs6Z1i5.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +0 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PaddockConfig } from "./config.js";
|
|
2
2
|
import type { Project } from "./projects.js";
|
|
3
3
|
import { type McpSources } from "./claude-mcp.js";
|
|
4
|
+
import { type HostPluginSource } from "./claude-plugins.js";
|
|
4
5
|
import { type PaddockTrigger } from "./trigger-config.js";
|
|
5
6
|
/**
|
|
6
7
|
* A project's keeper agent config. Inherits the fleet `defaults` (runtime,
|
|
@@ -34,8 +35,13 @@ import { type PaddockTrigger } from "./trigger-config.js";
|
|
|
34
35
|
* which is both the isolated mode and what every existing caller means. The
|
|
35
36
|
* KEEPER is the only agent that gets them: the sweeper is tool-less and each
|
|
36
37
|
* trigger declares its own narrow allowlist, so neither could call one anyway.
|
|
38
|
+
*
|
|
39
|
+
* `hostPlugins` is the same story for the host's Claude Code plugins (#700),
|
|
40
|
+
* resolved once at boot by `claude-plugins.ts` and gated by `claude.instructions`
|
|
41
|
+
* rather than `claude.mcpServers` — see that module for why. Keeper-only for the
|
|
42
|
+
* same reasons, and empty unless the lever is on.
|
|
37
43
|
*/
|
|
38
|
-
export declare function buildAgentConfig(cfg: PaddockConfig, project: Project, modelOverride?: string, mcpSources?: McpSources): Record<string, unknown> & {
|
|
44
|
+
export declare function buildAgentConfig(cfg: PaddockConfig, project: Project, modelOverride?: string, mcpSources?: McpSources, hostPlugins?: HostPluginSource): Record<string, unknown> & {
|
|
39
45
|
name: string;
|
|
40
46
|
};
|
|
41
47
|
/**
|
|
@@ -14,6 +14,7 @@ import { promises as fs } from "node:fs";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import YAML from "yaml";
|
|
16
16
|
import { EMPTY_MCP_SOURCES, mcpServersFor, mcpToolPattern, } from "./claude-mcp.js";
|
|
17
|
+
import { EMPTY_HOST_PLUGINS } from "./claude-plugins.js";
|
|
17
18
|
import { DEFAULT_MODEL, SWEEPER_DEFAULT_MODEL, DEFAULT_PERMISSION_MODE, DEFAULT_MAX_TURNS, } from "./models.js";
|
|
18
19
|
import { triggerToAgentToolConfig, triggersToHerdctlSchedules, curatorTriggerOf, } from "./trigger-config.js";
|
|
19
20
|
import { keeperAgentName, sweeperAgentName, triggerAgentName, browserMcpServers, FLEET_ALLOWED_TOOLS, DENIED_TOOLS, KEEPER_MAX_CONCURRENT, KEEPER_SESSION_TIMEOUT, } from "./herdctl-agent-names.js";
|
|
@@ -49,8 +50,13 @@ import { keeperAgentName, sweeperAgentName, triggerAgentName, browserMcpServers,
|
|
|
49
50
|
* which is both the isolated mode and what every existing caller means. The
|
|
50
51
|
* KEEPER is the only agent that gets them: the sweeper is tool-less and each
|
|
51
52
|
* trigger declares its own narrow allowlist, so neither could call one anyway.
|
|
53
|
+
*
|
|
54
|
+
* `hostPlugins` is the same story for the host's Claude Code plugins (#700),
|
|
55
|
+
* resolved once at boot by `claude-plugins.ts` and gated by `claude.instructions`
|
|
56
|
+
* rather than `claude.mcpServers` — see that module for why. Keeper-only for the
|
|
57
|
+
* same reasons, and empty unless the lever is on.
|
|
52
58
|
*/
|
|
53
|
-
export function buildAgentConfig(cfg, project, modelOverride, mcpSources = EMPTY_MCP_SOURCES) {
|
|
59
|
+
export function buildAgentConfig(cfg, project, modelOverride, mcpSources = EMPTY_MCP_SOURCES, hostPlugins = EMPTY_HOST_PLUGINS) {
|
|
54
60
|
const config = {
|
|
55
61
|
name: keeperAgentName(project.slug),
|
|
56
62
|
description: project.summary || `Claude Code agent for project ${project.name}.`,
|
|
@@ -142,9 +148,19 @@ export function buildAgentConfig(cfg, project, modelOverride, mcpSources = EMPTY
|
|
|
142
148
|
// Patterns already on the defaults are filtered out, so nothing is restated
|
|
143
149
|
// for an instance with no external servers (or one whose only server paddock's
|
|
144
150
|
// own overrode above) — that config stays byte-identical to before this lever.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
//
|
|
152
|
+
// The host's Claude Code PLUGINS ride on the same two mechanisms (#700, and the
|
|
153
|
+
// `plugins` passthrough herdctl 5.32.0 added for it). They are a separate key
|
|
154
|
+
// rather than more `mcp_servers` because a plugin is a directory the engine
|
|
155
|
+
// loads, not a server paddock can describe — but their servers land in the same
|
|
156
|
+
// tool namespace and need the same allowlist widening, under names paddock has
|
|
157
|
+
// to derive rather than read (`claude-plugins.ts`).
|
|
158
|
+
if (hostPlugins.plugins.length > 0)
|
|
159
|
+
config.plugins = hostPlugins.plugins;
|
|
160
|
+
const extra = [
|
|
161
|
+
...Object.keys(external).map(mcpToolPattern),
|
|
162
|
+
...hostPlugins.toolPatterns,
|
|
163
|
+
].filter((pattern) => !FLEET_ALLOWED_TOOLS.includes(pattern));
|
|
148
164
|
if (extra.length > 0)
|
|
149
165
|
config.allowed_tools = [...FLEET_ALLOWED_TOOLS, ...extra];
|
|
150
166
|
return config;
|
|
@@ -51,6 +51,7 @@ import type { PaddockConfig } from "./config.js";
|
|
|
51
51
|
import type { Project } from "./projects.js";
|
|
52
52
|
import { type PaddockTrigger } from "./trigger-config.js";
|
|
53
53
|
import { type McpSources } from "./claude-mcp.js";
|
|
54
|
+
import { type HostPluginSource } from "./claude-plugins.js";
|
|
54
55
|
import { type AdoptableSummary, type FilterReason } from "./adoptable.js";
|
|
55
56
|
/**
|
|
56
57
|
* One session an import did NOT bring in, with a reason a UI can show verbatim.
|
|
@@ -121,6 +122,7 @@ export declare function sliceTranscriptAtUuid(raw: string, cutUuid: string, endO
|
|
|
121
122
|
export declare class HerdctlService {
|
|
122
123
|
private readonly cfg;
|
|
123
124
|
private readonly mcpSources;
|
|
125
|
+
private readonly hostPlugins;
|
|
124
126
|
private fleet;
|
|
125
127
|
private started;
|
|
126
128
|
/**
|
|
@@ -193,8 +195,12 @@ export declare class HerdctlService {
|
|
|
193
195
|
* all under `mcpServers: own`. Defaults to empty so every existing caller —
|
|
194
196
|
* including the several tests that construct this with `{} as PaddockConfig` —
|
|
195
197
|
* keeps meaning "isolated".
|
|
198
|
+
*
|
|
199
|
+
* `hostPlugins` is the host's Claude Code plugins (#700), read at boot by
|
|
200
|
+
* `loadHostPlugins` under `claude.instructions: host`, and handed in for
|
|
201
|
+
* exactly the same reasons.
|
|
196
202
|
*/
|
|
197
|
-
constructor(cfg: PaddockConfig, mcpSources?: McpSources);
|
|
203
|
+
constructor(cfg: PaddockConfig, mcpSources?: McpSources, hostPlugins?: HostPluginSource);
|
|
198
204
|
/**
|
|
199
205
|
* The Claude home every transcript symlink is planted in, plus where those
|
|
200
206
|
* symlinks point (#620, #691). One accessor, so no call site can quietly take
|
|
@@ -57,6 +57,7 @@ import { ensureProjectChats, projectChatsDir } from "./transcripts.js";
|
|
|
57
57
|
import { triggerRunsOnOwnAgent, isCuratorTrigger, } from "./trigger-config.js";
|
|
58
58
|
import { buildAgentConfig, buildSweeperConfig, sweeperWorkingDir, buildTriggerConfig, ensureConfigFile as writeBootConfigFile, } from "./herdctl-agent-config.js";
|
|
59
59
|
import { EMPTY_MCP_SOURCES } from "./claude-mcp.js";
|
|
60
|
+
import { EMPTY_HOST_PLUGINS } from "./claude-plugins.js";
|
|
60
61
|
import * as jobs from "./herdctl-jobs.js";
|
|
61
62
|
import { JobsDirIndex } from "./herdctl-jobs-index.js";
|
|
62
63
|
import { AdoptableIndex } from "./adoptable.js";
|
|
@@ -164,6 +165,7 @@ export function sliceTranscriptAtUuid(raw, cutUuid, endOnCompletedAssistant = fa
|
|
|
164
165
|
export class HerdctlService {
|
|
165
166
|
cfg;
|
|
166
167
|
mcpSources;
|
|
168
|
+
hostPlugins;
|
|
167
169
|
fleet = null;
|
|
168
170
|
started = false;
|
|
169
171
|
/**
|
|
@@ -238,10 +240,15 @@ export class HerdctlService {
|
|
|
238
240
|
* all under `mcpServers: own`. Defaults to empty so every existing caller —
|
|
239
241
|
* including the several tests that construct this with `{} as PaddockConfig` —
|
|
240
242
|
* keeps meaning "isolated".
|
|
243
|
+
*
|
|
244
|
+
* `hostPlugins` is the host's Claude Code plugins (#700), read at boot by
|
|
245
|
+
* `loadHostPlugins` under `claude.instructions: host`, and handed in for
|
|
246
|
+
* exactly the same reasons.
|
|
241
247
|
*/
|
|
242
|
-
constructor(cfg, mcpSources = EMPTY_MCP_SOURCES) {
|
|
248
|
+
constructor(cfg, mcpSources = EMPTY_MCP_SOURCES, hostPlugins = EMPTY_HOST_PLUGINS) {
|
|
243
249
|
this.cfg = cfg;
|
|
244
250
|
this.mcpSources = mcpSources;
|
|
251
|
+
this.hostPlugins = hostPlugins;
|
|
245
252
|
}
|
|
246
253
|
/**
|
|
247
254
|
* The Claude home every transcript symlink is planted in, plus where those
|
|
@@ -1508,7 +1515,7 @@ export class HerdctlService {
|
|
|
1508
1515
|
// existing unit-test seams (which reach `sweeperAgentConfig`/`ensureConfigFile`
|
|
1509
1516
|
// via a cast) and internal callers keep working.
|
|
1510
1517
|
keeperAgentConfig(project, modelOverride) {
|
|
1511
|
-
return buildAgentConfig(this.cfg, project, modelOverride, this.mcpSources);
|
|
1518
|
+
return buildAgentConfig(this.cfg, project, modelOverride, this.mcpSources, this.hostPlugins);
|
|
1512
1519
|
}
|
|
1513
1520
|
sweeperAgentConfig(project) {
|
|
1514
1521
|
return buildSweeperConfig(this.cfg, project);
|
|
@@ -308,8 +308,9 @@ export function buildManagementOps(ctx, params) {
|
|
|
308
308
|
name: project.name,
|
|
309
309
|
dir: project.dir,
|
|
310
310
|
workingDir: project.workingDir,
|
|
311
|
-
|
|
311
|
+
managed: project.managed === true,
|
|
312
312
|
...(project.repo ? { repo: project.repo } : {}),
|
|
313
|
+
...(project.path ? { path: project.path } : {}),
|
|
313
314
|
agentRegistered,
|
|
314
315
|
};
|
|
315
316
|
},
|
|
@@ -48,33 +48,46 @@
|
|
|
48
48
|
* Unlike a management token, an MCP `env` entry is not necessarily secret
|
|
49
49
|
* (`NOTION_VERSION: "2022-06-28"` is not), so an inline value is a WARNING and
|
|
50
50
|
* not an error — but the warning is emitted for any key that looks like a
|
|
51
|
-
* credential, and nothing in this module ever prints a value.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* credential, and nothing in this module ever prints a value. `headers` follows
|
|
52
|
+
* the same rule and for a stronger reason: `Authorization` is the usual reason a
|
|
53
|
+
* header is declared at all. Every diagnostic names keys, variable names and
|
|
54
|
+
* server names only; a `url` is reported with its query string and userinfo
|
|
55
|
+
* stripped, because that is where an API key rides. {@link describeServer} is the
|
|
56
|
+
* single place a server is rendered for a human, so there is one function to
|
|
57
|
+
* audit rather than a scattering of template strings.
|
|
56
58
|
*
|
|
57
59
|
* The resolved values do land in the frozen `PaddockConfig` (as management tokens
|
|
58
60
|
* already do). They must never reach an API response: `instance-config.ts`
|
|
59
61
|
* publishes only the fields in its own `FIELDS` table, and this block is
|
|
60
62
|
* deliberately absent from it.
|
|
61
63
|
*
|
|
64
|
+
* One exposure the `env:` indirection does NOT close: under `driveMode: batch`
|
|
65
|
+
* the resolved value reaches a COMMAND LINE. See {@link argvExposure} — and note
|
|
66
|
+
* that carrying `headers` (#700) widens it, because an `Authorization` bearer is
|
|
67
|
+
* a likelier long-lived credential than an `env` entry.
|
|
68
|
+
*
|
|
62
69
|
* ## Validation is strict, and drops rather than degrades
|
|
63
70
|
*
|
|
64
71
|
* Step 5 passes a host server it cannot carry faithfully, with a warning, on the
|
|
65
72
|
* grounds that a user who declared a server elsewhere should get it plus a
|
|
66
73
|
* warning rather than nothing plus a warning. That reasoning inverts here,
|
|
67
74
|
* because the user typed this file: an unusable declaration is a mistake they can
|
|
68
|
-
* fix
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
75
|
+
* fix. So anything that cannot be carried is an ERROR and that server is not
|
|
76
|
+
* attached — including an unknown key, which is how a typo (`arg:` for `args:`)
|
|
77
|
+
* otherwise becomes a server that starts wrong.
|
|
78
|
+
*
|
|
79
|
+
* `headers:` and `type: sse` USED to be in that category, because herdctl's
|
|
80
|
+
* schema had no field for either. herdctl 5.32.0 (#446) carries both verbatim,
|
|
81
|
+
* so refusing them would now reject a declaration that works — they are accepted,
|
|
82
|
+
* and `headers` is a first-class secret-bearing field with the same `env:VAR`
|
|
83
|
+
* resolution and the same never-print rule as `env`.
|
|
72
84
|
*
|
|
73
85
|
* Never a boot failure, though: one bad server drops itself and the rest attach,
|
|
74
86
|
* matching `resolveManagementApiConfig`. A typo in a capability must not take an
|
|
75
87
|
* instance down.
|
|
76
88
|
*/
|
|
77
89
|
import type { McpServerDefs, McpServerDef } from "./claude-mcp.js";
|
|
90
|
+
import type { DriveMode } from "./models.js";
|
|
78
91
|
/** On-disk shape of one entry of the `mcpServers:` block. Every field optional. */
|
|
79
92
|
export interface McpServerConfigFile {
|
|
80
93
|
/** Executable for a stdio server. Mutually exclusive with {@link url}. */
|
|
@@ -83,11 +96,11 @@ export interface McpServerConfigFile {
|
|
|
83
96
|
args?: string[];
|
|
84
97
|
/** Environment for the server process. Values may be `env:VAR_NAME`. */
|
|
85
98
|
env?: Record<string, string>;
|
|
86
|
-
/** Endpoint for a
|
|
99
|
+
/** Endpoint for a remote server. Mutually exclusive with {@link command}. */
|
|
87
100
|
url?: string;
|
|
88
|
-
/**
|
|
101
|
+
/** `stdio` for a {@link command}; `http` or `sse` for a {@link url}. */
|
|
89
102
|
type?: string;
|
|
90
|
-
/**
|
|
103
|
+
/** Headers for a remote server. Values may be `env:VAR_NAME`. */
|
|
91
104
|
headers?: Record<string, string>;
|
|
92
105
|
}
|
|
93
106
|
/** On-disk shape of the whole block: a map of server name → declaration. */
|
|
@@ -165,4 +178,10 @@ export declare function declaredMcpNotices(opts: {
|
|
|
165
178
|
hostNames?: readonly string[];
|
|
166
179
|
/** Whether paddock's own browser server is attached (it wins a name clash). */
|
|
167
180
|
browserMcp?: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* The instance's drive mode, which decides whether a declared `env` value ends
|
|
183
|
+
* up on a command line. Omitted ⇒ say nothing (the several callers that only
|
|
184
|
+
* want the inventory line).
|
|
185
|
+
*/
|
|
186
|
+
driveMode?: DriveMode;
|
|
168
187
|
}): DeclaredMcpNotice[];
|
|
@@ -65,12 +65,15 @@ export function describeServer(name, def) {
|
|
|
65
65
|
if (def.command)
|
|
66
66
|
parts.push(`stdio: ${def.command.split(/[\\/]/).pop()}`);
|
|
67
67
|
if (def.url)
|
|
68
|
-
parts.push(
|
|
68
|
+
parts.push(`${def.type ?? "http"}: ${redactUrl(def.url)}`);
|
|
69
69
|
if (def.args?.length)
|
|
70
70
|
parts.push(`${def.args.length} args`);
|
|
71
71
|
const envCount = Object.keys(def.env ?? {}).length;
|
|
72
72
|
if (envCount > 0)
|
|
73
73
|
parts.push(`${envCount} env ${envCount === 1 ? "entry" : "entries"}`);
|
|
74
|
+
const headerCount = Object.keys(def.headers ?? {}).length;
|
|
75
|
+
if (headerCount > 0)
|
|
76
|
+
parts.push(`${headerCount} ${headerCount === 1 ? "header" : "headers"}`);
|
|
74
77
|
return `${name} (${parts.join(", ")})`;
|
|
75
78
|
}
|
|
76
79
|
/**
|
|
@@ -102,18 +105,18 @@ function resolveLeaf(raw, env, where) {
|
|
|
102
105
|
* Validate + resolve one declaration into the shape herdctl's `McpServerSchema`
|
|
103
106
|
* carries, or explain why it cannot be.
|
|
104
107
|
*
|
|
105
|
-
* The
|
|
106
|
-
* the
|
|
108
|
+
* The one rejection left that is about the engine rather than about the file is
|
|
109
|
+
* **an unknown key** — the only defence against `arg:`/`envs:`/`commands:`, which
|
|
110
|
+
* would otherwise yield a server that starts with the wrong argv.
|
|
107
111
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* which would otherwise yield a server that starts with the wrong argv.
|
|
112
|
+
* `headers` and `type: sse` were rejections too until herdctl 5.32.0, because
|
|
113
|
+
* the schema had no field for either: a bearer-authenticated server arrived
|
|
114
|
+
* unauthenticated and missed its stored OAuth token (keyed on a hash that
|
|
115
|
+
* includes the headers and the type), and an `sse` url was silently connected to
|
|
116
|
+
* as HTTP. Both are now carried verbatim, so both are accepted — `headers`
|
|
117
|
+
* through the same `env:VAR` resolution as `env`, and `type` checked only for
|
|
118
|
+
* agreeing with the declaration (`stdio` for a `command`, `http`/`sse` for a
|
|
119
|
+
* `url`).
|
|
117
120
|
*/
|
|
118
121
|
function narrowDeclaration(name, raw, env) {
|
|
119
122
|
const where = `mcpServers.${name}`;
|
|
@@ -135,16 +138,10 @@ function narrowDeclaration(name, raw, env) {
|
|
|
135
138
|
return fail(`${where}: is not a mapping`);
|
|
136
139
|
const unknown = Object.keys(raw).filter((k) => !KNOWN_KEYS.has(k));
|
|
137
140
|
if (unknown.length > 0) {
|
|
138
|
-
return fail(`${where}: unrecognised key(s) ${unknown.join(", ")}
|
|
141
|
+
return fail(`${where}: unrecognised key(s) ${unknown.join(", ")} ` +
|
|
142
|
+
`(supported: command, args, env, url, type, headers) ` +
|
|
139
143
|
`— a mistyped key would otherwise be dropped without a trace`);
|
|
140
144
|
}
|
|
141
|
-
if (raw.headers !== undefined) {
|
|
142
|
-
const keys = isRecord(raw.headers) ? Object.keys(raw.headers) : [];
|
|
143
|
-
return fail(`${where}: \`headers\`${keys.length > 0 ? ` (${keys.join(", ")})` : ""} cannot be ` +
|
|
144
|
-
`carried — the engine's MCP schema has no field for them, so the server would arrive ` +
|
|
145
|
-
`unauthenticated and its stored OAuth token (keyed on a hash that includes the headers) ` +
|
|
146
|
-
`would not be found either`);
|
|
147
|
-
}
|
|
148
145
|
const hasCommand = typeof raw.command === "string" && raw.command.trim().length > 0;
|
|
149
146
|
const hasUrl = typeof raw.url === "string" && raw.url.trim().length > 0;
|
|
150
147
|
if (hasCommand && hasUrl) {
|
|
@@ -153,20 +150,21 @@ function narrowDeclaration(name, raw, env) {
|
|
|
153
150
|
if (!hasCommand && !hasUrl) {
|
|
154
151
|
return fail(`${where}: declares neither a \`command\` nor a \`url\`, so there is nothing to start`);
|
|
155
152
|
}
|
|
153
|
+
const server = {};
|
|
156
154
|
if (raw.type !== undefined) {
|
|
157
155
|
const type = String(raw.type).trim().toLowerCase();
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
156
|
+
// `sse` joined `http` here in herdctl 5.32.0; an explicit type now wins over
|
|
157
|
+
// the bare-`url` ⇒ `http` inference rather than being stripped on the way
|
|
158
|
+
// down. It still has to agree with the rest of the declaration, because a
|
|
159
|
+
// `type` that disagrees is a typo and starting the wrong transport is a
|
|
160
|
+
// confusing failure rather than a loud one.
|
|
161
|
+
const expected = hasUrl ? ["http", "sse"] : ["stdio"];
|
|
162
|
+
if (!expected.includes(type)) {
|
|
163
|
+
return fail(`${where}: \`type: ${type}\` disagrees with the declaration ` +
|
|
164
|
+
`(expected ${expected.join(" or ")})`);
|
|
167
165
|
}
|
|
166
|
+
server.type = type;
|
|
168
167
|
}
|
|
169
|
-
const server = {};
|
|
170
168
|
if (hasCommand) {
|
|
171
169
|
const res = resolveLeaf(raw.command.trim(), env, `${where}.command`);
|
|
172
170
|
if (!res.ok)
|
|
@@ -178,9 +176,9 @@ function narrowDeclaration(name, raw, env) {
|
|
|
178
176
|
if (!res.ok)
|
|
179
177
|
return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
|
|
180
178
|
server.url = res.value;
|
|
181
|
-
// A key in the query string is the
|
|
182
|
-
// authenticated
|
|
183
|
-
// git-tracked. Say so once; never echo the query itself.
|
|
179
|
+
// A key in the query string is one of the two ways a remote MCP endpoint is
|
|
180
|
+
// authenticated (the other, `headers`, is carryable again since #700), and
|
|
181
|
+
// this file is git-tracked. Say so once; never echo the query itself.
|
|
184
182
|
if (!raw.url.startsWith(ENV_REF_PREFIX)) {
|
|
185
183
|
let parsed;
|
|
186
184
|
try {
|
|
@@ -231,6 +229,34 @@ function narrowDeclaration(name, raw, env) {
|
|
|
231
229
|
if (Object.keys(resolved).length > 0)
|
|
232
230
|
server.env = resolved;
|
|
233
231
|
}
|
|
232
|
+
if (raw.headers !== undefined) {
|
|
233
|
+
if (!hasUrl) {
|
|
234
|
+
return fail(`${where}.headers: only a \`url\` server can carry headers`);
|
|
235
|
+
}
|
|
236
|
+
if (!isRecord(raw.headers)) {
|
|
237
|
+
return fail(`${where}.headers: must be a mapping of header name to value`);
|
|
238
|
+
}
|
|
239
|
+
const resolved = {};
|
|
240
|
+
for (const [key, value] of Object.entries(raw.headers)) {
|
|
241
|
+
if (typeof value !== "string") {
|
|
242
|
+
return fail(`${where}.headers.${key}: must be a string`);
|
|
243
|
+
}
|
|
244
|
+
const res = resolveLeaf(value, env, `${where}.headers.${key}`);
|
|
245
|
+
if (!res.ok)
|
|
246
|
+
return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
|
|
247
|
+
resolved[key] = res.value;
|
|
248
|
+
// Same heuristic as `env`, one rule to learn — and `Authorization` is the
|
|
249
|
+
// usual reason a header is declared at all, so this fires on the common
|
|
250
|
+
// case rather than the exotic one.
|
|
251
|
+
if (!value.startsWith(ENV_REF_PREFIX) && SECRET_ISH_KEY_RE.test(key)) {
|
|
252
|
+
warnings.push(`${where}.headers.${key}: looks like a credential and is written into the config file ` +
|
|
253
|
+
`itself, which is git-tracked — prefer \`${key}: ${ENV_REF_PREFIX}VAR_NAME\` and set ` +
|
|
254
|
+
`the value in the environment`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (Object.keys(resolved).length > 0)
|
|
258
|
+
server.headers = resolved;
|
|
259
|
+
}
|
|
234
260
|
return { server, errors, warnings };
|
|
235
261
|
}
|
|
236
262
|
/**
|
|
@@ -265,6 +291,62 @@ export function resolveDeclaredMcpServers(file, env) {
|
|
|
265
291
|
}
|
|
266
292
|
return { servers, errors, warnings };
|
|
267
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* The one way a declared credential leaves paddock, said out loud.
|
|
296
|
+
*
|
|
297
|
+
* Everything else in this module is about keeping a resolved value out of the
|
|
298
|
+
* places paddock controls — the log, an error, the Settings API. It does that
|
|
299
|
+
* completely, and it is still not the whole story, because of what herdctl does
|
|
300
|
+
* with the record afterwards:
|
|
301
|
+
*
|
|
302
|
+
* ```js
|
|
303
|
+
* // @herdctl/core, runner/runtime/cli-runtime.js
|
|
304
|
+
* const mcpServers = transformMcpServers(options.agent.mcp_servers);
|
|
305
|
+
* const mcpConfig = JSON.stringify({ mcpServers });
|
|
306
|
+
* args.push("--mcp-config", mcpConfig); // ← env values and all
|
|
307
|
+
* ```
|
|
308
|
+
*
|
|
309
|
+
* A process ARGUMENT is not private on Linux: `/proc/<pid>/cmdline` is
|
|
310
|
+
* world-readable by default (no `hidepid`), and `ps` prints it. So on the CLI
|
|
311
|
+
* runtime every declared server's `env` — and, since #700 made the field
|
|
312
|
+
* carryable, its `headers` — is legible to any local user for the lifetime of
|
|
313
|
+
* each `claude` invocation. Observed, not
|
|
314
|
+
* inferred: `test/integration/declared-mcp-argv.test.ts` drives a real turn and
|
|
315
|
+
* reads the token back out of the spawned process's argv.
|
|
316
|
+
*
|
|
317
|
+
* The SDK runtime does NOT do this. It hands the same record to the SDK
|
|
318
|
+
* in-process, and the stdio server it spawns receives the value in its
|
|
319
|
+
* environment, where `/proc/<pid>/environ` is owner-only — which is exactly what
|
|
320
|
+
* Claude Code itself does, so it is not a regression to fix here.
|
|
321
|
+
*
|
|
322
|
+
* Which runtime runs is `driveMode`: `session` (the default) is the SDK,
|
|
323
|
+
* `batch` is the CLI. Hence a WARNING on an instance that is on `batch`, and an
|
|
324
|
+
* informational note otherwise — because a single project can pin `driveMode:
|
|
325
|
+
* batch` for itself and bring the exposure back with it.
|
|
326
|
+
*
|
|
327
|
+
* Paddock cannot close this from here: the fix is upstream (the Claude CLI's
|
|
328
|
+
* `--mcp-config` also accepts a file path, which is not readable from another
|
|
329
|
+
* process's argv). Refusing to attach the server instead would break the feature
|
|
330
|
+
* for the deployments most likely to need it, so what this does is refuse to be
|
|
331
|
+
* silent — the same posture step 5 takes towards the fields the engine's schema
|
|
332
|
+
* cannot carry.
|
|
333
|
+
*/
|
|
334
|
+
function argvExposure(names, driveMode) {
|
|
335
|
+
const batch = driveMode === "batch";
|
|
336
|
+
return {
|
|
337
|
+
level: batch ? "warn" : "info",
|
|
338
|
+
message: `${names.join(", ")} ${names.length === 1 ? "declares" : "declare"} \`env\` values or ` +
|
|
339
|
+
`\`headers\`, and ` +
|
|
340
|
+
`under \`driveMode: batch\` the engine passes the whole server definition to \`claude\` ` +
|
|
341
|
+
`as a \`--mcp-config\` COMMAND-LINE argument — where any local process can read it via ` +
|
|
342
|
+
`/proc/<pid>/cmdline. ` +
|
|
343
|
+
(batch
|
|
344
|
+
? `This instance is on \`driveMode: batch\`, so that applies to every turn. Prefer ` +
|
|
345
|
+
`\`driveMode: session\` (the default) for a server that holds a credential.`
|
|
346
|
+
: `This instance is on \`driveMode: session\`, which passes them in-process instead — ` +
|
|
347
|
+
`but a project that pins \`driveMode: batch\` brings the exposure back.`),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
268
350
|
/**
|
|
269
351
|
* What to say at boot about the declared servers — the answer to "what does this
|
|
270
352
|
* instance actually have?", which should be readable off the log rather than
|
|
@@ -294,6 +376,18 @@ export function declaredMcpNotices(opts) {
|
|
|
294
376
|
`~/.claude.json (\`claude.mcpServers: host\`); this instance's own declaration wins.`,
|
|
295
377
|
});
|
|
296
378
|
}
|
|
379
|
+
// `headers` joined `env` here in #700: herdctl 5.32.0 carries them, so an
|
|
380
|
+
// `Authorization` bearer now rides in the same `--mcp-config` argv element that
|
|
381
|
+
// #702 found an `env` token in — and a bearer is the likelier long-lived
|
|
382
|
+
// credential of the two. A server declared with headers and no env would
|
|
383
|
+
// otherwise be the one case this warning missed.
|
|
384
|
+
const exposed = names.filter((n) => Object.keys(opts.servers[n].env ?? {}).length > 0 ||
|
|
385
|
+
Object.keys(opts.servers[n].headers ?? {}).length > 0);
|
|
386
|
+
if (exposed.length > 0 && opts.driveMode !== undefined) {
|
|
387
|
+
// The one place a declared credential escapes paddock, and it escapes
|
|
388
|
+
// downstream of every rule this module enforces — see {@link argvExposure}.
|
|
389
|
+
notices.push(argvExposure(exposed, opts.driveMode));
|
|
390
|
+
}
|
|
297
391
|
if (opts.browserMcp && names.includes("playwright")) {
|
|
298
392
|
notices.push({
|
|
299
393
|
level: "warn",
|
|
@@ -66,11 +66,79 @@ export declare function isValidRepoUrl(url: string): boolean;
|
|
|
66
66
|
*/
|
|
67
67
|
export declare function repoCheckoutName(repo: string): string;
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* Whether `child` is `parent` itself or lives underneath it. Both are resolved
|
|
70
|
+
* first, so this compares real path strings rather than the caller's spelling
|
|
71
|
+
* (it does NOT follow symlinks — callers that care resolve them beforehand).
|
|
72
|
+
*
|
|
73
|
+
* The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
|
|
74
|
+
* `/data/projects-old` a child of `/data/projects`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function isPathInside(child: string, parent: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a project's working directory (keeper cwd) from its metadata dir plus
|
|
79
|
+
* whichever backing field is set:
|
|
80
|
+
*
|
|
81
|
+
* - **`linkedPath`** (issue #206) — the cwd IS that directory, returned
|
|
82
|
+
* verbatim, used in place with no copy. Applies on BOTH sides of the
|
|
83
|
+
* managed/unmanaged axis: an unmanaged project links a code checkout you
|
|
84
|
+
* already have, a managed one nominates where its notes should live.
|
|
85
|
+
* - **`repo`** (issue #187) — the nested checkout Paddock cloned.
|
|
86
|
+
* - **neither** — the metadata dir itself.
|
|
87
|
+
*
|
|
88
|
+
* `linkedPath` WINS over `repo`, which is why the two are not mutually exclusive:
|
|
89
|
+
* `repo` alongside a path is an ACQUISITION hint (clone here if the path is
|
|
90
|
+
* missing) plus a remote-match for adoption (`adoptable.ts`, #659) and a re-clone
|
|
91
|
+
* hint for disaster recovery. It never moves the cwd off the nominated path.
|
|
92
|
+
*/
|
|
93
|
+
export declare function workingDirFor(dir: string, repo?: string, linkedPath?: string): string;
|
|
94
|
+
/** The subset of `project.yaml` that decides a project's shape. */
|
|
95
|
+
export interface ProjectBacking {
|
|
96
|
+
managed?: boolean;
|
|
97
|
+
repo?: string;
|
|
98
|
+
path?: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether Paddock looks after this project's own files — the real axis, and the
|
|
102
|
+
* one that replaced the overloaded `repoBacked` boolean (issue #206).
|
|
103
|
+
*
|
|
104
|
+
* **managed** — Paddock curates `CLAUDE.md` / `OVERVIEW.md` / `CHANGELOG.md`
|
|
105
|
+
* here. This is what "notebook" used to mean.
|
|
106
|
+
* **unmanaged** — the content is code that you or your agents source-control
|
|
107
|
+
* OUTSIDE Paddock. Paddock never writes project files into it.
|
|
108
|
+
*
|
|
109
|
+
* Whether a git repo sits behind it is an INDEPENDENT question (just which of
|
|
110
|
+
* `path`/`repo` are set), not a type. See `docs/DESIGN-backing-store.md` §2 goal
|
|
111
|
+
* 4: the base store is a plain directory and git is an optional layer.
|
|
112
|
+
*
|
|
113
|
+
* ## The default is derived, never constant
|
|
114
|
+
*
|
|
115
|
+
* `managed ?? !(repo || path)` — an absent key means "whatever this project's
|
|
116
|
+
* backing implies". That is load-bearing for the upgrade path: every existing
|
|
117
|
+
* `project.yaml` predates the key, and if absent meant `true`, every existing
|
|
118
|
+
* repo-backed project would flip to managed on upgrade and the sweeper would
|
|
119
|
+
* start writing `CLAUDE.md` into somebody's checkout. New projects write the key
|
|
120
|
+
* explicitly, so the derivation only ever has to serve legacy files.
|
|
121
|
+
*/
|
|
122
|
+
export declare function isManaged(backing: ProjectBacking): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Where a project's CURATED CONTENT lives — `OVERVIEW.md`, `CHANGELOG.md` and
|
|
125
|
+
* `CLAUDE.md`, the three files the sweeper writes.
|
|
126
|
+
*
|
|
127
|
+
* Content follows the working directory; application state does not. So:
|
|
128
|
+
*
|
|
129
|
+
* - **managed** ⇒ the working dir. For a classic notebook that IS the metadata
|
|
130
|
+
* dir (unchanged). For a managed project with an external `path`, the curated
|
|
131
|
+
* files live out there with the content — an accepted consequence being that
|
|
132
|
+
* they then do NOT live in the Paddock data dir.
|
|
133
|
+
* - **unmanaged** ⇒ the metadata dir, always. The working dir is somebody
|
|
134
|
+
* else's tree and Paddock does not write project files into it; `OVERVIEW.md`
|
|
135
|
+
* and `CHANGELOG.md` are still curated, just sidecarred (issue #187), and
|
|
136
|
+
* `CLAUDE.md` is not curated at all (the checkout owns its own).
|
|
137
|
+
*
|
|
138
|
+
* `project.yaml` and `.chats/` are NOT content and never move — see
|
|
139
|
+
* {@link import("./projects.js").ProjectStore.dirFor}.
|
|
72
140
|
*/
|
|
73
|
-
export declare function
|
|
141
|
+
export declare function contentDirFor(dir: string, backing: ProjectBacking): string;
|
|
74
142
|
export declare class ProjectError extends Error {
|
|
75
143
|
readonly code: "not_found" | "exists" | "invalid" | "not_directory";
|
|
76
144
|
constructor(message: string, code: "not_found" | "exists" | "invalid" | "not_directory");
|
|
@@ -74,13 +74,85 @@ export function repoCheckoutName(repo) {
|
|
|
74
74
|
return safe || "repo";
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
77
|
+
* Whether `child` is `parent` itself or lives underneath it. Both are resolved
|
|
78
|
+
* first, so this compares real path strings rather than the caller's spelling
|
|
79
|
+
* (it does NOT follow symlinks — callers that care resolve them beforehand).
|
|
80
|
+
*
|
|
81
|
+
* The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
|
|
82
|
+
* `/data/projects-old` a child of `/data/projects`.
|
|
83
|
+
*/
|
|
84
|
+
export function isPathInside(child, parent) {
|
|
85
|
+
const c = path.resolve(child);
|
|
86
|
+
const p = path.resolve(parent);
|
|
87
|
+
return c === p || c.startsWith(p + path.sep);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a project's working directory (keeper cwd) from its metadata dir plus
|
|
91
|
+
* whichever backing field is set:
|
|
92
|
+
*
|
|
93
|
+
* - **`linkedPath`** (issue #206) — the cwd IS that directory, returned
|
|
94
|
+
* verbatim, used in place with no copy. Applies on BOTH sides of the
|
|
95
|
+
* managed/unmanaged axis: an unmanaged project links a code checkout you
|
|
96
|
+
* already have, a managed one nominates where its notes should live.
|
|
97
|
+
* - **`repo`** (issue #187) — the nested checkout Paddock cloned.
|
|
98
|
+
* - **neither** — the metadata dir itself.
|
|
99
|
+
*
|
|
100
|
+
* `linkedPath` WINS over `repo`, which is why the two are not mutually exclusive:
|
|
101
|
+
* `repo` alongside a path is an ACQUISITION hint (clone here if the path is
|
|
102
|
+
* missing) plus a remote-match for adoption (`adoptable.ts`, #659) and a re-clone
|
|
103
|
+
* hint for disaster recovery. It never moves the cwd off the nominated path.
|
|
80
104
|
*/
|
|
81
|
-
export function workingDirFor(dir, repo) {
|
|
105
|
+
export function workingDirFor(dir, repo, linkedPath) {
|
|
106
|
+
if (linkedPath)
|
|
107
|
+
return linkedPath;
|
|
82
108
|
return repo ? path.join(dir, repoCheckoutName(repo)) : dir;
|
|
83
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether Paddock looks after this project's own files — the real axis, and the
|
|
112
|
+
* one that replaced the overloaded `repoBacked` boolean (issue #206).
|
|
113
|
+
*
|
|
114
|
+
* **managed** — Paddock curates `CLAUDE.md` / `OVERVIEW.md` / `CHANGELOG.md`
|
|
115
|
+
* here. This is what "notebook" used to mean.
|
|
116
|
+
* **unmanaged** — the content is code that you or your agents source-control
|
|
117
|
+
* OUTSIDE Paddock. Paddock never writes project files into it.
|
|
118
|
+
*
|
|
119
|
+
* Whether a git repo sits behind it is an INDEPENDENT question (just which of
|
|
120
|
+
* `path`/`repo` are set), not a type. See `docs/DESIGN-backing-store.md` §2 goal
|
|
121
|
+
* 4: the base store is a plain directory and git is an optional layer.
|
|
122
|
+
*
|
|
123
|
+
* ## The default is derived, never constant
|
|
124
|
+
*
|
|
125
|
+
* `managed ?? !(repo || path)` — an absent key means "whatever this project's
|
|
126
|
+
* backing implies". That is load-bearing for the upgrade path: every existing
|
|
127
|
+
* `project.yaml` predates the key, and if absent meant `true`, every existing
|
|
128
|
+
* repo-backed project would flip to managed on upgrade and the sweeper would
|
|
129
|
+
* start writing `CLAUDE.md` into somebody's checkout. New projects write the key
|
|
130
|
+
* explicitly, so the derivation only ever has to serve legacy files.
|
|
131
|
+
*/
|
|
132
|
+
export function isManaged(backing) {
|
|
133
|
+
return backing.managed ?? !(backing.repo || backing.path);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Where a project's CURATED CONTENT lives — `OVERVIEW.md`, `CHANGELOG.md` and
|
|
137
|
+
* `CLAUDE.md`, the three files the sweeper writes.
|
|
138
|
+
*
|
|
139
|
+
* Content follows the working directory; application state does not. So:
|
|
140
|
+
*
|
|
141
|
+
* - **managed** ⇒ the working dir. For a classic notebook that IS the metadata
|
|
142
|
+
* dir (unchanged). For a managed project with an external `path`, the curated
|
|
143
|
+
* files live out there with the content — an accepted consequence being that
|
|
144
|
+
* they then do NOT live in the Paddock data dir.
|
|
145
|
+
* - **unmanaged** ⇒ the metadata dir, always. The working dir is somebody
|
|
146
|
+
* else's tree and Paddock does not write project files into it; `OVERVIEW.md`
|
|
147
|
+
* and `CHANGELOG.md` are still curated, just sidecarred (issue #187), and
|
|
148
|
+
* `CLAUDE.md` is not curated at all (the checkout owns its own).
|
|
149
|
+
*
|
|
150
|
+
* `project.yaml` and `.chats/` are NOT content and never move — see
|
|
151
|
+
* {@link import("./projects.js").ProjectStore.dirFor}.
|
|
152
|
+
*/
|
|
153
|
+
export function contentDirFor(dir, backing) {
|
|
154
|
+
return isManaged(backing) ? workingDirFor(dir, backing.repo, backing.path) : dir;
|
|
155
|
+
}
|
|
84
156
|
export class ProjectError extends Error {
|
|
85
157
|
code;
|
|
86
158
|
constructor(message, code) {
|