@savvy-web/mcp 1.7.6 → 1.8.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 +3 -1
- package/index.d.ts +7 -6
- package/package.json +2 -2
- package/runtime.js +16 -5
- package/server.js +51 -0
- package/tools/md-inline.js +22 -0
- package/tools/repos-inspect.js +104 -0
- package/tools/repos-manage.js +195 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@savvy-web/mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
The `savvy-mcp` [Model Context Protocol](https://modelcontextprotocol.io/) server. It serves [Silk Suite](https://github.com/savvy-web/systems) tooling to coding agents as structured tools, so an agent can read workspace facts and run Silk checks instead of parsing console output or guessing. It is a tools-only server —
|
|
6
|
+
The `savvy-mcp` [Model Context Protocol](https://modelcontextprotocol.io/) server. It serves [Silk Suite](https://github.com/savvy-web/systems) tooling to coding agents as structured tools, so an agent can read workspace facts and run Silk checks instead of parsing console output or guessing. It is a tools-only server — ten tools, no resources.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -49,6 +49,8 @@ npx @modelcontextprotocol/inspector savvy-mcp .
|
|
|
49
49
|
- `changeset_deps_detect` — read-only preview of the cumulative dependency diff (merge-base to working tree): one entry per affected workspace package with its resolved dependency-table rows, `catalog:`/`workspace:` specifiers resolved to concrete versions. It never writes a changeset. Backed by `silk-effects`' `Changesets.DepsRegen.plan`.
|
|
50
50
|
- `changeset_deps_regen` — regenerates pure-dependency changesets: deletes stale ones and writes fresh single-package, patch-bump changesets from the cumulative dependency diff. Mutating unless `dryRun` is set, in which case it reports what it would delete and write without touching the filesystem. Backed by `silk-effects`' `Changesets.DepsRegen`.
|
|
51
51
|
- `biome_check` — run Biome over a path and get structured diagnostics back: `mode=check` (lint, format and organize-imports) or `mode=lint`. Unlike most of the other tools it can mutate — pass `write` for safe fixes or `unsafe` for unsafe ones (both git-reversible) — so it returns the same diagnostics the Biome LSP surfaces for files you have edited.
|
|
52
|
+
- `repos_inspect` — read-only inspection of vendored repositories: `mode=status` reports per-repo presence, the gitlink commit, working-tree dirtiness, and stale note ids; `mode=config` surfaces the validated `.repos/config.json` manifest and its entries. Returns markdown-escaped output since vendored-repo content is untrusted input. Backed by the same `silk-effects` `Repos` services the `savvy` CLI uses.
|
|
53
|
+
- `repos_manage` — manages vendored repositories (mutating counterpart to repos_inspect): `action=sync` initializes any missing submodules (`git submodule update --init --depth 1`), applies sparse-checkout from the manifest, and clears stale git locks; `action=pin` fetches and checks out the new ref, staging the updated gitlink and manifest; `action=add` adds a new repo entry; `action=note` appends a short note to a repo. Pin and add stage git changes for the caller to commit. Backed by the same `silk-effects` `Repos` services the `savvy` CLI uses.
|
|
52
54
|
|
|
53
55
|
## License
|
|
54
56
|
|
package/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { Changesets, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
|
|
1
|
+
import { Changesets, Repos, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
|
|
2
2
|
import { Layer, ManagedRuntime } from "effect";
|
|
3
3
|
import { WorkspaceDiscoveryError, WorkspaceRoot } from "workspaces-effect";
|
|
4
4
|
import "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
//#region src/context.d.ts
|
|
6
6
|
/** The long-lived runtime and the project working directory. */
|
|
7
7
|
interface McpContext {
|
|
8
|
-
readonly runtime: ManagedRuntime.ManagedRuntime<SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector | Changesets.ReleasePlanner | Changesets.DepsRegen, WorkspaceDiscoveryError>;
|
|
8
|
+
readonly runtime: ManagedRuntime.ManagedRuntime<SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector | Changesets.ReleasePlanner | Changesets.DepsRegen | Repos.ReposManager | Repos.ReposConfigStore, WorkspaceDiscoveryError>;
|
|
9
9
|
readonly cwd: string;
|
|
10
10
|
}
|
|
11
11
|
//#endregion
|
|
@@ -13,16 +13,17 @@ interface McpContext {
|
|
|
13
13
|
/**
|
|
14
14
|
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
15
15
|
* `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`,
|
|
16
|
-
* `Changesets.ConfigInspector`, `Changesets.ReleasePlanner`,
|
|
17
|
-
* `Changesets.DepsRegen
|
|
18
|
-
* from the host's platform
|
|
16
|
+
* `Changesets.ConfigInspector`, `Changesets.ReleasePlanner`,
|
|
17
|
+
* `Changesets.DepsRegen`, `Repos.ReposManager`, and `Repos.ReposConfigStore`;
|
|
18
|
+
* requires `CommandExecutor` + `FileSystem` + `Path` from the host's platform
|
|
19
|
+
* layer (`NodeContext.layer` in bin.ts).
|
|
19
20
|
*
|
|
20
21
|
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
21
22
|
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
22
23
|
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
23
24
|
* host platform layer.
|
|
24
25
|
*/
|
|
25
|
-
declare const SilkRuntimeLive: Layer.Layer<Changesets.BranchAnalyzer | Changesets.ConfigInspector | Changesets.DepsRegen | Changesets.ReleasePlanner | import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | Turbo.TurboInspector | import("workspaces-effect").WorkspaceRoot, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/CommandExecutor").CommandExecutor | import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path>;
|
|
26
|
+
declare const SilkRuntimeLive: Layer.Layer<Changesets.BranchAnalyzer | Changesets.ConfigInspector | Changesets.DepsRegen | Changesets.ReleasePlanner | Repos.ReposConfigStore | Repos.ReposManager | import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | Turbo.TurboInspector | import("workspaces-effect").WorkspaceRoot, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/CommandExecutor").CommandExecutor | import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path>;
|
|
26
27
|
//#endregion
|
|
27
28
|
//#region src/server.d.ts
|
|
28
29
|
/** Build the server and connect it over stdio. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@savvy-web/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The savvy MCP server — Silk Suite tooling and library knowledge for coding agents",
|
|
6
6
|
"homepage": "https://github.com/savvy-web/systems/tree/main/packages/mcp",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@effect/sql": "^0.51.1",
|
|
39
39
|
"@effect/workflow": "^0.18.2",
|
|
40
40
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
41
|
-
"@savvy-web/silk-effects": "3.
|
|
41
|
+
"@savvy-web/silk-effects": "3.3.0",
|
|
42
42
|
"effect": "^3.21.4",
|
|
43
43
|
"workspaces-effect": "^2.0.3",
|
|
44
44
|
"zod": "^4.4.3"
|
package/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangesetConfigLive, ChangesetConfigReaderLive, Changesets, SilkWorkspaceAnalyzerLive, TagStrategyLive, ToolDiscoveryLive, Turbo, VersioningStrategyLive } from "@savvy-web/silk-effects";
|
|
1
|
+
import { ChangesetConfigLive, ChangesetConfigReaderLive, Changesets, Repos, SilkWorkspaceAnalyzerLive, TagStrategyLive, ToolDiscoveryLive, Turbo, VersioningStrategyLive } from "@savvy-web/silk-effects";
|
|
2
2
|
import { Layer } from "effect";
|
|
3
3
|
import { PointInTimeWorkspaceLive, WorkspaceRootLive, WorkspacesLive } from "workspaces-effect";
|
|
4
4
|
|
|
@@ -43,18 +43,29 @@ const InspectorAndAnalyzerLive = Changesets.BranchAnalyzerLive.pipe(Layer.provid
|
|
|
43
43
|
*/
|
|
44
44
|
const DepsRegenGroupLive = Changesets.DepsRegenLive.pipe(Layer.provide(InspectorAndAnalyzerLive), Layer.provide(PointInTimeWorkspaceLive), Layer.provide(ChangesetConfigLive.pipe(Layer.provide(ChangesetConfigReaderLive))));
|
|
45
45
|
/**
|
|
46
|
+
* `Repos.ReposManager` + `Repos.ReposConfigStore`, both exposed on the
|
|
47
|
+
* runtime. `ReposManagerLive` requires `ReposConfigStore`, so it is given its
|
|
48
|
+
* own `ReposConfigStoreLive` reference here (`Layer.mergeAll` does not
|
|
49
|
+
* cross-feed sibling layers); `ReposConfigStore` is ALSO merged in directly so
|
|
50
|
+
* `repos_inspect`'s config mode can resolve it on its own. The remaining
|
|
51
|
+
* `CommandExecutor` + `FileSystem` + `Path` requirements flow up to the host
|
|
52
|
+
* platform layer (`NodeContext.layer` in bin.ts).
|
|
53
|
+
*/
|
|
54
|
+
const ReposGroupLive = Layer.mergeAll(Repos.ReposConfigStoreLive, Repos.ReposManagerLive.pipe(Layer.provide(Repos.ReposConfigStoreLive)));
|
|
55
|
+
/**
|
|
46
56
|
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
47
57
|
* `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`,
|
|
48
|
-
* `Changesets.ConfigInspector`, `Changesets.ReleasePlanner`,
|
|
49
|
-
* `Changesets.DepsRegen
|
|
50
|
-
* from the host's platform
|
|
58
|
+
* `Changesets.ConfigInspector`, `Changesets.ReleasePlanner`,
|
|
59
|
+
* `Changesets.DepsRegen`, `Repos.ReposManager`, and `Repos.ReposConfigStore`;
|
|
60
|
+
* requires `CommandExecutor` + `FileSystem` + `Path` from the host's platform
|
|
61
|
+
* layer (`NodeContext.layer` in bin.ts).
|
|
51
62
|
*
|
|
52
63
|
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
53
64
|
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
54
65
|
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
55
66
|
* host platform layer.
|
|
56
67
|
*/
|
|
57
|
-
const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive)), InspectorAndAnalyzerLive, DepsRegenGroupLive).pipe(Layer.provide(DepsLive));
|
|
68
|
+
const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive)), InspectorAndAnalyzerLive, DepsRegenGroupLive, ReposGroupLive).pipe(Layer.provide(DepsLive));
|
|
58
69
|
|
|
59
70
|
//#endregion
|
|
60
71
|
export { SilkRuntimeLive };
|
package/server.js
CHANGED
|
@@ -5,6 +5,8 @@ import { ChangesetDepsRegenAsMarkdown, ChangesetDepsRegenResult, changesetDepsRe
|
|
|
5
5
|
import { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect } from "./tools/changeset-inspect.js";
|
|
6
6
|
import { ChangesetPreviewAsMarkdown, ChangesetPreviewResult, changesetPreview } from "./tools/changeset-preview.js";
|
|
7
7
|
import { ChangesetValidateAsMarkdown, ChangesetValidateResult, changesetValidate } from "./tools/changeset-validate.js";
|
|
8
|
+
import { ReposInspectAsMarkdown, ReposInspectResult, reposInspect } from "./tools/repos-inspect.js";
|
|
9
|
+
import { ReposManageAsMarkdown, ReposManageResult, reposManage } from "./tools/repos-manage.js";
|
|
8
10
|
import { TurboInspectAsMarkdown, TurboInspectResult, turboInspect } from "./tools/turbo-inspect.js";
|
|
9
11
|
import { WorkspaceInfoAsMarkdown, WorkspaceInfoResult, workspaceInfo } from "./tools/workspace-info.js";
|
|
10
12
|
import { CURRENT_MCP_VERSION } from "./version.js";
|
|
@@ -141,6 +143,55 @@ function buildServer(ctx) {
|
|
|
141
143
|
const text = Schema.decodeSync(ChangesetDepsRegenAsMarkdown)(data);
|
|
142
144
|
return structuredResult(text, data);
|
|
143
145
|
});
|
|
146
|
+
server.registerTool("repos_inspect", {
|
|
147
|
+
title: "Inspect vendored repos",
|
|
148
|
+
description: "Read-only: drift report or parsed .repos/config.json manifest with orientation and notes.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
mode: z.enum(["status", "config"]).describe("status = drift report; config = the full agent brief."),
|
|
151
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
152
|
+
},
|
|
153
|
+
outputSchema: effectToZodSchema(ReposInspectResult),
|
|
154
|
+
annotations: { readOnlyHint: true }
|
|
155
|
+
}, async (args) => {
|
|
156
|
+
const data = await ctx.runtime.runPromise(reposInspect(args, ctx.cwd));
|
|
157
|
+
const text = Schema.decodeSync(ReposInspectAsMarkdown)(data);
|
|
158
|
+
return structuredResult(text, data);
|
|
159
|
+
});
|
|
160
|
+
server.registerTool("repos_manage", {
|
|
161
|
+
title: "Manage vendored repos",
|
|
162
|
+
description: "Mutating: sync (initialize/reconcile submodules per the manifest), pin (re-pin a repo to a new ref), add (vendor a new repo), or note (add/remove/promote an agent note). Pass action plus the fields that action needs: pin needs name+ref; add needs url+ref+purpose (name/sparse optional); note needs name+op, plus note (op=add), id (op=remove), or id+into (op=promote). A decode failure names the missing field. The pin result's markdown surfaces commitMessage and staleNoteIds — review and commit after pinning.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
action: z.enum([
|
|
165
|
+
"sync",
|
|
166
|
+
"pin",
|
|
167
|
+
"add",
|
|
168
|
+
"note"
|
|
169
|
+
]).describe("Which mutation to perform."),
|
|
170
|
+
name: z.optional(z.string()).describe("Repo name (pin, note; optional override for add)."),
|
|
171
|
+
ref: z.optional(z.string()).describe("Git ref to pin/vendor to (pin, add)."),
|
|
172
|
+
url: z.optional(z.string()).describe("Repo URL to vendor (add)."),
|
|
173
|
+
purpose: z.optional(z.string()).describe("One-line purpose for the manifest (add)."),
|
|
174
|
+
sparse: z.optional(z.array(z.string())).describe("Sparse-checkout patterns (add)."),
|
|
175
|
+
op: z.optional(z.enum([
|
|
176
|
+
"add",
|
|
177
|
+
"remove",
|
|
178
|
+
"promote"
|
|
179
|
+
])).describe("Note operation (note)."),
|
|
180
|
+
note: z.optional(z.string()).describe("Note text (note, op=add)."),
|
|
181
|
+
id: z.optional(z.string()).describe("Note id (note, op=remove|promote)."),
|
|
182
|
+
into: z.optional(z.enum(["layout", "startHere"])).describe("Orientation target (note, op=promote)."),
|
|
183
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
184
|
+
},
|
|
185
|
+
outputSchema: effectToZodSchema(ReposManageResult),
|
|
186
|
+
annotations: {
|
|
187
|
+
destructiveHint: true,
|
|
188
|
+
idempotentHint: false
|
|
189
|
+
}
|
|
190
|
+
}, async (args) => {
|
|
191
|
+
const data = await ctx.runtime.runPromise(reposManage(args, ctx.cwd));
|
|
192
|
+
const text = Schema.decodeSync(ReposManageAsMarkdown)(data);
|
|
193
|
+
return structuredResult(text, data);
|
|
194
|
+
});
|
|
144
195
|
server.registerTool("biome_check", {
|
|
145
196
|
description: "Run Biome over a path and get structured diagnostics back. mode=check (default; lint + format + organize-imports) or mode=lint. Set write=true to apply safe fixes (--write), unsafe=true for unsafe fixes (--write --unsafe). Severities match the project's Biome config (what `biome check` reports); set strict=true to surface project warnings as errors, each marked with its originalSeverity. Prefer this over shelling out to biome; the LSP already covers files you've edited. Returns markdown in content[] and a typed object in structuredContent. NOTE: with write/unsafe this tool MUTATES files (git-reversible).",
|
|
146
197
|
inputSchema: {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/tools/md-inline.ts
|
|
2
|
+
/**
|
|
3
|
+
* Render a repo-derived value as an inert markdown code span. Backslash
|
|
4
|
+
* escaping does NOT work inside code spans (CommonMark treats backslashes
|
|
5
|
+
* literally there), so this uses the delimiter-run rule instead: wrap the
|
|
6
|
+
* value in a backtick run strictly longer than any backtick run it contains,
|
|
7
|
+
* padding with spaces when the value starts or ends with a backtick. This
|
|
8
|
+
* keeps vendored-repo content (names, refs, purposes, orientation values,
|
|
9
|
+
* commit messages, note text) — the definition of untrusted input — from
|
|
10
|
+
* injecting markdown structure into the transcript an agent reads.
|
|
11
|
+
*
|
|
12
|
+
* Shared by every tool that interpolates untrusted repo content into a
|
|
13
|
+
* markdown transcript so the escaping rule has exactly one implementation.
|
|
14
|
+
*/
|
|
15
|
+
const mdInline = (value) => {
|
|
16
|
+
const runs = value.match(/`+/g) ?? [];
|
|
17
|
+
const delimiter = "`".repeat(Math.max(1, ...runs.map((run) => run.length + 1)));
|
|
18
|
+
return `${delimiter}${value.startsWith("`") || value.endsWith("`") ? ` ${value} ` : value}${delimiter}`;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { mdInline };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mdInline } from "./md-inline.js";
|
|
2
|
+
import { Repos } from "@savvy-web/silk-effects";
|
|
3
|
+
import { Effect, ParseResult, Schema } from "effect";
|
|
4
|
+
import { WorkspaceRoot } from "workspaces-effect";
|
|
5
|
+
|
|
6
|
+
//#region src/tools/repos-inspect.ts
|
|
7
|
+
/**
|
|
8
|
+
* The `repos_inspect` MCP tool: a discriminated-union result keyed by `mode`
|
|
9
|
+
* (status | config), each variant embedding the corresponding resolved-output
|
|
10
|
+
* schema from silk-effects' Repos namespace, plus a one-way markdown
|
|
11
|
+
* transform. Read-only.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
/** Status-report variant. */
|
|
16
|
+
const ReposStatusResult = Schema.Struct({
|
|
17
|
+
mode: Schema.Literal("status"),
|
|
18
|
+
result: Repos.ReposStatusReport
|
|
19
|
+
}).annotations({ identifier: "ReposStatusResult" });
|
|
20
|
+
/** Manifest-config variant. */
|
|
21
|
+
const ReposConfigResult = Schema.Struct({
|
|
22
|
+
mode: Schema.Literal("config"),
|
|
23
|
+
result: Repos.ReposManifestFile
|
|
24
|
+
}).annotations({ identifier: "ReposConfigResult" });
|
|
25
|
+
/** The `repos_inspect` tool result — a discriminated union keyed by `mode`. */
|
|
26
|
+
const ReposInspectResult = Schema.Union(ReposStatusResult, ReposConfigResult).annotations({
|
|
27
|
+
identifier: "ReposInspectResult",
|
|
28
|
+
title: "repos_inspect result",
|
|
29
|
+
description: "Drift report (status) or the parsed manifest with purposes, orientation, and notes (config)."
|
|
30
|
+
});
|
|
31
|
+
/** Render the structured result as a markdown transcript. */
|
|
32
|
+
const renderMarkdown = (data) => {
|
|
33
|
+
switch (data.mode) {
|
|
34
|
+
case "status": {
|
|
35
|
+
const r = data.result;
|
|
36
|
+
const lines = [
|
|
37
|
+
`# repos status`,
|
|
38
|
+
``,
|
|
39
|
+
`clean: ${r.clean}`,
|
|
40
|
+
``,
|
|
41
|
+
`## Repos`
|
|
42
|
+
];
|
|
43
|
+
for (const entry of r.repos) {
|
|
44
|
+
lines.push(`### ${mdInline(entry.name)}`, `- ref: ${mdInline(entry.ref)}`, `- purpose: ${mdInline(entry.purpose)}`, `- present: ${entry.present}`, `- commit: ${entry.commit ? mdInline(entry.commit) : "(none)"}`, `- dirty: ${entry.dirty}`);
|
|
45
|
+
if (entry.staleNoteIds.length > 0) lines.push(`- staleNoteIds: ${entry.staleNoteIds.map(mdInline).join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
if (r.repos.length === 0) lines.push("(none)");
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
case "config": {
|
|
51
|
+
const r = data.result;
|
|
52
|
+
const lines = [
|
|
53
|
+
`# repos config`,
|
|
54
|
+
``,
|
|
55
|
+
`## Repos`
|
|
56
|
+
];
|
|
57
|
+
for (const [name, entry] of Object.entries(r.repos)) {
|
|
58
|
+
lines.push(`### ${mdInline(name)}`, `- url: ${mdInline(entry.url)}`, `- ref: ${mdInline(entry.ref)}`, `- purpose: ${mdInline(entry.purpose)}`);
|
|
59
|
+
if (entry.sparse && entry.sparse.length > 0) lines.push(`- sparse: ${entry.sparse.map(mdInline).join(", ")}`);
|
|
60
|
+
if (entry.orientation) {
|
|
61
|
+
const o = entry.orientation;
|
|
62
|
+
if (o.layout) lines.push(`- layout: ${mdInline(o.layout)}`);
|
|
63
|
+
if (o.startHere) lines.push(`- startHere: ${mdInline(o.startHere)}`);
|
|
64
|
+
if (o.keyPaths) {
|
|
65
|
+
lines.push(`- keyPaths:`);
|
|
66
|
+
for (const [key, value] of Object.entries(o.keyPaths)) lines.push(` - ${mdInline(key)}: ${mdInline(value)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (entry.notes && entry.notes.length > 0) {
|
|
70
|
+
lines.push(`- notes:`);
|
|
71
|
+
for (const note of entry.notes) lines.push(` - ${mdInline(note.id)} (${mdInline(note.date)}, ref ${mdInline(note.ref)}): ${mdInline(note.note)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(r.repos).length === 0) lines.push("(none)");
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
80
|
+
const ReposInspectAsMarkdown = Schema.transformOrFail(ReposInspectResult, Schema.String, {
|
|
81
|
+
strict: true,
|
|
82
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
83
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "ReposInspectAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* Effect handler: resolve the workspace root, then dispatch to the matching
|
|
87
|
+
* Repos service keyed by `mode`. Mirrors `changesetInspect`.
|
|
88
|
+
*/
|
|
89
|
+
const reposInspect = (args, fallbackCwd) => Effect.gen(function* () {
|
|
90
|
+
const root = yield* (yield* WorkspaceRoot).find(args.cwd ?? fallbackCwd);
|
|
91
|
+
switch (args.mode) {
|
|
92
|
+
case "status": return {
|
|
93
|
+
mode: "status",
|
|
94
|
+
result: yield* (yield* Repos.ReposManager).status(root)
|
|
95
|
+
};
|
|
96
|
+
case "config": return {
|
|
97
|
+
mode: "config",
|
|
98
|
+
result: yield* (yield* Repos.ReposConfigStore).read(root)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
export { ReposConfigResult, ReposInspectAsMarkdown, ReposInspectResult, ReposStatusResult, reposInspect };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { mdInline } from "./md-inline.js";
|
|
2
|
+
import { Repos } from "@savvy-web/silk-effects";
|
|
3
|
+
import { Effect, ParseResult, Schema } from "effect";
|
|
4
|
+
import { WorkspaceRoot } from "workspaces-effect";
|
|
5
|
+
|
|
6
|
+
//#region src/tools/repos-manage.ts
|
|
7
|
+
/**
|
|
8
|
+
* The `repos_manage` MCP tool: one action-discriminated mutating tool
|
|
9
|
+
* covering `sync`, `pin`, `add`, and `note` against the vendored `.repos/`
|
|
10
|
+
* submodules. The wire schema is flat (no `oneOf`); the handler maps it into
|
|
11
|
+
* an internal `Schema.TaggedStruct` request union that names the missing
|
|
12
|
+
* field per action on decode failure. Mutating — no `readOnlyHint`.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/** `sync` has no extra fields. */
|
|
17
|
+
const SyncRequest = Schema.TaggedStruct("sync", {});
|
|
18
|
+
/** `pin` requires both `name` and `ref`. */
|
|
19
|
+
const PinRequest = Schema.TaggedStruct("pin", {
|
|
20
|
+
name: Schema.String,
|
|
21
|
+
ref: Schema.String
|
|
22
|
+
});
|
|
23
|
+
/** `add` requires `url`/`ref`/`purpose`; `name` and `sparse` are optional. */
|
|
24
|
+
const AddRequest = Schema.TaggedStruct("add", {
|
|
25
|
+
url: Schema.String,
|
|
26
|
+
ref: Schema.String,
|
|
27
|
+
purpose: Schema.String,
|
|
28
|
+
name: Schema.optional(Schema.String),
|
|
29
|
+
sparse: Schema.optional(Schema.Array(Schema.String))
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* `note` requires `name` and `op`; the fields required beyond that depend on
|
|
33
|
+
* `op` — enforced by the trailing filter so the decode error names exactly
|
|
34
|
+
* what's missing for the chosen op.
|
|
35
|
+
*/
|
|
36
|
+
const NoteRequest = Schema.TaggedStruct("note", {
|
|
37
|
+
name: Schema.String,
|
|
38
|
+
op: Schema.Literal("add", "remove", "promote"),
|
|
39
|
+
note: Schema.optional(Schema.String),
|
|
40
|
+
id: Schema.optional(Schema.String),
|
|
41
|
+
into: Schema.optional(Schema.Literal("layout", "startHere"))
|
|
42
|
+
}).pipe(Schema.filter((request) => {
|
|
43
|
+
if (request.op === "add" && request.note === void 0) return "note op \"add\" requires `note`";
|
|
44
|
+
if (request.op === "remove" && request.id === void 0) return "note op \"remove\" requires `id`";
|
|
45
|
+
if (request.op === "promote" && (request.id === void 0 || request.into === void 0)) return "note op \"promote\" requires both `id` and `into`";
|
|
46
|
+
return true;
|
|
47
|
+
}));
|
|
48
|
+
/** Internal tagged-union request the flat wire args decode into. */
|
|
49
|
+
const ReposManageRequest = Schema.Union(SyncRequest, PinRequest, AddRequest, NoteRequest);
|
|
50
|
+
/** `sync` result variant. */
|
|
51
|
+
const ReposManageSyncResult = Schema.Struct({
|
|
52
|
+
action: Schema.Literal("sync"),
|
|
53
|
+
result: Repos.ReposSyncReport
|
|
54
|
+
}).annotations({ identifier: "ReposManageSyncResult" });
|
|
55
|
+
/** `pin` result variant. */
|
|
56
|
+
const ReposManagePinResult = Schema.Struct({
|
|
57
|
+
action: Schema.Literal("pin"),
|
|
58
|
+
result: Repos.ReposPinResult
|
|
59
|
+
}).annotations({ identifier: "ReposManagePinResult" });
|
|
60
|
+
/** `add` result variant. */
|
|
61
|
+
const ReposManageAddResult = Schema.Struct({
|
|
62
|
+
action: Schema.Literal("add"),
|
|
63
|
+
result: Repos.ReposAddResult
|
|
64
|
+
}).annotations({ identifier: "ReposManageAddResult" });
|
|
65
|
+
/** `note` result variant. */
|
|
66
|
+
const ReposManageNoteResult = Schema.Struct({
|
|
67
|
+
action: Schema.Literal("note"),
|
|
68
|
+
result: Repos.ReposNoteResult
|
|
69
|
+
}).annotations({ identifier: "ReposManageNoteResult" });
|
|
70
|
+
/** The `repos_manage` tool result — a discriminated union keyed by `action`. */
|
|
71
|
+
const ReposManageResult = Schema.Union(ReposManageSyncResult, ReposManagePinResult, ReposManageAddResult, ReposManageNoteResult).annotations({
|
|
72
|
+
identifier: "ReposManageResult",
|
|
73
|
+
title: "repos_manage result",
|
|
74
|
+
description: "Result of a mutating repos action: sync, pin, add, or note."
|
|
75
|
+
});
|
|
76
|
+
/** Render the structured result as a markdown transcript. */
|
|
77
|
+
const renderMarkdown = (data) => {
|
|
78
|
+
switch (data.action) {
|
|
79
|
+
case "sync": {
|
|
80
|
+
const r = data.result;
|
|
81
|
+
const section = (title, names) => [`## ${title}`, ...names.length > 0 ? names.map((name) => `- ${mdInline(name)}`) : ["(none)"]];
|
|
82
|
+
return [
|
|
83
|
+
`# repos sync`,
|
|
84
|
+
``,
|
|
85
|
+
...section("Initialized", r.initialized),
|
|
86
|
+
``,
|
|
87
|
+
...section("Sparse applied", r.sparseApplied),
|
|
88
|
+
``,
|
|
89
|
+
...section("Up to date", r.upToDate),
|
|
90
|
+
``,
|
|
91
|
+
...section("Cleared locks", r.clearedLocks)
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
case "pin": {
|
|
95
|
+
const r = data.result;
|
|
96
|
+
const lines = [
|
|
97
|
+
`# repos pin — ${mdInline(r.name)}`,
|
|
98
|
+
``,
|
|
99
|
+
`ref: ${mdInline(r.ref)}`,
|
|
100
|
+
`oldCommit: ${r.oldCommit ? mdInline(r.oldCommit) : "(none)"}`,
|
|
101
|
+
`newCommit: ${mdInline(r.newCommit)}`,
|
|
102
|
+
``,
|
|
103
|
+
`## Commit message`,
|
|
104
|
+
``,
|
|
105
|
+
mdInline(r.commitMessage),
|
|
106
|
+
``,
|
|
107
|
+
`## Stale notes`,
|
|
108
|
+
``
|
|
109
|
+
];
|
|
110
|
+
if (r.staleNoteIds.length > 0) lines.push(`These notes reference a ref other than the new pin and should be reviewed before committing:`, ...r.staleNoteIds.map((id) => `- ${mdInline(id)}`));
|
|
111
|
+
else lines.push("(none)");
|
|
112
|
+
lines.push(``, `REVIEW AND COMMIT: stage the updated manifest and submodule gitlink, then commit using the message above.`);
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
case "add": {
|
|
116
|
+
const r = data.result;
|
|
117
|
+
return [
|
|
118
|
+
`# repos add — ${mdInline(r.name)}`,
|
|
119
|
+
``,
|
|
120
|
+
`ref: ${mdInline(r.ref)}`,
|
|
121
|
+
`path: ${mdInline(r.path)}`
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
case "note": {
|
|
125
|
+
const r = data.result;
|
|
126
|
+
return [
|
|
127
|
+
`# repos note — ${mdInline(r.name)}`,
|
|
128
|
+
``,
|
|
129
|
+
`op: ${mdInline(r.op)}`,
|
|
130
|
+
`id: ${mdInline(r.id)}`,
|
|
131
|
+
`noteCount: ${r.noteCount}`
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
137
|
+
const ReposManageAsMarkdown = Schema.transformOrFail(ReposManageResult, Schema.String, {
|
|
138
|
+
strict: true,
|
|
139
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
140
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "ReposManageAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
141
|
+
});
|
|
142
|
+
/**
|
|
143
|
+
* Effect handler: resolve the workspace root, decode the flat wire args into
|
|
144
|
+
* the internal per-action request (naming the missing field on failure), then
|
|
145
|
+
* dispatch to the matching `ReposManager` method.
|
|
146
|
+
*/
|
|
147
|
+
const reposManage = (args, fallbackCwd) => Effect.gen(function* () {
|
|
148
|
+
const root = yield* (yield* WorkspaceRoot).find(args.cwd ?? fallbackCwd);
|
|
149
|
+
const manager = yield* Repos.ReposManager;
|
|
150
|
+
const { action, cwd: _cwd, ...rest } = args;
|
|
151
|
+
const request = yield* Schema.decodeUnknown(ReposManageRequest)({
|
|
152
|
+
_tag: action,
|
|
153
|
+
...rest
|
|
154
|
+
});
|
|
155
|
+
switch (request._tag) {
|
|
156
|
+
case "sync": return {
|
|
157
|
+
action: "sync",
|
|
158
|
+
result: yield* manager.sync(root)
|
|
159
|
+
};
|
|
160
|
+
case "pin": return {
|
|
161
|
+
action: "pin",
|
|
162
|
+
result: yield* manager.pin(root, request.name, request.ref)
|
|
163
|
+
};
|
|
164
|
+
case "add": return {
|
|
165
|
+
action: "add",
|
|
166
|
+
result: yield* manager.add(root, {
|
|
167
|
+
url: request.url,
|
|
168
|
+
ref: request.ref,
|
|
169
|
+
purpose: request.purpose,
|
|
170
|
+
...request.name !== void 0 ? { name: request.name } : {},
|
|
171
|
+
...request.sparse !== void 0 ? { sparse: request.sparse } : {}
|
|
172
|
+
})
|
|
173
|
+
};
|
|
174
|
+
case "note": {
|
|
175
|
+
const noteOp = request.op === "add" ? {
|
|
176
|
+
op: "add",
|
|
177
|
+
note: request.note
|
|
178
|
+
} : request.op === "remove" ? {
|
|
179
|
+
op: "remove",
|
|
180
|
+
id: request.id
|
|
181
|
+
} : {
|
|
182
|
+
op: "promote",
|
|
183
|
+
id: request.id,
|
|
184
|
+
into: request.into
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
action: "note",
|
|
188
|
+
result: yield* manager.note(root, request.name, noteOp)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
//#endregion
|
|
195
|
+
export { ReposManageAddResult, ReposManageAsMarkdown, ReposManageNoteResult, ReposManagePinResult, ReposManageResult, ReposManageSyncResult, reposManage };
|