@savvy-web/mcp 0.4.2 → 1.0.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 +1 -0
- package/index.d.ts +7 -6
- package/package.json +6 -5
- package/public/content/packages/cli/command-tree.md +2 -3
- package/runtime.js +7 -5
- package/server.js +47 -0
- package/tools/biome-check.js +188 -0
- package/tools/changeset-inspect.js +126 -0
- package/tools/changeset-validate.js +84 -0
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ npx @modelcontextprotocol/inspector savvy-mcp .
|
|
|
44
44
|
- `workspace_info` — returns a flat, structured projection of the workspace analysis: linked and fixed package groups as name arrays plus resolved registry targets. Backed by the same `silk-effects` analyzer the `savvy` CLI uses.
|
|
45
45
|
- `silk_docs_search` — ranks documents in the corpus against a plain keyword or phrase query and returns hits with a normalized confidence score plus a high/medium/low label. It never returns empty: when nothing matches, it falls back to the priority-ordered top results.
|
|
46
46
|
- `turbo_inspect` — read-only Turborepo inspection over `turbo --dry`: diagnose a task's per-package cache hits, derive the task graph or list the packages affected by recent changes. It never runs a task. Backed by the same `silk-effects` `Turbo` inspector an agent would otherwise drive by hand.
|
|
47
|
+
- `changeset_inspect` — read-only changeset analysis for release work: `mode=branch` diffs the current branch against its base and attributes every changed file to its owning package, `mode=config` surfaces the resolved `.changeset/config.json` with its release surfaces, version files and ignore list. It never writes a changeset. Backed by the same `silk-effects` changeset services the `savvy` CLI uses.
|
|
47
48
|
|
|
48
49
|
## Resources
|
|
49
50
|
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
|
|
1
|
+
import { Changesets, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
|
|
2
2
|
import { Layer, ManagedRuntime, Schema } from "effect";
|
|
3
3
|
import { WorkspaceDiscoveryError, WorkspaceRoot } from "workspaces-effect";
|
|
4
4
|
//#region src/resources/schema.d.ts
|
|
@@ -84,7 +84,7 @@ declare class DocIndex {
|
|
|
84
84
|
//#region src/context.d.ts
|
|
85
85
|
/** The long-lived runtime, the project working directory, and the resource layer. */
|
|
86
86
|
interface McpContext {
|
|
87
|
-
readonly runtime: ManagedRuntime.ManagedRuntime<SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector, WorkspaceDiscoveryError>;
|
|
87
|
+
readonly runtime: ManagedRuntime.ManagedRuntime<SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector, WorkspaceDiscoveryError>;
|
|
88
88
|
readonly cwd: string;
|
|
89
89
|
readonly docIndex: DocIndex;
|
|
90
90
|
readonly manifest: Manifest;
|
|
@@ -93,16 +93,17 @@ interface McpContext {
|
|
|
93
93
|
//#endregion
|
|
94
94
|
//#region src/runtime.d.ts
|
|
95
95
|
/**
|
|
96
|
-
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
97
|
-
* `Turbo.TurboInspector
|
|
98
|
-
*
|
|
96
|
+
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
97
|
+
* `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`, and
|
|
98
|
+
* `Changesets.ConfigInspector`; requires `CommandExecutor` + `FileSystem` +
|
|
99
|
+
* `Path` from the host's platform layer (`NodeContext.layer` in bin.ts).
|
|
99
100
|
*
|
|
100
101
|
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
101
102
|
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
102
103
|
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
103
104
|
* host platform layer.
|
|
104
105
|
*/
|
|
105
|
-
declare const SilkRuntimeLive: Layer.Layer<import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | import("workspaces-effect").WorkspaceRoot | Turbo.TurboInspector, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("@effect/platform/CommandExecutor").CommandExecutor>;
|
|
106
|
+
declare const SilkRuntimeLive: Layer.Layer<import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | import("workspaces-effect").WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("@effect/platform/CommandExecutor").CommandExecutor>;
|
|
106
107
|
//#endregion
|
|
107
108
|
//#region src/server.d.ts
|
|
108
109
|
/** 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": "0.
|
|
3
|
+
"version": "1.0.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",
|
|
@@ -23,17 +23,18 @@
|
|
|
23
23
|
".": {
|
|
24
24
|
"types": "./index.d.ts",
|
|
25
25
|
"import": "./index.js"
|
|
26
|
-
}
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
27
28
|
},
|
|
28
29
|
"bin": {
|
|
29
30
|
"savvy-mcp": "bin/savvy-mcp.js"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@effect/platform": "^0.96.1",
|
|
33
|
-
"@effect/platform-node": "^0.
|
|
34
|
+
"@effect/platform-node": "^0.107.0",
|
|
34
35
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
|
-
"@savvy-web/silk-effects": "1.0
|
|
36
|
-
"effect": "^3.21.
|
|
36
|
+
"@savvy-web/silk-effects": "1.1.0",
|
|
37
|
+
"effect": "^3.21.3",
|
|
37
38
|
"fuse.js": "^7.4.0",
|
|
38
39
|
"workspaces-effect": "^1.2.0",
|
|
39
40
|
"zod": "^4.4.3"
|
|
@@ -29,9 +29,8 @@ savvy init orchestrator → changeset · commit · lint init in one pass
|
|
|
29
29
|
savvy check orchestrator → runs all three checks
|
|
30
30
|
savvy commit init · check · hook(session-start · pre-commit-message ·
|
|
31
31
|
post-commit-verify · user-prompt-submit)
|
|
32
|
-
savvy changeset
|
|
33
|
-
|
|
34
|
-
config(show · validate) · deps(detect · regen)
|
|
32
|
+
savvy changeset lint · check · transform · validate-file · version ·
|
|
33
|
+
config(validate) · deps(detect · regen)
|
|
35
34
|
savvy lint init · check · fmt(package-json · pnpm-workspace · yaml)
|
|
36
35
|
```
|
|
37
36
|
|
package/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangesetConfigReaderLive, SilkWorkspaceAnalyzerLive, TagStrategyLive, ToolDiscoveryLive, Turbo, VersioningStrategyLive } from "@savvy-web/silk-effects";
|
|
1
|
+
import { ChangesetConfigReaderLive, Changesets, SilkWorkspaceAnalyzerLive, TagStrategyLive, ToolDiscoveryLive, Turbo, VersioningStrategyLive } from "@savvy-web/silk-effects";
|
|
2
2
|
import { Layer } from "effect";
|
|
3
3
|
import { WorkspaceRootLive, WorkspacesLive } from "workspaces-effect";
|
|
4
4
|
|
|
@@ -22,17 +22,19 @@ import { WorkspaceRootLive, WorkspacesLive } from "workspaces-effect";
|
|
|
22
22
|
* cross-feed sibling layers.
|
|
23
23
|
*/
|
|
24
24
|
const DepsLive = Layer.mergeAll(WorkspacesLive, ChangesetConfigReaderLive, TagStrategyLive, VersioningStrategyLive.pipe(Layer.provide(ChangesetConfigReaderLive)));
|
|
25
|
+
const InspectorAndAnalyzerLive = Changesets.BranchAnalyzerLive.pipe(Layer.provideMerge(Changesets.ConfigInspectorLive));
|
|
25
26
|
/**
|
|
26
|
-
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
27
|
-
* `Turbo.TurboInspector
|
|
28
|
-
*
|
|
27
|
+
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
28
|
+
* `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`, and
|
|
29
|
+
* `Changesets.ConfigInspector`; requires `CommandExecutor` + `FileSystem` +
|
|
30
|
+
* `Path` from the host's platform layer (`NodeContext.layer` in bin.ts).
|
|
29
31
|
*
|
|
30
32
|
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
31
33
|
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
32
34
|
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
33
35
|
* host platform layer.
|
|
34
36
|
*/
|
|
35
|
-
const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive))).pipe(Layer.provide(DepsLive));
|
|
37
|
+
const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive)), InspectorAndAnalyzerLive).pipe(Layer.provide(DepsLive));
|
|
36
38
|
|
|
37
39
|
//#endregion
|
|
38
40
|
export { SilkRuntimeLive };
|
package/server.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { registerAllResources } from "./resources/index.js";
|
|
2
2
|
import { stderrQueryLogger } from "./resources/query-log.js";
|
|
3
3
|
import { effectToZodSchema } from "./schema/effect-to-zod.js";
|
|
4
|
+
import { BiomeCheckAsMarkdown, BiomeCheckResult, runBiomeCheck } from "./tools/biome-check.js";
|
|
5
|
+
import { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect } from "./tools/changeset-inspect.js";
|
|
6
|
+
import { ChangesetValidateAsMarkdown, ChangesetValidateResult, changesetValidate } from "./tools/changeset-validate.js";
|
|
4
7
|
import { DocsSearchResult, DocsSearchResultAsMarkdown, runDocsSearch } from "./tools/docs-search.js";
|
|
5
8
|
import { TurboInspectAsMarkdown, TurboInspectResult, turboInspect } from "./tools/turbo-inspect.js";
|
|
6
9
|
import { WorkspaceInfoAsMarkdown, WorkspaceInfoResult, workspaceInfo } from "./tools/workspace-info.js";
|
|
@@ -78,6 +81,50 @@ function buildServer(ctx) {
|
|
|
78
81
|
const data = await ctx.runtime.runPromise(turboInspect(args, ctx.cwd));
|
|
79
82
|
return structuredResult(Schema.decodeSync(TurboInspectAsMarkdown)(data), data);
|
|
80
83
|
});
|
|
84
|
+
server.registerTool("changeset_inspect", {
|
|
85
|
+
description: "Read-only changeset analysis for the changeset-manager workflow. mode=branch diffs the current branch against its base and classifies every changed file by owning package (with packagesAffected and the unmapped paths to ask the user about). mode=config surfaces the resolved .changeset/config.json (release surfaces, versionFiles, ignore list). mode=classify maps arbitrary repo-relative paths to their owning package. Prefer this over shelling out to the savvy CLI.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
mode: z.enum([
|
|
88
|
+
"branch",
|
|
89
|
+
"config",
|
|
90
|
+
"classify"
|
|
91
|
+
]).describe("Which inspection to run."),
|
|
92
|
+
base: z.optional(z.string()).describe("Override the base branch (branch mode only)."),
|
|
93
|
+
paths: z.optional(z.array(z.string())).describe("Paths to classify (classify mode only)."),
|
|
94
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
95
|
+
},
|
|
96
|
+
outputSchema: effectToZodSchema(ChangesetInspectResult),
|
|
97
|
+
annotations: { readOnlyHint: true }
|
|
98
|
+
}, async (args) => {
|
|
99
|
+
const data = await ctx.runtime.runPromise(changesetInspect(args, ctx.cwd));
|
|
100
|
+
return structuredResult(Schema.decodeSync(ChangesetInspectAsMarkdown)(data), data);
|
|
101
|
+
});
|
|
102
|
+
server.registerTool("changeset_validate", {
|
|
103
|
+
description: "Read-only validation of changeset files against the section-aware rules. Pass dir (default .changeset). Returns typed diagnostics (file, rule, line, column, message) plus ok/errorCount in structuredContent. Prefer this over shelling out to savvy changeset lint.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
dir: z.optional(z.string()).describe("Changeset directory to validate (default .changeset)."),
|
|
106
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
107
|
+
},
|
|
108
|
+
outputSchema: effectToZodSchema(ChangesetValidateResult),
|
|
109
|
+
annotations: { readOnlyHint: true }
|
|
110
|
+
}, async (args) => {
|
|
111
|
+
const data = await ctx.runtime.runPromise(changesetValidate(args, ctx.cwd));
|
|
112
|
+
return structuredResult(Schema.decodeSync(ChangesetValidateAsMarkdown)(data), data);
|
|
113
|
+
});
|
|
114
|
+
server.registerTool("biome_check", {
|
|
115
|
+
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). 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).",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
paths: z.optional(z.array(z.string())).describe("Paths to check. Defaults to the whole workspace."),
|
|
118
|
+
mode: z.optional(z.enum(["check", "lint"])).describe("check = lint+format+imports (default); lint = lint only."),
|
|
119
|
+
write: z.optional(z.boolean()).describe("Apply safe fixes (--write)."),
|
|
120
|
+
unsafe: z.optional(z.boolean()).describe("Apply unsafe fixes (--write --unsafe); implies write."),
|
|
121
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
122
|
+
},
|
|
123
|
+
outputSchema: effectToZodSchema(BiomeCheckResult)
|
|
124
|
+
}, async (args) => {
|
|
125
|
+
const data = await runBiomeCheck(args, ctx.cwd);
|
|
126
|
+
return structuredResult(Schema.decodeSync(BiomeCheckAsMarkdown)(data), data);
|
|
127
|
+
});
|
|
81
128
|
registerAllResources(server, {
|
|
82
129
|
manifest: ctx.manifest,
|
|
83
130
|
contentRoot: ctx.contentRoot
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Lint } from "@savvy-web/silk-effects";
|
|
2
|
+
import { ParseResult, Schema } from "effect";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { relative, resolve, sep } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
//#region src/tools/biome-check.ts
|
|
8
|
+
/**
|
|
9
|
+
* The `biome_check` MCP tool: a thin proxy that runs Biome over a path with the
|
|
10
|
+
* gitlab reporter, parses it into a typed result, and can apply fixes. Unlike the
|
|
11
|
+
* other savvy-mcp tools this one MUTATES the working tree when `write`/`unsafe`
|
|
12
|
+
* is set — the first intentional exception to the read-only convention.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/** Normalized diagnostic severity. */
|
|
17
|
+
const BiomeSeverity = Schema.Literal("error", "warning", "info");
|
|
18
|
+
/** A single normalized Biome diagnostic. */
|
|
19
|
+
const BiomeDiagnostic = Schema.Struct({
|
|
20
|
+
file: Schema.String,
|
|
21
|
+
line: Schema.Number,
|
|
22
|
+
severity: BiomeSeverity,
|
|
23
|
+
rule: Schema.String,
|
|
24
|
+
message: Schema.String
|
|
25
|
+
}).annotations({ identifier: "BiomeDiagnostic" });
|
|
26
|
+
/** The `biome_check` tool result. */
|
|
27
|
+
const BiomeCheckResult = Schema.Struct({
|
|
28
|
+
summary: Schema.Struct({
|
|
29
|
+
errors: Schema.Number,
|
|
30
|
+
warnings: Schema.Number
|
|
31
|
+
}),
|
|
32
|
+
diagnostics: Schema.Array(BiomeDiagnostic),
|
|
33
|
+
wrote: Schema.Boolean,
|
|
34
|
+
guidance: Schema.String
|
|
35
|
+
}).annotations({
|
|
36
|
+
identifier: "BiomeCheckResult",
|
|
37
|
+
title: "biome_check result",
|
|
38
|
+
description: "Structured Biome diagnostics, with a flag for whether a --write pass ran."
|
|
39
|
+
});
|
|
40
|
+
/** Guardrail shown to the agent so it fixes code rather than silencing rules. */
|
|
41
|
+
const GUIDANCE = "Fix the actual code. Do NOT disable rules or add config overrides to silence these.";
|
|
42
|
+
/** Shape of a single diagnostic in Biome's `--reporter=gitlab` output. */
|
|
43
|
+
const GitlabDiagnostic = Schema.Struct({
|
|
44
|
+
description: Schema.String,
|
|
45
|
+
check_name: Schema.String,
|
|
46
|
+
severity: Schema.Literal("info", "minor", "major", "critical", "blocker"),
|
|
47
|
+
location: Schema.Struct({
|
|
48
|
+
path: Schema.String,
|
|
49
|
+
lines: Schema.Struct({ begin: Schema.Number })
|
|
50
|
+
})
|
|
51
|
+
});
|
|
52
|
+
const GitlabArray = Schema.Array(GitlabDiagnostic);
|
|
53
|
+
const decodeGitlab = Schema.decodeUnknownSync(GitlabArray);
|
|
54
|
+
/** Map a gitlab severity onto our three-level scale. */
|
|
55
|
+
const mapSeverity = (s) => s === "info" ? "info" : s === "minor" ? "warning" : "error";
|
|
56
|
+
/**
|
|
57
|
+
* Parse Biome `--reporter=gitlab` stdout into normalized diagnostics. Returns []
|
|
58
|
+
* for empty, non-JSON, or shape-mismatched input (never throws).
|
|
59
|
+
*/
|
|
60
|
+
const parseBiomeGitlab = (stdout) => {
|
|
61
|
+
const trimmed = stdout.trim();
|
|
62
|
+
if (!trimmed) return [];
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = JSON.parse(trimmed);
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
let decoded;
|
|
70
|
+
try {
|
|
71
|
+
decoded = decodeGitlab(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
return decoded.map((d) => ({
|
|
76
|
+
file: d.location.path,
|
|
77
|
+
line: d.location.lines.begin,
|
|
78
|
+
severity: mapSeverity(d.severity),
|
|
79
|
+
rule: d.check_name,
|
|
80
|
+
message: d.description
|
|
81
|
+
}));
|
|
82
|
+
};
|
|
83
|
+
/** Assemble the structured result from normalized diagnostics + the write flag. */
|
|
84
|
+
const buildBiomeResult = (params) => {
|
|
85
|
+
return {
|
|
86
|
+
summary: {
|
|
87
|
+
errors: params.diagnostics.filter((d) => d.severity === "error").length,
|
|
88
|
+
warnings: params.diagnostics.filter((d) => d.severity === "warning").length
|
|
89
|
+
},
|
|
90
|
+
diagnostics: [...params.diagnostics],
|
|
91
|
+
wrote: params.wrote,
|
|
92
|
+
guidance: GUIDANCE
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/** Render the structured result as markdown. */
|
|
96
|
+
const renderMarkdown = (data) => {
|
|
97
|
+
if (data.diagnostics.length === 0) return `# biome — clean\n\n✅ No remaining diagnostics.${data.wrote ? " A --write pass ran; check `git diff` for what changed." : ""}`;
|
|
98
|
+
const lines = [`# biome — ${data.summary.errors} error(s), ${data.summary.warnings} warning(s)`, ``];
|
|
99
|
+
if (data.wrote) lines.push(`A --write pass ran; the diagnostics below remain unfixed.`, ``);
|
|
100
|
+
for (const d of data.diagnostics) lines.push(`- \`${d.file}:${d.line}\` **${d.severity}** ${d.rule} — ${d.message}`);
|
|
101
|
+
lines.push(``, `---`, data.guidance);
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
};
|
|
104
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
105
|
+
const BiomeCheckAsMarkdown = Schema.transformOrFail(BiomeCheckResult, Schema.String, {
|
|
106
|
+
strict: true,
|
|
107
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
108
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "BiomeCheckAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* Run Biome and return structured diagnostics. When `write`/`unsafe` is set,
|
|
112
|
+
* runs a fix pass first, then a read-only gitlab pass to report what remains.
|
|
113
|
+
*
|
|
114
|
+
* @remarks Resolves the Biome binary via {@link Lint.Biome.findBiome} (global
|
|
115
|
+
* first, then the project's package manager). Throws if Biome is unavailable or
|
|
116
|
+
* exits with status > 1 (Biome itself failed, vs. status 1 = lint issues found).
|
|
117
|
+
*/
|
|
118
|
+
const runBiomeCheck = async (args, fallbackCwd) => {
|
|
119
|
+
const mode = args.mode ?? "check";
|
|
120
|
+
const canonicalize = (p) => {
|
|
121
|
+
try {
|
|
122
|
+
return realpathSync(p);
|
|
123
|
+
} catch {
|
|
124
|
+
return resolve(p);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const root = canonicalize(fallbackCwd);
|
|
128
|
+
const within = (abs) => abs === root || abs.startsWith(`${root}${sep}`);
|
|
129
|
+
const cwd = canonicalize(args.cwd ?? fallbackCwd);
|
|
130
|
+
if (!within(cwd)) throw new Error(`cwd escapes the workspace root: ${args.cwd}`);
|
|
131
|
+
const paths = (args.paths && args.paths.length > 0 ? args.paths : ["."]).map((p) => {
|
|
132
|
+
const lexical = resolve(cwd, p);
|
|
133
|
+
if (!within(canonicalize(lexical))) throw new Error(`path escapes the workspace root: ${p}`);
|
|
134
|
+
return relative(cwd, lexical) || ".";
|
|
135
|
+
});
|
|
136
|
+
const doWrite = Boolean(args.write || args.unsafe);
|
|
137
|
+
const biomeCmd = Lint.Biome.findBiome();
|
|
138
|
+
if (!biomeCmd) throw new Error("Biome not found. Install @biomejs/biome globally (recommended) or as a devDependency.");
|
|
139
|
+
const parts = biomeCmd.split(" ");
|
|
140
|
+
const bin = parts[0];
|
|
141
|
+
const prefix = parts.slice(1);
|
|
142
|
+
const maxBuffer = 64 * 1024 * 1024;
|
|
143
|
+
const timeout = 12e4;
|
|
144
|
+
const killSignal = "SIGKILL";
|
|
145
|
+
let wrote = false;
|
|
146
|
+
if (doWrite) {
|
|
147
|
+
const fix = spawnSync(bin, [
|
|
148
|
+
...prefix,
|
|
149
|
+
mode,
|
|
150
|
+
"--write",
|
|
151
|
+
...args.unsafe ? ["--unsafe"] : [],
|
|
152
|
+
"--no-errors-on-unmatched",
|
|
153
|
+
...paths
|
|
154
|
+
], {
|
|
155
|
+
cwd,
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
maxBuffer,
|
|
158
|
+
timeout,
|
|
159
|
+
killSignal
|
|
160
|
+
});
|
|
161
|
+
if (fix.error) throw fix.error;
|
|
162
|
+
if ((fix.status ?? 0) > 1) throw new Error(`Biome --write failed (exit ${fix.status}): ${(fix.stderr ?? "").trim() || "unknown error"}`);
|
|
163
|
+
wrote = true;
|
|
164
|
+
}
|
|
165
|
+
const read = spawnSync(bin, [
|
|
166
|
+
...prefix,
|
|
167
|
+
mode,
|
|
168
|
+
"--reporter=gitlab",
|
|
169
|
+
"--error-on-warnings",
|
|
170
|
+
"--no-errors-on-unmatched",
|
|
171
|
+
...paths
|
|
172
|
+
], {
|
|
173
|
+
cwd,
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
maxBuffer,
|
|
176
|
+
timeout,
|
|
177
|
+
killSignal
|
|
178
|
+
});
|
|
179
|
+
if (read.error) throw read.error;
|
|
180
|
+
if ((read.status ?? 0) > 1) throw new Error(`Biome failed (exit ${read.status}): ${(read.stderr ?? "").trim() || "unknown error"}`);
|
|
181
|
+
return buildBiomeResult({
|
|
182
|
+
diagnostics: parseBiomeGitlab(read.stdout ?? ""),
|
|
183
|
+
wrote
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
export { BiomeCheckAsMarkdown, BiomeCheckResult, runBiomeCheck };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Changesets } from "@savvy-web/silk-effects";
|
|
2
|
+
import { Effect, ParseResult, Schema } from "effect";
|
|
3
|
+
import { WorkspaceRoot } from "workspaces-effect";
|
|
4
|
+
|
|
5
|
+
//#region src/tools/changeset-inspect.ts
|
|
6
|
+
/**
|
|
7
|
+
* The `changeset_inspect` MCP tool: a discriminated-union result keyed by `mode`
|
|
8
|
+
* (branch | config), each variant embedding the corresponding resolved-output
|
|
9
|
+
* schema from silk-effects' Changesets namespace, plus a one-way markdown
|
|
10
|
+
* transform. Read-only.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/** Branch-analysis variant. */
|
|
15
|
+
const ChangesetBranchResult = Schema.Struct({
|
|
16
|
+
mode: Schema.Literal("branch"),
|
|
17
|
+
result: Changesets.BranchAnalysisSchema
|
|
18
|
+
}).annotations({ identifier: "ChangesetBranchResult" });
|
|
19
|
+
/** Config-inspection variant. */
|
|
20
|
+
const ChangesetConfigResult = Schema.Struct({
|
|
21
|
+
mode: Schema.Literal("config"),
|
|
22
|
+
result: Changesets.InspectedConfigSchema
|
|
23
|
+
}).annotations({ identifier: "ChangesetConfigResult" });
|
|
24
|
+
/** Classify variant — arbitrary paths to owning package. */
|
|
25
|
+
const ChangesetClassifyResult = Schema.Struct({
|
|
26
|
+
mode: Schema.Literal("classify"),
|
|
27
|
+
result: Schema.Array(Changesets.ClassificationSchema)
|
|
28
|
+
}).annotations({ identifier: "ChangesetClassifyResult" });
|
|
29
|
+
/** The `changeset_inspect` tool result — a discriminated union keyed by `mode`. */
|
|
30
|
+
const ChangesetInspectResult = Schema.Union(ChangesetBranchResult, ChangesetConfigResult, ChangesetClassifyResult).annotations({
|
|
31
|
+
identifier: "ChangesetInspectResult",
|
|
32
|
+
title: "changeset_inspect result",
|
|
33
|
+
description: "Read-only changeset analysis grouped by mode (branch | config | classify)."
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Render a repo/config-derived value as an inert markdown code span. Escapes
|
|
37
|
+
* backticks and backslashes so a crafted filename or package name cannot inject
|
|
38
|
+
* markdown structure into the transcript that an agent reads.
|
|
39
|
+
*/
|
|
40
|
+
const mdInline = (value) => `\`${value.replace(/[`\\]/g, "\\$&")}\``;
|
|
41
|
+
/** Render the structured result as a markdown transcript. */
|
|
42
|
+
const renderMarkdown = (data) => {
|
|
43
|
+
switch (data.mode) {
|
|
44
|
+
case "branch": {
|
|
45
|
+
const r = data.result;
|
|
46
|
+
const lines = [
|
|
47
|
+
`# changeset branch analysis — base ${mdInline(r.baseBranch)}`,
|
|
48
|
+
``,
|
|
49
|
+
`merge base: ${mdInline(r.mergeBaseSha)}`,
|
|
50
|
+
``,
|
|
51
|
+
`## Packages affected`,
|
|
52
|
+
r.packagesAffected.map((p) => `- ${mdInline(p)}`).join("\n") || "(none)",
|
|
53
|
+
``,
|
|
54
|
+
`## Files`
|
|
55
|
+
];
|
|
56
|
+
for (const f of r.files) {
|
|
57
|
+
const owner = f.package ? mdInline(f.package) : mdInline("<unmapped>");
|
|
58
|
+
lines.push(`- ${mdInline(f.status)} ${mdInline(f.path)} -> ${owner}`);
|
|
59
|
+
}
|
|
60
|
+
if (r.unmappedFiles.length > 0) {
|
|
61
|
+
lines.push(``, `## Unmapped (ask the user)`);
|
|
62
|
+
for (const p of r.unmappedFiles) lines.push(`- ${mdInline(p)}`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
case "config": {
|
|
67
|
+
const r = data.result;
|
|
68
|
+
const lines = [
|
|
69
|
+
`# changeset config — ${mdInline(r.configPath)}`,
|
|
70
|
+
``,
|
|
71
|
+
`base branch: ${mdInline(r.baseBranch)}`,
|
|
72
|
+
`access: ${r.access}`,
|
|
73
|
+
`changelog: ${r.changelog ? mdInline(r.changelog) : "(none)"}`,
|
|
74
|
+
`ignored: ${r.ignore.map(mdInline).join(", ") || "(none)"}`,
|
|
75
|
+
``,
|
|
76
|
+
`## Packages`
|
|
77
|
+
];
|
|
78
|
+
for (const p of r.packages) {
|
|
79
|
+
lines.push(`### ${mdInline(p.name)} (${mdInline(p.version)})`, `- dir: ${mdInline(p.workspaceDir)}`);
|
|
80
|
+
if (p.additionalScopes.length > 0) lines.push(`- additionalScopes: ${p.additionalScopes.map(mdInline).join(", ")}`);
|
|
81
|
+
if (p.versionFiles.length > 0) lines.push(`- versionFiles: ${p.versionFiles.map((v) => mdInline(v.glob)).join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
if (r.packages.length === 0) lines.push("(none resolved)");
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
case "classify": {
|
|
87
|
+
const lines = [`# changeset classify`, ``];
|
|
88
|
+
for (const c of data.result) {
|
|
89
|
+
const owner = c.package ? mdInline(c.package) : mdInline("<unmapped>");
|
|
90
|
+
lines.push(`- ${mdInline(c.path)} -> ${owner}`);
|
|
91
|
+
}
|
|
92
|
+
if (data.result.length === 0) lines.push("(no paths)");
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
98
|
+
const ChangesetInspectAsMarkdown = Schema.transformOrFail(ChangesetInspectResult, Schema.String, {
|
|
99
|
+
strict: true,
|
|
100
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
101
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "ChangesetInspectAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
102
|
+
});
|
|
103
|
+
/**
|
|
104
|
+
* Effect handler: resolve the workspace root, then dispatch to the matching
|
|
105
|
+
* Changesets service keyed by `mode`. Mirrors `turboInspect`.
|
|
106
|
+
*/
|
|
107
|
+
const changesetInspect = (args, fallbackCwd) => Effect.gen(function* () {
|
|
108
|
+
const root = yield* (yield* WorkspaceRoot).find(args.cwd ?? fallbackCwd);
|
|
109
|
+
switch (args.mode) {
|
|
110
|
+
case "branch": return {
|
|
111
|
+
mode: "branch",
|
|
112
|
+
result: yield* (yield* Changesets.BranchAnalyzer).analyzeBranch(root, args.base ? { baseBranch: args.base } : void 0)
|
|
113
|
+
};
|
|
114
|
+
case "config": return {
|
|
115
|
+
mode: "config",
|
|
116
|
+
result: yield* (yield* Changesets.ConfigInspector).inspect(root)
|
|
117
|
+
};
|
|
118
|
+
case "classify": return {
|
|
119
|
+
mode: "classify",
|
|
120
|
+
result: yield* (yield* Changesets.ConfigInspector).classify(root, args.paths ?? [])
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
export { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Changesets } from "@savvy-web/silk-effects";
|
|
2
|
+
import { Data, Effect, ParseResult, Schema } from "effect";
|
|
3
|
+
import { WorkspaceRoot } from "workspaces-effect";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
//#region src/tools/changeset-validate.ts
|
|
7
|
+
/**
|
|
8
|
+
* The `changeset_validate` MCP tool: structured changeset-file validation over
|
|
9
|
+
* silk-effects' pure ChangesetLinter, returning typed diagnostics plus a
|
|
10
|
+
* pass/fail summary. Read-only.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/** A thrown failure from the pure {@link Changesets.ChangesetLinter.validate} (e.g. a missing directory). */
|
|
15
|
+
var ChangesetValidateError = class extends Data.TaggedError("ChangesetValidateError") {};
|
|
16
|
+
/** A single changeset lint diagnostic. Mirrors silk-effects' `LintMessage`. */
|
|
17
|
+
const ChangesetLintMessage = Schema.Struct({
|
|
18
|
+
file: Schema.String,
|
|
19
|
+
rule: Schema.String,
|
|
20
|
+
line: Schema.Number,
|
|
21
|
+
column: Schema.Number,
|
|
22
|
+
message: Schema.String
|
|
23
|
+
}).annotations({ identifier: "ChangesetLintMessage" });
|
|
24
|
+
/** The `changeset_validate` tool result. */
|
|
25
|
+
const ChangesetValidateResult = Schema.Struct({
|
|
26
|
+
dir: Schema.String,
|
|
27
|
+
ok: Schema.Boolean,
|
|
28
|
+
errorCount: Schema.Number,
|
|
29
|
+
messages: Schema.Array(ChangesetLintMessage)
|
|
30
|
+
}).annotations({
|
|
31
|
+
identifier: "ChangesetValidateResult",
|
|
32
|
+
title: "changeset_validate result",
|
|
33
|
+
description: "Read-only validation of changeset files against the section-aware rules."
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Render a repo/lint-derived value as an inert markdown code span. Escapes
|
|
37
|
+
* backticks and backslashes so a crafted filename or message cannot inject
|
|
38
|
+
* markdown structure into the transcript that an agent reads.
|
|
39
|
+
*/
|
|
40
|
+
const mdInline = (value) => `\`${value.replace(/[`\\]/g, "\\$&")}\``;
|
|
41
|
+
/** Render the structured result as a markdown transcript. */
|
|
42
|
+
const renderMarkdown = (data) => {
|
|
43
|
+
if (data.ok) return `# changeset validate — ${mdInline(data.dir)}\n\nNo changeset issues found.`;
|
|
44
|
+
const lines = [
|
|
45
|
+
`# changeset validate — ${mdInline(data.dir)}`,
|
|
46
|
+
``,
|
|
47
|
+
`${data.errorCount} issue(s):`,
|
|
48
|
+
``
|
|
49
|
+
];
|
|
50
|
+
for (const m of data.messages) lines.push(`- ${mdInline(`${m.file}:${m.line}:${m.column}`)} ${mdInline(m.rule)} — ${m.message}`);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
};
|
|
53
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
54
|
+
const ChangesetValidateAsMarkdown = Schema.transformOrFail(ChangesetValidateResult, Schema.String, {
|
|
55
|
+
strict: true,
|
|
56
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
57
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "ChangesetValidateAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* Effect handler: resolve the workspace root, then validate the changeset
|
|
61
|
+
* directory via the pure {@link Changesets.ChangesetLinter.validate}. The
|
|
62
|
+
* synchronous call is wrapped in {@link Effect.try} so a thrown error (e.g. a
|
|
63
|
+
* missing directory) surfaces as a typed {@link ChangesetValidateError} rather
|
|
64
|
+
* than escaping as a defect.
|
|
65
|
+
*/
|
|
66
|
+
const changesetValidate = (args, fallbackCwd) => Effect.gen(function* () {
|
|
67
|
+
const dir = resolve(yield* (yield* WorkspaceRoot).find(args.cwd ?? fallbackCwd), args.dir ?? ".changeset");
|
|
68
|
+
const messages = yield* Effect.try({
|
|
69
|
+
try: () => Changesets.ChangesetLinter.validate(dir),
|
|
70
|
+
catch: (cause) => new ChangesetValidateError({
|
|
71
|
+
dir,
|
|
72
|
+
cause
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
dir,
|
|
77
|
+
ok: messages.length === 0,
|
|
78
|
+
errorCount: messages.length,
|
|
79
|
+
messages
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
export { ChangesetValidateAsMarkdown, ChangesetValidateResult, changesetValidate };
|