@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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host Claude Code **plugins**, handed to each keeper as `agent.plugins` (#700).
|
|
3
|
+
*
|
|
4
|
+
* The half of MCP inheritance that `claude-mcp.ts` structurally could not reach.
|
|
5
|
+
* A plugin bundles commands, agents, skills, hooks and — the reported symptom —
|
|
6
|
+
* MCP servers, and none of it is declared in `~/.claude.json`, so reading that
|
|
7
|
+
* file finds nothing however carefully it is read.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the symlink was not enough
|
|
10
|
+
*
|
|
11
|
+
* `plugins/` IS bridged into paddock's home (under `claude.instructions: host`,
|
|
12
|
+
* see `claude-home.ts`) and the SDK really does auto-discover plugins under
|
|
13
|
+
* `join(CLAUDE_CONFIG_DIR, "plugins")` — two facts that together made this look
|
|
14
|
+
* fixed for months. It is not, because discovery of an *installed* plugin is
|
|
15
|
+
* driven by the `enabledPlugins` key, and `enabledPlugins` lives in the **user**
|
|
16
|
+
* settings source, which herdctl does not load: every agent with a working
|
|
17
|
+
* directory is invoked with `setting_sources: ["project"]`. So the plugin is
|
|
18
|
+
* found and then never enabled, silently.
|
|
19
|
+
*
|
|
20
|
+
* herdctl 5.32.0 (herdctl#446, closing herdctl#444) adds the channel this needs:
|
|
21
|
+
* an optional `plugins` array on the agent config, translated to the SDK's own
|
|
22
|
+
* `plugins` option and to `--plugin-dir` on the CLI runtime. A plugin passed that
|
|
23
|
+
* way is a *session* plugin, and — read out of the CLI bundle rather than
|
|
24
|
+
* assumed — a session plugin's enablement is
|
|
25
|
+
* `enabledPlugins[`${name}@inline`] ?? manifest.defaultEnabled !== false`, i.e.
|
|
26
|
+
* **enabled by default**, with no settings-source grant required. That is why the
|
|
27
|
+
* passthrough works where the symlink did not, and why herdctl#446 was right to
|
|
28
|
+
* refuse to widen `setting_sources` instead.
|
|
29
|
+
*
|
|
30
|
+
* ## Which lever gates this, which is NOT the one #700 assumes
|
|
31
|
+
*
|
|
32
|
+
* #700 (and the brief written from it) scope plugin enumeration to
|
|
33
|
+
* `claude.mcpServers: host`. That is half right, and shipping it that way would
|
|
34
|
+
* have punched a hole through a different lever:
|
|
35
|
+
*
|
|
36
|
+
* - `plugins/` is bridged by **`claude.instructions`**, alongside `CLAUDE.md`,
|
|
37
|
+
* `agents/` and `commands/` — because most of a plugin IS instructions. Under
|
|
38
|
+
* `instructions: own` paddock withdraws that symlink and prints a notice saying
|
|
39
|
+
* the host's plugins are not loaded. Passing them anyway because a *different*
|
|
40
|
+
* key says `host` would contradict a notice paddock itself emits.
|
|
41
|
+
* - the MCP servers inside a plugin are what `claude.mcpServers` is about.
|
|
42
|
+
*
|
|
43
|
+
* So both levers apply, to the two halves of a plugin, and the SDK has exactly
|
|
44
|
+
* the flag needed to split them: `skipMcpDiscovery` loads a plugin's
|
|
45
|
+
* skills/hooks/agents/commands but does NOT read its `.mcp.json` or manifest
|
|
46
|
+
* `mcpServers`. Hence {@link enumerateHostPlugins}:
|
|
47
|
+
*
|
|
48
|
+
* | `instructions` | `mcpServers` | result |
|
|
49
|
+
* |---|---|---|
|
|
50
|
+
* | `host` | `host` | plugins passed whole |
|
|
51
|
+
* | `host` | `own` | plugins passed with `skipMcpDiscovery: true` |
|
|
52
|
+
* | `own` | *any* | no plugins, and a notice saying which key turns them on |
|
|
53
|
+
*
|
|
54
|
+
* ## The allowlist, which is where this gets uncomfortable
|
|
55
|
+
*
|
|
56
|
+
* Both runtimes auto-deny any tool missing from an explicit `allowed_tools`, with
|
|
57
|
+
* no prompt and nothing in the logs — the trap `claude-mcp.ts` documents at
|
|
58
|
+
* length. A plugin-provided MCP server needs the same widening, so paddock has to
|
|
59
|
+
* know its tool prefix *before* the plugin has ever run.
|
|
60
|
+
*
|
|
61
|
+
* It is knowable, from the CLI bundle: a plugin's servers are registered under
|
|
62
|
+
* `` `plugin:${pluginName}:${serverName}` ``, and a server name is normalised
|
|
63
|
+
* with `[^a-zA-Z0-9_-] → _` before it becomes a tool prefix. So the pattern is
|
|
64
|
+
* `mcp__plugin_<plugin>_<server>__*` — matching the SDK's own documented example,
|
|
65
|
+
* `mcp__plugin_documents_docs__doc_export`. {@link pluginMcpToolPattern} is that
|
|
66
|
+
* one line, and {@link readPluginServerNames} recovers `<server>` by reading the
|
|
67
|
+
* two places a plugin declares servers statically: `<dir>/.mcp.json` and an
|
|
68
|
+
* inline `mcpServers` object in `<dir>/.claude-plugin/plugin.json`.
|
|
69
|
+
*
|
|
70
|
+
* What it CANNOT recover is a manifest whose `mcpServers` is a string or an array
|
|
71
|
+
* of strings — a pointer to another file, or to an MCPB source the CLI downloads.
|
|
72
|
+
* Resolving a same-directory JSON pointer is easy and is done; anything else is
|
|
73
|
+
* recorded as a caveat and warned about at boot, naming the plugin, because the
|
|
74
|
+
* failure it produces otherwise is the silent one: the plugin loads, its server
|
|
75
|
+
* connects, and every tool call is denied with no log line. Better a warning that
|
|
76
|
+
* says "add `mcp__plugin_x_y__*` yourself" than a mystery.
|
|
77
|
+
*
|
|
78
|
+
* ## What is read, and what is not verified
|
|
79
|
+
*
|
|
80
|
+
* Enumeration is from `<pluginRoot>/installed_plugins.json`, the CLI's own
|
|
81
|
+
* registry of installs (v2: `{plugins: {"<name>@<marketplace>": [{installPath,
|
|
82
|
+
* scope, …}]}}`; v1 keyed the same ids to a single record, whose path was derived
|
|
83
|
+
* as `<pluginRoot>/cache/<marketplace>/<name>/<version>`). Both are read. The
|
|
84
|
+
* host's `settings.json` `enabledPlugins` is consulted only to VETO — an id set
|
|
85
|
+
* to `false` is skipped — because a session plugin's own default is "enabled",
|
|
86
|
+
* so honouring the veto is the only thing that can respect a host's `/plugin
|
|
87
|
+
* disable`.
|
|
88
|
+
*
|
|
89
|
+
* None of this was validated against a machine with plugins installed: this box
|
|
90
|
+
* is Linux with an empty plugin root, and no test here proves a plugin *loads*.
|
|
91
|
+
* Every path, file name and naming rule above comes from reading the bundled CLI
|
|
92
|
+
* (`node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude`), and the tests
|
|
93
|
+
* assert what config paddock produces from a planted directory tree, which is a
|
|
94
|
+
* strictly weaker claim. A `marketplace`-installed plugin loading via
|
|
95
|
+
* `type: "local"` remains unverified end-to-end and needs a real host.
|
|
96
|
+
*/
|
|
97
|
+
import { promises as fs } from "node:fs";
|
|
98
|
+
import path from "node:path";
|
|
99
|
+
/** Nothing inherited: `instructions: own`, and where tests start. */
|
|
100
|
+
export const EMPTY_HOST_PLUGINS = Object.freeze({
|
|
101
|
+
plugins: [],
|
|
102
|
+
toolPatterns: [],
|
|
103
|
+
caveats: [],
|
|
104
|
+
});
|
|
105
|
+
/** The plugin root, `join(CLAUDE_CONFIG_DIR, "plugins")` in the CLI's terms. */
|
|
106
|
+
export function hostPluginRoot(legacyClaudeHome) {
|
|
107
|
+
return path.join(legacyClaudeHome, "plugins");
|
|
108
|
+
}
|
|
109
|
+
/** The CLI's own registry of what is installed, under {@link hostPluginRoot}. */
|
|
110
|
+
export const INSTALLED_PLUGINS_FILE = "installed_plugins.json";
|
|
111
|
+
/** A plugin's manifest, relative to its directory. */
|
|
112
|
+
export const PLUGIN_MANIFEST = path.join(".claude-plugin", "plugin.json");
|
|
113
|
+
/** A plugin's out-of-manifest MCP declaration, relative to its directory. */
|
|
114
|
+
export const PLUGIN_MCP_FILE = ".mcp.json";
|
|
115
|
+
/**
|
|
116
|
+
* The CLI's server-name normalisation: `[^a-zA-Z0-9_-] → _`, applied to the whole
|
|
117
|
+
* `plugin:<plugin>:<server>` name before it becomes a `mcp__…__` prefix. Applied
|
|
118
|
+
* to the joined string, not to the parts, so a `.` in a plugin name normalises
|
|
119
|
+
* the same way the separators do.
|
|
120
|
+
*/
|
|
121
|
+
function normalizeServerName(name) {
|
|
122
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The allowlist pattern for one server of one plugin.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately not `mcpToolPattern` from `claude-mcp.ts`: the name a plugin's
|
|
128
|
+
* server is registered under is not the name it is declared under, and that
|
|
129
|
+
* derivation is the whole content of this function.
|
|
130
|
+
*/
|
|
131
|
+
export function pluginMcpToolPattern(pluginName, serverName) {
|
|
132
|
+
return `mcp__${normalizeServerName(`plugin:${pluginName}:${serverName}`)}__*`;
|
|
133
|
+
}
|
|
134
|
+
function isRecord(v) {
|
|
135
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
136
|
+
}
|
|
137
|
+
async function readJson(file) {
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The server names one `.mcp.json`-shaped document declares.
|
|
147
|
+
*
|
|
148
|
+
* Claude Code accepts both `{mcpServers: {…}}` and a bare map at the top level,
|
|
149
|
+
* so both are read — the CLI does `parsed.mcpServers || parsed`.
|
|
150
|
+
*/
|
|
151
|
+
function serverNamesOf(doc) {
|
|
152
|
+
if (!isRecord(doc))
|
|
153
|
+
return [];
|
|
154
|
+
const servers = isRecord(doc.mcpServers) ? doc.mcpServers : doc;
|
|
155
|
+
return Object.keys(servers).filter((k) => isRecord(servers[k]));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Read one plugin directory's declared MCP server names.
|
|
159
|
+
*
|
|
160
|
+
* Mirrors the CLI's own discovery order — `<dir>/.mcp.json` first, then whatever
|
|
161
|
+
* `manifest.mcpServers` adds — with the one deliberate gap described in the
|
|
162
|
+
* module doc: a manifest that points at an MCPB source (or at any path outside
|
|
163
|
+
* its own directory) is recorded in {@link PluginServerNames.unresolved} rather
|
|
164
|
+
* than fetched. Paddock does not download anything to build an allowlist.
|
|
165
|
+
*/
|
|
166
|
+
export async function readPluginServerNames(dir) {
|
|
167
|
+
const manifest = await readJson(path.join(dir, PLUGIN_MANIFEST));
|
|
168
|
+
const pluginName = isRecord(manifest) && typeof manifest.name === "string" && manifest.name.trim() !== ""
|
|
169
|
+
? manifest.name.trim()
|
|
170
|
+
: path.basename(dir);
|
|
171
|
+
const servers = new Set(serverNamesOf(await readJson(path.join(dir, PLUGIN_MCP_FILE))));
|
|
172
|
+
const unresolved = [];
|
|
173
|
+
const declared = isRecord(manifest) ? manifest.mcpServers : undefined;
|
|
174
|
+
// A string entry is a path relative to the plugin directory (or an MCPB
|
|
175
|
+
// source, which looks the same and is not resolvable without downloading it).
|
|
176
|
+
// Resolve it only when it stays inside the directory and parses as JSON;
|
|
177
|
+
// anything else is a gap paddock reports rather than guesses at.
|
|
178
|
+
const resolveRef = async (ref) => {
|
|
179
|
+
const target = path.resolve(dir, ref);
|
|
180
|
+
if (target !== dir && !target.startsWith(dir + path.sep)) {
|
|
181
|
+
unresolved.push(ref);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const names = serverNamesOf(await readJson(target));
|
|
185
|
+
if (names.length === 0)
|
|
186
|
+
unresolved.push(ref);
|
|
187
|
+
else
|
|
188
|
+
for (const n of names)
|
|
189
|
+
servers.add(n);
|
|
190
|
+
};
|
|
191
|
+
if (typeof declared === "string")
|
|
192
|
+
await resolveRef(declared);
|
|
193
|
+
else if (Array.isArray(declared)) {
|
|
194
|
+
for (const entry of declared) {
|
|
195
|
+
if (typeof entry === "string")
|
|
196
|
+
await resolveRef(entry);
|
|
197
|
+
else
|
|
198
|
+
for (const n of serverNamesOf(entry))
|
|
199
|
+
servers.add(n);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else if (isRecord(declared)) {
|
|
203
|
+
for (const n of serverNamesOf(declared))
|
|
204
|
+
servers.add(n);
|
|
205
|
+
}
|
|
206
|
+
return { pluginName, servers: [...servers], unresolved };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Parse `installed_plugins.json`, v1 or v2. Pure, so the whole selection is
|
|
210
|
+
* testable from a string.
|
|
211
|
+
*
|
|
212
|
+
* v2 keys each `<name>@<marketplace>` id to an ARRAY of installations (one per
|
|
213
|
+
* scope), each carrying an explicit `installPath`. v1 keyed it to a single record
|
|
214
|
+
* with no path, which the CLI derives as
|
|
215
|
+
* `<root>/cache/<marketplace>/<name>/<version>` with each segment sanitised
|
|
216
|
+
* `[^a-zA-Z0-9\-_] → -` (and the version segment additionally allowing `.`).
|
|
217
|
+
*
|
|
218
|
+
* Fails soft in every direction: this is a file paddock does not own and must
|
|
219
|
+
* never turn into a boot failure.
|
|
220
|
+
*/
|
|
221
|
+
export function parseInstalledPlugins(raw, root) {
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(raw);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
if (!isRecord(parsed) || !isRecord(parsed.plugins))
|
|
230
|
+
return [];
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const [id, entry] of Object.entries(parsed.plugins)) {
|
|
233
|
+
if (Array.isArray(entry)) {
|
|
234
|
+
for (const install of entry) {
|
|
235
|
+
if (isRecord(install) && typeof install.installPath === "string" && install.installPath) {
|
|
236
|
+
out.push({ id, installPath: install.installPath });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (isRecord(entry)) {
|
|
241
|
+
const derived = legacyInstallPath(root, id, entry.version);
|
|
242
|
+
if (derived)
|
|
243
|
+
out.push({ id, installPath: derived });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
/** The v1 path derivation, kept beside its only caller. */
|
|
249
|
+
function legacyInstallPath(root, id, version) {
|
|
250
|
+
if (typeof version !== "string" || version === "")
|
|
251
|
+
return undefined;
|
|
252
|
+
const at = id.lastIndexOf("@");
|
|
253
|
+
const name = at > 0 ? id.slice(0, at) : id;
|
|
254
|
+
const marketplace = at > 0 ? id.slice(at + 1) : "unknown";
|
|
255
|
+
const seg = (s) => s.replace(/[^a-zA-Z0-9\-_]/g, "-");
|
|
256
|
+
let ver = version.replace(/[^a-zA-Z0-9\-_.]/g, "-");
|
|
257
|
+
if (ver === "." || ver === "..")
|
|
258
|
+
ver = "-";
|
|
259
|
+
return path.join(root, "cache", seg(marketplace || "unknown"), seg(name), ver);
|
|
260
|
+
}
|
|
261
|
+
/** Ids the host has explicitly turned off, from its `settings.json`. */
|
|
262
|
+
function disabledIds(settings) {
|
|
263
|
+
const out = new Set();
|
|
264
|
+
if (!isRecord(settings) || !isRecord(settings.enabledPlugins))
|
|
265
|
+
return out;
|
|
266
|
+
for (const [id, value] of Object.entries(settings.enabledPlugins)) {
|
|
267
|
+
// Anything that is not an explicit `false` counts as enabled: the key also
|
|
268
|
+
// takes an extended object form carrying a version constraint, and a version
|
|
269
|
+
// constraint is not a disable.
|
|
270
|
+
if (value === false)
|
|
271
|
+
out.add(id.toLowerCase());
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Enumerate the host's installed plugin directories. Pure apart from reading the
|
|
277
|
+
* plugin tree, and takes the two files' contents rather than paths so the whole
|
|
278
|
+
* selection is testable without a home directory.
|
|
279
|
+
*/
|
|
280
|
+
export async function enumerateHostPlugins(opts) {
|
|
281
|
+
const caveats = [];
|
|
282
|
+
if (opts.installed === undefined)
|
|
283
|
+
return { plugins: [], toolPatterns: [], caveats };
|
|
284
|
+
const disabled = disabledIds(opts.settings);
|
|
285
|
+
const plugins = [];
|
|
286
|
+
const toolPatterns = new Set();
|
|
287
|
+
// One plugin can be recorded under several scopes, and two ids can in principle
|
|
288
|
+
// resolve to one directory. `--plugin-dir` the same path twice and the CLI loads
|
|
289
|
+
// it twice under one name, so collapse them here.
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
for (const install of parseInstalledPlugins(opts.installed, opts.root)) {
|
|
292
|
+
if (disabled.has(install.id.toLowerCase()))
|
|
293
|
+
continue;
|
|
294
|
+
if (seen.has(install.installPath))
|
|
295
|
+
continue;
|
|
296
|
+
seen.add(install.installPath);
|
|
297
|
+
// A registry entry whose directory is gone is the normal aftermath of a
|
|
298
|
+
// hand-deleted plugin. The SDK would log "Plugin path does not exist" and
|
|
299
|
+
// carry on, but saying it here is what makes an empty `plugins` explicable.
|
|
300
|
+
try {
|
|
301
|
+
if (!(await fs.stat(install.installPath)).isDirectory())
|
|
302
|
+
throw new Error("not a directory");
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
caveats.push({
|
|
306
|
+
name: install.id,
|
|
307
|
+
kind: "dropped",
|
|
308
|
+
reason: `its recorded install directory (${install.installPath}) is missing`,
|
|
309
|
+
});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
plugins.push(opts.mcp
|
|
313
|
+
? { type: "local", path: install.installPath }
|
|
314
|
+
: { type: "local", path: install.installPath, skipMcpDiscovery: true });
|
|
315
|
+
if (!opts.mcp)
|
|
316
|
+
continue;
|
|
317
|
+
const { pluginName, servers, unresolved } = await readPluginServerNames(install.installPath);
|
|
318
|
+
for (const server of servers)
|
|
319
|
+
toolPatterns.add(pluginMcpToolPattern(pluginName, server));
|
|
320
|
+
if (unresolved.length > 0) {
|
|
321
|
+
caveats.push({
|
|
322
|
+
name: install.id,
|
|
323
|
+
kind: "degraded",
|
|
324
|
+
reason: `its manifest points \`mcpServers\` at ${unresolved.join(", ")} rather than declaring ` +
|
|
325
|
+
`them inline, so paddock cannot name its servers — the plugin is attached, but any ` +
|
|
326
|
+
`tool it provides will be auto-denied with no prompt until you add the matching ` +
|
|
327
|
+
`\`mcp__plugin_${normalizeServerName(pluginName)}_<server>__*\` to the keeper's ` +
|
|
328
|
+
`allowed tools`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { plugins, toolPatterns: [...toolPatterns], caveats };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Read the host's plugin registry — once, at boot — and say what came of it.
|
|
336
|
+
*
|
|
337
|
+
* Under `instructions: own` nothing is opened at all, for the same reason
|
|
338
|
+
* `loadHostMcpSource` does not open `.claude.json` under `mcpServers: own`: "own
|
|
339
|
+
* everywhere means nothing outside the data dir is read" is a guarantee that has
|
|
340
|
+
* to be true in the place someone would check it.
|
|
341
|
+
*/
|
|
342
|
+
export async function loadHostPlugins(cfg) {
|
|
343
|
+
const notices = [];
|
|
344
|
+
const mcp = cfg.claude.mcpServers === "host";
|
|
345
|
+
if (cfg.claude.instructions !== "host") {
|
|
346
|
+
if (mcp) {
|
|
347
|
+
// The one combination that silently does less than a user asked for, so it
|
|
348
|
+
// is the one that gets a line naming the other key.
|
|
349
|
+
notices.push({
|
|
350
|
+
level: "info",
|
|
351
|
+
message: "`claude.mcpServers: host` does not reach MCP servers provided by a Claude Code " +
|
|
352
|
+
"plugin: a plugin is mostly commands, agents and skills, so loading one is a " +
|
|
353
|
+
"`claude.instructions` decision. Set `claude.instructions: host` (or " +
|
|
354
|
+
"PADDOCK_CLAUDE_INSTRUCTIONS=host) as well to inherit your plugins.",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return { source: EMPTY_HOST_PLUGINS, notices };
|
|
358
|
+
}
|
|
359
|
+
const root = hostPluginRoot(cfg.legacyClaudeHome);
|
|
360
|
+
const file = path.join(root, INSTALLED_PLUGINS_FILE);
|
|
361
|
+
let installed;
|
|
362
|
+
try {
|
|
363
|
+
installed = await fs.readFile(file, "utf8");
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Not a warning: no plugins installed is the overwhelmingly common case, and
|
|
367
|
+
// this file does not exist until the first `claude plugin install`.
|
|
368
|
+
return { source: EMPTY_HOST_PLUGINS, notices };
|
|
369
|
+
}
|
|
370
|
+
const settings = await readJson(path.join(cfg.legacyClaudeHome, "settings.json"));
|
|
371
|
+
const source = await enumerateHostPlugins({ root, installed, settings, mcp });
|
|
372
|
+
if (source.plugins.length > 0) {
|
|
373
|
+
notices.push({
|
|
374
|
+
level: "info",
|
|
375
|
+
message: `Claude plugins: host (\`claude.instructions: host\`) — ${source.plugins.length} ` +
|
|
376
|
+
`installed plugin${source.plugins.length === 1 ? "" : "s"} attached to every keeper` +
|
|
377
|
+
(mcp
|
|
378
|
+
? source.toolPatterns.length > 0
|
|
379
|
+
? `, including their MCP servers (${source.toolPatterns.join(", ")})`
|
|
380
|
+
: ", including their MCP servers (none declared)"
|
|
381
|
+
: ". Their MCP servers are NOT attached (`claude.mcpServers: own`)") +
|
|
382
|
+
".",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
for (const caveat of source.caveats) {
|
|
386
|
+
notices.push({
|
|
387
|
+
level: "warn",
|
|
388
|
+
message: caveat.kind === "dropped"
|
|
389
|
+
? `Claude plugin "${caveat.name}" was NOT attached: ${caveat.reason}.`
|
|
390
|
+
: `Claude plugin "${caveat.name}" is attached but incomplete: ${caveat.reason}.`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return { source, notices };
|
|
394
|
+
}
|
|
@@ -53,4 +53,4 @@ export declare function nodeVersionProblem(nodeVersion: string): string | undefi
|
|
|
53
53
|
* flag that fixes it.
|
|
54
54
|
*/
|
|
55
55
|
export declare function explainListenError(err: unknown, host: string, port: string): string;
|
|
56
|
-
export declare const USAGE = "paddock \u2014 run a Paddock instance locally\n\nUsage\n npx @edspencer/paddock [options]\n\nOptions\n -p, --port <port> HTTP/WS port (default 4000, or $PORT)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)\n --here Open the CURRENT directory as the workspace (see below)\n -o, --open Open the app in your browser once it is listening\n --verbose Show the server's own logs (quiet by default)\n -v, --version Print the Paddock version and exit\n -h, --help Show this help\n\nOpening a directory (--here)\n Run `paddock --here` inside a project and Paddock opens THAT directory as its\n workspace: Claude works in your files, and any Claude Code sessions you already\n have for the directory are offered for import. It:\n\n \u00B7 creates .paddock/ in the directory for this workspace's own state\n \u00B7 creates .chats/ for transcripts, and adds both to .gitignore\n \u00B7 offers your ~/.claude sessions for this directory for import \u2014 nothing\n there is moved, copied or linked until you confirm\n\n Once done, later runs in the same directory resume it \u2014 no flag needed.\n Without --here, Paddock never touches the directory you ran it from.\n\nCredentials\n Paddock drives Claude Code, so it needs Claude credentials \u2014 and if you\n already use Claude Code on this machine, it uses the login you already have.\n That is a macOS Keychain entry on a Mac, or your ~/.claude/.credentials.json\n elsewhere (symlinked in, never copied). Reading a login writes nothing.\n\n Otherwise: a CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment,\n or a one-off `CLAUDE_CONFIG_DIR=<data-dir>/claude-home claude login`. With no\n login at all anywhere, run `claude setup-token`. Paddock says at startup when\n it can find none.\n\nSharing your Claude Code state\n Apart from that login, Paddock writes nothing outside its data dir by\n default: transcripts go to each project's .chats/, and your ~/.claude is read\n for config only. Each thing it can share is one key in\n <data-dir>/paddock.config.yaml:\n\n claude:\n transcripts: host # own | host, default own\n credentials: host # own | host, default host\n instructions: host # own | host, default own\n hooks: host # own | host, default own\n\n transcripts: host makes a chat and a `claude --resume` in the same directory\n the same file, live in both directions; deleting such a chat in Paddock\n releases it rather than removing it, because it is your history rather than\n Paddock's copy. credentials: own is the opt-out from sharing the login above.\n\n instructions: host loads your ~/.claude CLAUDE.md, agents/, commands/ and\n plugins/. Off by default, which is a change: your curated CLAUDE.md does not\n reach Paddock's agents unless you say so. Each project's own CLAUDE.md always\n does.\n\n hooks: host runs the shell commands your ~/.claude/settings.json binds to\n tool use. Off by default \u2014 inheriting someone's hooks is not something to\n discover after the fact. Its other keys (permissions, model, statusline)\n apply either way: under hooks: own Paddock writes its own settings.json\n carrying them with hooks dropped, regenerated at startup.\n\nYour data\n Everything lives in one directory \u2014 ~/.paddock unless you pass --data-dir.\n Projects, chat transcripts and settings all persist there between runs. Move\n that directory to move your instance; delete it to start over. Nothing is\n stored anywhere else.\n\nNotes\n Binds loopback with authentication disabled, which is safe for a laptop. To\n expose it on a network, set PADDOCK_AUTH_MODE first \u2014 Paddock refuses to bind\n a routable interface wide open. See AUTH.md.\n\nDocs: https://github.com/edspencer/paddock";
|
|
56
|
+
export declare const USAGE = "paddock \u2014 run a Paddock instance locally\n\nUsage\n npx @edspencer/paddock [options]\n\nOptions\n -p, --port <port> HTTP/WS port (default 4000, or $PORT)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)\n --here Open the CURRENT directory as the workspace (see below)\n -o, --open Open the app in your browser once it is listening\n --verbose Show the server's own logs (quiet by default)\n -v, --version Print the Paddock version and exit\n -h, --help Show this help\n\nOpening a directory (--here)\n Run `paddock --here` inside a project and Paddock opens THAT directory as its\n workspace: Claude works in your files, and any Claude Code sessions you already\n have for the directory are offered for import. It:\n\n \u00B7 creates .paddock/ in the directory for this workspace's own state\n \u00B7 creates .chats/ for transcripts, and adds both to .gitignore\n \u00B7 offers your ~/.claude sessions for this directory for import \u2014 nothing\n there is moved, copied or linked until you confirm\n\n Once done, later runs in the same directory resume it \u2014 no flag needed.\n Without --here, Paddock never touches the directory you ran it from.\n\nCredentials\n Paddock drives Claude Code, so it needs Claude credentials \u2014 and if you\n already use Claude Code on this machine, it uses the login you already have.\n That is a macOS Keychain entry on a Mac, or your ~/.claude/.credentials.json\n elsewhere (symlinked in, never copied). Reading a login writes nothing.\n\n Otherwise: a CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment,\n or a one-off `CLAUDE_CONFIG_DIR=<data-dir>/claude-home claude login`. With no\n login at all anywhere, run `claude setup-token`. Paddock says at startup when\n it can find none.\n\nSharing your Claude Code state\n Apart from that login, Paddock writes nothing outside its data dir by\n default: transcripts go to each project's .chats/, and your ~/.claude is read\n for config only. Each thing it can share is one key in\n <data-dir>/paddock.config.yaml:\n\n claude:\n transcripts: host # own | host, default own\n credentials: host # own | host, default host\n instructions: host # own | host, default own\n hooks: host # own | host, default own\n mcpServers: host # own | host, default own\n\n transcripts: host makes a chat and a `claude --resume` in the same directory\n the same file, live in both directions; deleting such a chat in Paddock\n releases it rather than removing it, because it is your history rather than\n Paddock's copy. credentials: own is the opt-out from sharing the login above.\n\n instructions: host loads your ~/.claude CLAUDE.md, agents/, commands/ and\n plugins/. Off by default, which is a change: your curated CLAUDE.md does not\n reach Paddock's agents unless you say so. Each project's own CLAUDE.md always\n does.\n\n hooks: host runs the shell commands your ~/.claude/settings.json binds to\n tool use. Off by default \u2014 inheriting someone's hooks is not something to\n discover after the fact. Its other keys (permissions, model, statusline)\n apply either way: under hooks: own Paddock writes its own settings.json\n carrying them with hooks dropped, regenerated at startup.\n\n mcpServers: host attaches the MCP servers declared in your ~/.claude.json \u2014\n the top-level ones, plus a project's own when its directory matches. To give\n this instance a server your machine doesn't have, declare it instead in a\n sibling mcpServers: block of the same file; use env:VAR_NAME anywhere a\n string goes so tokens stay out of it. That keeps a token out of the file, not\n out of `ps`: leave driveMode on its default (session) for a server holding\n one, since batch passes the definition to claude as a command-line argument.\n\nYour data\n Everything lives in one directory \u2014 ~/.paddock unless you pass --data-dir.\n Projects, chat transcripts and settings all persist there between runs. Move\n that directory to move your instance; delete it to start over. Nothing is\n stored anywhere else.\n\nNotes\n Binds loopback with authentication disabled, which is safe for a laptop. To\n expose it on a network, set PADDOCK_AUTH_MODE first \u2014 Paddock refuses to bind\n a routable interface wide open. See AUTH.md.\n\nDocs: https://github.com/edspencer/paddock";
|
|
@@ -162,6 +162,7 @@ Sharing your Claude Code state
|
|
|
162
162
|
credentials: host # own | host, default host
|
|
163
163
|
instructions: host # own | host, default own
|
|
164
164
|
hooks: host # own | host, default own
|
|
165
|
+
mcpServers: host # own | host, default own
|
|
165
166
|
|
|
166
167
|
transcripts: host makes a chat and a \`claude --resume\` in the same directory
|
|
167
168
|
the same file, live in both directions; deleting such a chat in Paddock
|
|
@@ -179,6 +180,14 @@ Sharing your Claude Code state
|
|
|
179
180
|
apply either way: under hooks: own Paddock writes its own settings.json
|
|
180
181
|
carrying them with hooks dropped, regenerated at startup.
|
|
181
182
|
|
|
183
|
+
mcpServers: host attaches the MCP servers declared in your ~/.claude.json —
|
|
184
|
+
the top-level ones, plus a project's own when its directory matches. To give
|
|
185
|
+
this instance a server your machine doesn't have, declare it instead in a
|
|
186
|
+
sibling mcpServers: block of the same file; use env:VAR_NAME anywhere a
|
|
187
|
+
string goes so tokens stay out of it. That keeps a token out of the file, not
|
|
188
|
+
out of \`ps\`: leave driveMode on its default (session) for a server holding
|
|
189
|
+
one, since batch passes the definition to claude as a command-line argument.
|
|
190
|
+
|
|
182
191
|
Your data
|
|
183
192
|
Everything lives in one directory — ~/.paddock unless you pass --data-dir.
|
|
184
193
|
Projects, chat transcripts and settings all persist there between runs. Move
|
|
@@ -126,7 +126,7 @@ async function main() {
|
|
|
126
126
|
if (args.dataDir !== undefined)
|
|
127
127
|
process.env.PADDOCK_DATA_DIR = args.dataDir;
|
|
128
128
|
const cfg = loadPaddockConfig();
|
|
129
|
-
const projects = new ProjectStore(cfg.projectsRoot);
|
|
129
|
+
const projects = new ProjectStore(cfg.projectsRoot, cfg.dataDir);
|
|
130
130
|
const herdctl = new HerdctlService(cfg);
|
|
131
131
|
const project = await projects.get(args.project);
|
|
132
132
|
// Register the keepers so the agent this imports INTO exists and its `.chats/`
|
|
@@ -46,6 +46,8 @@ export declare class GitService {
|
|
|
46
46
|
private readonly author;
|
|
47
47
|
/** Cached repo-detection result for `projectsRoot` (null = not yet checked). */
|
|
48
48
|
private repoFlag;
|
|
49
|
+
/** Cached per-directory repo detection, keyed by resolved path (#597 / #206). */
|
|
50
|
+
private readonly repoAt;
|
|
49
51
|
/**
|
|
50
52
|
* @param projectsRoot Absolute path to the projects root the service operates on.
|
|
51
53
|
* @param author Commit identity used when Paddock commits on a project's behalf.
|
|
@@ -62,9 +64,36 @@ export declare class GitService {
|
|
|
62
64
|
* Whether the backing store is a git working tree. Cached after the first
|
|
63
65
|
* successful check (repo-ness doesn't change under a running server); a thrown
|
|
64
66
|
* git / missing-binary error resolves to `false`.
|
|
67
|
+
*
|
|
68
|
+
* Store-wide, so it still gates the store-wide operations ({@link dirtyCounts},
|
|
69
|
+
* {@link remote}, {@link push}). The PER-PROJECT surface asks
|
|
70
|
+
* {@link isRepoAt} about the project's own working directory instead — see
|
|
71
|
+
* that method for why the two had to be separated.
|
|
65
72
|
*/
|
|
66
73
|
isRepo(): Promise<boolean>;
|
|
67
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Whether `dir` is inside a git working tree, cached per directory.
|
|
76
|
+
*
|
|
77
|
+
* The per-project git surface used to gate on {@link isRepo} — "is the PROJECTS
|
|
78
|
+
* ROOT a repo?" — which was right only while every project's tree lived under
|
|
79
|
+
* that root. It stopped being right twice over (issue #597, issue #206):
|
|
80
|
+
*
|
|
81
|
+
* - a project whose working directory is a linked checkout outside the root
|
|
82
|
+
* would report "not a repo" whenever the root itself wasn't one, hiding the
|
|
83
|
+
* Changes tab for the exact directory that IS a repo;
|
|
84
|
+
* - conversely a non-repo directory inside a repo root would claim to be one.
|
|
85
|
+
*
|
|
86
|
+
* Asking about the directory the project actually works in answers both. For
|
|
87
|
+
* every pre-existing shape it returns what {@link isRepo} did, because a
|
|
88
|
+
* project dir under a repo root is itself inside that work tree.
|
|
89
|
+
*
|
|
90
|
+
* Probing rather than requiring is the house style: nothing here refuses a
|
|
91
|
+
* directory for not being a repo, it just doesn't light up the git features.
|
|
92
|
+
*/
|
|
93
|
+
isRepoAt(dir: string): Promise<boolean>;
|
|
94
|
+
/** One `rev-parse`, false on any failure (missing binary, missing dir, not a repo). */
|
|
95
|
+
private probeRepo;
|
|
96
|
+
/** Force the next repo check to re-run (e.g. after `git init` at runtime). */
|
|
68
97
|
resetRepoCache(): void;
|
|
69
98
|
/**
|
|
70
99
|
* Status for one project's subtree. Returns `{ repo: false }` when the store
|
|
@@ -120,6 +149,20 @@ export declare class GitService {
|
|
|
120
149
|
* when the store isn't a repo.
|
|
121
150
|
*/
|
|
122
151
|
dirtyCounts(): Promise<Record<string, number>>;
|
|
152
|
+
/**
|
|
153
|
+
* Uncommitted-file count for ONE directory's own repository.
|
|
154
|
+
*
|
|
155
|
+
* The companion to {@link dirtyCounts} for a project whose working directory
|
|
156
|
+
* lives outside `projectsRoot` (issue #206). That method answers the whole grid
|
|
157
|
+
* in a single `git status` over the store, which is why it is cheap — and also
|
|
158
|
+
* why it structurally cannot see a linked checkout: it buckets paths by their
|
|
159
|
+
* first segment relative to the store, and a directory outside it has no such
|
|
160
|
+
* segment. This one costs a subprocess, so callers use it only for the projects
|
|
161
|
+
* the cheap sweep genuinely cannot cover.
|
|
162
|
+
*
|
|
163
|
+
* Returns 0 for a directory that isn't a repo, matching "nothing to report".
|
|
164
|
+
*/
|
|
165
|
+
dirtyCountAt(dir: string): Promise<number>;
|
|
123
166
|
/** Push the current branch to origin (sets upstream on first push). */
|
|
124
167
|
push(): Promise<{
|
|
125
168
|
pushed: boolean;
|
|
@@ -52,6 +52,8 @@ export class GitService {
|
|
|
52
52
|
author;
|
|
53
53
|
/** Cached repo-detection result for `projectsRoot` (null = not yet checked). */
|
|
54
54
|
repoFlag = null;
|
|
55
|
+
/** Cached per-directory repo detection, keyed by resolved path (#597 / #206). */
|
|
56
|
+
repoAt = new Map();
|
|
55
57
|
/**
|
|
56
58
|
* @param projectsRoot Absolute path to the projects root the service operates on.
|
|
57
59
|
* @param author Commit identity used when Paddock commits on a project's behalf.
|
|
@@ -74,25 +76,60 @@ export class GitService {
|
|
|
74
76
|
* Whether the backing store is a git working tree. Cached after the first
|
|
75
77
|
* successful check (repo-ness doesn't change under a running server); a thrown
|
|
76
78
|
* git / missing-binary error resolves to `false`.
|
|
79
|
+
*
|
|
80
|
+
* Store-wide, so it still gates the store-wide operations ({@link dirtyCounts},
|
|
81
|
+
* {@link remote}, {@link push}). The PER-PROJECT surface asks
|
|
82
|
+
* {@link isRepoAt} about the project's own working directory instead — see
|
|
83
|
+
* that method for why the two had to be separated.
|
|
77
84
|
*/
|
|
78
85
|
async isRepo() {
|
|
79
86
|
if (this.repoFlag !== null)
|
|
80
87
|
return this.repoFlag;
|
|
88
|
+
this.repoFlag = await this.probeRepo(this.projectsRoot);
|
|
89
|
+
return this.repoFlag;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether `dir` is inside a git working tree, cached per directory.
|
|
93
|
+
*
|
|
94
|
+
* The per-project git surface used to gate on {@link isRepo} — "is the PROJECTS
|
|
95
|
+
* ROOT a repo?" — which was right only while every project's tree lived under
|
|
96
|
+
* that root. It stopped being right twice over (issue #597, issue #206):
|
|
97
|
+
*
|
|
98
|
+
* - a project whose working directory is a linked checkout outside the root
|
|
99
|
+
* would report "not a repo" whenever the root itself wasn't one, hiding the
|
|
100
|
+
* Changes tab for the exact directory that IS a repo;
|
|
101
|
+
* - conversely a non-repo directory inside a repo root would claim to be one.
|
|
102
|
+
*
|
|
103
|
+
* Asking about the directory the project actually works in answers both. For
|
|
104
|
+
* every pre-existing shape it returns what {@link isRepo} did, because a
|
|
105
|
+
* project dir under a repo root is itself inside that work tree.
|
|
106
|
+
*
|
|
107
|
+
* Probing rather than requiring is the house style: nothing here refuses a
|
|
108
|
+
* directory for not being a repo, it just doesn't light up the git features.
|
|
109
|
+
*/
|
|
110
|
+
async isRepoAt(dir) {
|
|
111
|
+
const key = path.resolve(dir);
|
|
112
|
+
const cached = this.repoAt.get(key);
|
|
113
|
+
if (cached !== undefined)
|
|
114
|
+
return cached;
|
|
115
|
+
const found = await this.probeRepo(key);
|
|
116
|
+
this.repoAt.set(key, found);
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
/** One `rev-parse`, false on any failure (missing binary, missing dir, not a repo). */
|
|
120
|
+
async probeRepo(dir) {
|
|
81
121
|
try {
|
|
82
|
-
const out = await this.git(
|
|
83
|
-
|
|
84
|
-
"--is-inside-work-tree",
|
|
85
|
-
]);
|
|
86
|
-
this.repoFlag = out.trim() === "true";
|
|
122
|
+
const out = await this.git(dir, ["rev-parse", "--is-inside-work-tree"]);
|
|
123
|
+
return out.trim() === "true";
|
|
87
124
|
}
|
|
88
125
|
catch {
|
|
89
|
-
|
|
126
|
+
return false;
|
|
90
127
|
}
|
|
91
|
-
return this.repoFlag;
|
|
92
128
|
}
|
|
93
|
-
/** Force the next
|
|
129
|
+
/** Force the next repo check to re-run (e.g. after `git init` at runtime). */
|
|
94
130
|
resetRepoCache() {
|
|
95
131
|
this.repoFlag = null;
|
|
132
|
+
this.repoAt.clear();
|
|
96
133
|
}
|
|
97
134
|
/**
|
|
98
135
|
* Status for one project's subtree. Returns `{ repo: false }` when the store
|
|
@@ -100,7 +137,9 @@ export class GitService {
|
|
|
100
137
|
* `projectDir`.
|
|
101
138
|
*/
|
|
102
139
|
async projectStatus(projectDir) {
|
|
103
|
-
|
|
140
|
+
// Per-DIRECTORY, not store-wide: callers pass the project's workingDir, which
|
|
141
|
+
// may be a linked checkout outside `projectsRoot` entirely (#597 / #206).
|
|
142
|
+
if (!(await this.isRepoAt(projectDir))) {
|
|
104
143
|
return { repo: false, files: [], clean: true };
|
|
105
144
|
}
|
|
106
145
|
let branch;
|
|
@@ -188,7 +227,7 @@ export class GitService {
|
|
|
188
227
|
* (they're listed by `projectStatus`). Returns "" when not a repo / no diff.
|
|
189
228
|
*/
|
|
190
229
|
async projectDiff(projectDir, file) {
|
|
191
|
-
if (!(await this.
|
|
230
|
+
if (!(await this.isRepoAt(projectDir)))
|
|
192
231
|
return "";
|
|
193
232
|
const args = ["diff", "HEAD", "--", file ?? "."];
|
|
194
233
|
try {
|
|
@@ -259,7 +298,7 @@ export class GitService {
|
|
|
259
298
|
* Omitting `paths` keeps the legacy commit-the-whole-subtree behavior.
|
|
260
299
|
*/
|
|
261
300
|
async commitProject(projectDir, message, paths) {
|
|
262
|
-
if (!(await this.
|
|
301
|
+
if (!(await this.isRepoAt(projectDir)))
|
|
263
302
|
return { committed: false, error: "not a repo" };
|
|
264
303
|
const msg = message.trim() || "Update project";
|
|
265
304
|
let pathspec;
|
|
@@ -325,6 +364,35 @@ export class GitService {
|
|
|
325
364
|
return {};
|
|
326
365
|
}
|
|
327
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Uncommitted-file count for ONE directory's own repository.
|
|
369
|
+
*
|
|
370
|
+
* The companion to {@link dirtyCounts} for a project whose working directory
|
|
371
|
+
* lives outside `projectsRoot` (issue #206). That method answers the whole grid
|
|
372
|
+
* in a single `git status` over the store, which is why it is cheap — and also
|
|
373
|
+
* why it structurally cannot see a linked checkout: it buckets paths by their
|
|
374
|
+
* first segment relative to the store, and a directory outside it has no such
|
|
375
|
+
* segment. This one costs a subprocess, so callers use it only for the projects
|
|
376
|
+
* the cheap sweep genuinely cannot cover.
|
|
377
|
+
*
|
|
378
|
+
* Returns 0 for a directory that isn't a repo, matching "nothing to report".
|
|
379
|
+
*/
|
|
380
|
+
async dirtyCountAt(dir) {
|
|
381
|
+
if (!(await this.isRepoAt(dir)))
|
|
382
|
+
return 0;
|
|
383
|
+
try {
|
|
384
|
+
const out = await this.git(dir, [
|
|
385
|
+
"status",
|
|
386
|
+
"--porcelain=v1",
|
|
387
|
+
"-z",
|
|
388
|
+
"--untracked-files=all",
|
|
389
|
+
]);
|
|
390
|
+
return parsePorcelainZ(out).length;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
328
396
|
/** Push the current branch to origin (sets upstream on first push). */
|
|
329
397
|
async push() {
|
|
330
398
|
if (!(await this.isRepo()))
|