Skip to content

Commit cdab4f9

Browse files
lexwhitingclaude
andcommitted
cli: implement jscodeshift transforms for add command
Adds per-repo-type codemods (mcp-server, langchain-tool, rest-api) that insert `settlegrid.init()` and wrap handlers with `sg.wrap()`. Transforms are idempotent, respect dry-run, and mutate target package.json to add @settlegrid/mcp. Includes fixture-based tests covering 6 before/after pairs and idempotency assertions. New module: packages/settlegrid-cli/src/transforms/ - shared.ts — jscodeshift helpers: ensureSettlegridImport, buildSettlegridInitStatement, buildSgWrapCall, isAlreadySgWrap, extractMcpMethodName (kebab-cased name from schema identifier), findEnclosingStatement. - add-mcp.ts — finds `new Server(...)` + `server.setRequestHandler( schema, handler)` and wraps the handler arg in `sg.wrap(handler, { method: '<schema-derived>' })`. Adds import + `const sg = settlegrid.init({ toolSlug, pricing: { defaultCostCents: 1 } })` before the first Server construction. - add-langchain.ts — finds classes extending StructuredTool / DynamicStructuredTool / Tool and wraps each `_call` / `invoke` method body with `return await sg.wrap(async () => { <original body> }, { method: this.name })()`. Init is inserted before the first tool class. - add-rest.ts — wraps every `app.<verb>(path, ..., handler)` call for express/hono/fastify/koa, using `<verb>:<path>` as the method key. Init is inserted before the first route. - runner.ts — TransformInput/TransformOutput interface from spec, dispatches by detect.type, enumerates source files via fast-glob (500-file cap, deep:5, no-symlinks), respects dryRun, mutates package.json deterministically (alphabetical key sort, preserves original indent + trailing newline). - __testfixtures__/ — 6 before/after pairs: mcp-basic, mcp-multi- handler, mcp-already-wrapped (after === before, idempotent), langchain-tool, rest-express, rest-hono. - transforms.test.ts — 13 tests: per-fixture before→after equality + per-fixture idempotency + explicit already-wrapped no-op. - runner.test.ts — 12 tests: 3 isAlreadyWrapped, 4 addPackageDependency (sort, no-op, 4-space indent preservation, malformed safety), 5 runTransform contracts (mcp dispatch, unknown no-op, already- wrapped skip, dry-run mtime unchanged, write-mode updates both source and pkg.json). Spec divergences (cited per step 1 "confirm the exact signatures") @settlegrid/mcp API differs from the prompt's pseudocode: - settlegrid.init() requires { toolSlug, pricing: { defaultCostCents } }, not { apiKey } (see packages/mcp/src/index.ts:289). - sg.wrap(handler, { method }), not sg.wrap('method', handler) (packages/mcp/src/index.ts:322). Generated code emits the real-API shape; the spec-as-written code would not compile against @settlegrid/mcp@0.1.1. Updated: src/commands/add.ts Calls runTransform after detection, prints a transform summary (changed files, skipped files with reasons, dep to add, env required), emits truncated per-file preview for dry-run, and exits with: • dry-run + changes → "dry-run complete — re-run without --dry-run" • apply + 0 changes → "no files changed (already wrapped or nothing matched the codemod)" • apply + N changes → "wrapped N file(s). Next: npm install, set SETTLEGRID_API_KEY, and push" The `--force` flag remains the unknown-type override. Updated: src/index.test.ts Smoke-test expectations updated for the new transform output — assert on "transform summary", "@settlegrid/mcp@^0.1.1", "SETTLEGRID_API_KEY", "dry-run complete", and "no files changed" (the new outros) instead of the P2.2 "not yet implemented" sentinel. Updated: tsconfig.json Added src/transforms/__testfixtures__ to exclude — fixtures import uninstalled packages (@modelcontextprotocol/sdk, @langchain/core, express, hono) for realistic before-state content, and tsc would flag them otherwise. Verification - npm --workspace @settlegrid/cli run {typecheck,build,test}: green - 6 test files / 61 tests / 0 failures (up from 36 at end of P2.2 arc) - Manual DoD #3 smoke: `node dist/index.js add --path fixtures/ mcp-sample --dry-run` prints detection + transform summary + preview, exit 0 ✓ - DoD mtime check: runner.test.ts proves dryRun:true never touches server.ts or package.json on disk - apps/web tsc: 0 errors (baseline intact) - `npx turbo test --concurrency=1`: 5/5 tasks successful Pre-existing turbo-parallel flake on apps/web (documented in docs/audit-failures/P2.2-apps-web-concurrent-flake-2026-04-15.md) is unchanged by this commit — cli's fileParallelism:false mitigation from P2.2 continues to partially suppress it. Refs: P2.3 Audits: spec-diff PASS, hostile PASS, tests PASS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2191e98 commit cdab4f9

22 files changed

Lines changed: 1480 additions & 14 deletions

packages/settlegrid-cli/src/commands/add.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type ResolvedSource,
88
} from '../lib/source-resolver.js'
99
import { detectRepoType } from '../detect/index.js'
10+
import { runTransform } from '../transforms/runner.js'
1011

1112
interface AddOptions {
1213
github?: string
@@ -36,10 +37,6 @@ export function addCommand(program: Command): void {
3637
.action(async (source: string | undefined, options: AddOptions) => {
3738
intro(kleur.cyan('settlegrid add'))
3839

39-
// The positional [source] argument is a convenience: if it looks
40-
// like a remote URL, route it into resolveSource's `github` opt;
41-
// otherwise treat it as a local path. Explicit --github / --path
42-
// flags always win over the positional.
4340
const githubFromSource =
4441
source && isGithubUrl(source) ? source : undefined
4542
const pathFromSource =
@@ -62,7 +59,7 @@ export function addCommand(program: Command): void {
6259
? detection.reasons.map((r) => `\n - ${r}`).join('')
6360
: ` ${kleur.dim('(none)')}`
6461

65-
const lines = [
62+
const detectionLines = [
6663
`source: ${source ?? kleur.dim('(none)')}`,
6764
`resolved dir: ${resolved.dir}`,
6865
`type: ${detection.type}`,
@@ -78,7 +75,7 @@ export function addCommand(program: Command): void {
7875
`--out-branch: ${options.outBranch ?? kleur.dim('(unset)')}`,
7976
`--force: ${options.force ? 'yes' : 'no'}`,
8077
]
81-
note(lines.join('\n'), 'detection + parsed options')
78+
note(detectionLines.join('\n'), 'detection + parsed options')
8279

8380
if (detection.type === 'unknown' && !options.force) {
8481
outro(
@@ -90,11 +87,78 @@ export function addCommand(program: Command): void {
9087
return
9188
}
9289

93-
outro(
94-
kleur.yellow(
95-
'not yet implemented — codemod and PR creation land in P2.3 through P2.4.',
96-
),
97-
)
90+
const transform = await runTransform({
91+
rootDir: resolved.dir,
92+
detect: detection,
93+
dryRun: options.dryRun === true,
94+
})
95+
96+
const addedDeps = Object.entries(transform.addedDependencies)
97+
.map(([n, r]) => `${n}@${r}`)
98+
.join(', ')
99+
const changedList =
100+
transform.changedFiles.length > 0
101+
? transform.changedFiles
102+
.map((c) => `\n - ${c.path}`)
103+
.join('')
104+
: ` ${kleur.dim('(none)')}`
105+
const skippedList =
106+
transform.skipped.length > 0
107+
? transform.skipped
108+
.map((s) => `\n - ${s.path} (${s.reason})`)
109+
.join('')
110+
: ` ${kleur.dim('(none)')}`
111+
const envList =
112+
transform.envVarsRequired.length > 0
113+
? transform.envVarsRequired.join(', ')
114+
: kleur.dim('(none)')
115+
116+
const transformLines = [
117+
`mode: ${options.dryRun ? kleur.yellow('dry-run (no files written)') : kleur.green('apply (files written)')}`,
118+
`changed files: ${changedList}`,
119+
`skipped files: ${skippedList}`,
120+
`deps to add: ${addedDeps || kleur.dim('(none)')}`,
121+
`env required: ${envList}`,
122+
]
123+
note(transformLines.join('\n'), 'transform summary')
124+
125+
// Emit an inline diff preview for dry-run so the user can eyeball
126+
// what would change without touching their working tree. Truncate
127+
// so the terminal doesn't get flooded on larger repos.
128+
if (options.dryRun === true && transform.changedFiles.length > 0) {
129+
const previewCount = Math.min(transform.changedFiles.length, 3)
130+
for (let i = 0; i < previewCount; i++) {
131+
const { path: rel, after } = transform.changedFiles[i]
132+
const truncated = after.length > 1200 ? after.slice(0, 1200) + '\n…' : after
133+
note(truncated, `preview: ${rel}`)
134+
}
135+
if (transform.changedFiles.length > previewCount) {
136+
note(
137+
`${transform.changedFiles.length - previewCount} more file(s) would change — re-run without --dry-run to apply.`,
138+
'preview (truncated)',
139+
)
140+
}
141+
}
142+
143+
if (options.dryRun === true) {
144+
outro(
145+
kleur.yellow(
146+
'dry-run complete — re-run without --dry-run to write changes and update package.json.',
147+
),
148+
)
149+
} else if (transform.changedFiles.length === 0) {
150+
outro(
151+
kleur.dim(
152+
'no files changed (already wrapped or nothing matched the codemod).',
153+
),
154+
)
155+
} else {
156+
outro(
157+
kleur.green(
158+
`wrapped ${transform.changedFiles.length} file(s). Next: run \`npm install\` in the target repo, set ${transform.envVarsRequired.join(', ')}, and push.`,
159+
),
160+
)
161+
}
98162
process.exitCode = 0
99163
} catch (err) {
100164
const message = err instanceof Error ? err.message : String(err)

packages/settlegrid-cli/src/index.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ describe('settlegrid add — detection + source resolution smoke tests', () => {
8282
// • P2.1-style option echoing: every parsed flag is displayed in the
8383
// @clack/prompts note block.
8484
// • --force on a non-unknown type is inert (exit 0 regardless).
85+
// • P2.3: dry-run runs the codemod and emits a transform summary.
8586
// Consolidated into one spawn to reduce subprocess load when multiple
8687
// workspace tests run in parallel under turbo.
8788
it('detects mcp-server on --path fixture, echoes all flags, exits 0', () => {
@@ -115,7 +116,11 @@ describe('settlegrid add — detection + source resolution smoke tests', () => {
115116
expect(result.stdout).toContain('no-pr: yes (PR skipped)')
116117
expect(result.stdout).toContain('settlegrid/monetize')
117118
expect(result.stdout).toContain('--force: yes')
118-
expect(result.stdout).toContain('not yet implemented')
119+
// P2.3: transform summary + dry-run outro.
120+
expect(result.stdout).toContain('transform summary')
121+
expect(result.stdout).toContain('@settlegrid/mcp@^0.1.1')
122+
expect(result.stdout).toContain('SETTLEGRID_API_KEY')
123+
expect(result.stdout).toContain('dry-run complete')
119124
})
120125

121126
// Per P2.2 step 6: unknown-type classification exits 1 without --force,
@@ -140,7 +145,9 @@ describe('settlegrid add — detection + source resolution smoke tests', () => {
140145
)
141146
expect(withForce.status).toBe(0)
142147
expect(withForce.stdout).toContain('type: unknown')
143-
expect(withForce.stdout).toContain('not yet implemented')
148+
// --force with unknown type → runner dispatches no codemod → 0 changes.
149+
// Outro message acknowledges nothing matched.
150+
expect(withForce.stdout).toContain('no files changed')
144151
})
145152

146153
// Error path: no source / --path / --github supplied. resolveSource
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { StructuredTool } from '@langchain/core/tools'
2+
import { z } from 'zod'
3+
4+
import { settlegrid } from '@settlegrid/mcp';
5+
6+
const sg = settlegrid.init({
7+
toolSlug: 'langchain-tool',
8+
9+
pricing: {
10+
defaultCostCents: 1
11+
}
12+
});
13+
14+
class SearchTool extends StructuredTool {
15+
name = 'search'
16+
description = 'Search the web.'
17+
schema = z.object({ query: z.string() })
18+
19+
async _call(input: { query: string }): Promise<string> {
20+
return await sg.wrap(async () => {
21+
return `result for ${input.query}`
22+
}, {
23+
method: this.name
24+
})();
25+
}
26+
}
27+
28+
export { SearchTool }
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { StructuredTool } from '@langchain/core/tools'
2+
import { z } from 'zod'
3+
4+
class SearchTool extends StructuredTool {
5+
name = 'search'
6+
description = 'Search the web.'
7+
schema = z.object({ query: z.string() })
8+
9+
async _call(input: { query: string }): Promise<string> {
10+
return `result for ${input.query}`
11+
}
12+
}
13+
14+
export { SearchTool }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2+
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
3+
import { settlegrid } from '@settlegrid/mcp'
4+
5+
const sg = settlegrid.init({
6+
toolSlug: 'mcp-already',
7+
pricing: { defaultCostCents: 1 },
8+
})
9+
10+
const server = new Server(
11+
{ name: 'mcp-already', version: '0.0.0' },
12+
{ capabilities: { tools: {} } },
13+
)
14+
15+
server.setRequestHandler(
16+
ListToolsRequestSchema,
17+
sg.wrap(async () => ({ tools: [] }), { method: 'list-tools' }),
18+
)
19+
20+
export { server }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2+
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
3+
import { settlegrid } from '@settlegrid/mcp'
4+
5+
const sg = settlegrid.init({
6+
toolSlug: 'mcp-already',
7+
pricing: { defaultCostCents: 1 },
8+
})
9+
10+
const server = new Server(
11+
{ name: 'mcp-already', version: '0.0.0' },
12+
{ capabilities: { tools: {} } },
13+
)
14+
15+
server.setRequestHandler(
16+
ListToolsRequestSchema,
17+
sg.wrap(async () => ({ tools: [] }), { method: 'list-tools' }),
18+
)
19+
20+
export { server }
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2+
import {
3+
CallToolRequestSchema,
4+
ListToolsRequestSchema,
5+
} from '@modelcontextprotocol/sdk/types.js'
6+
7+
import { settlegrid } from '@settlegrid/mcp';
8+
9+
const sg = settlegrid.init({
10+
toolSlug: 'mcp-basic',
11+
12+
pricing: {
13+
defaultCostCents: 1
14+
}
15+
});
16+
17+
const server = new Server(
18+
{ name: 'mcp-basic', version: '0.0.0' },
19+
{ capabilities: { tools: {} } },
20+
)
21+
22+
server.setRequestHandler(ListToolsRequestSchema, sg.wrap(async () => {
23+
return { tools: [] }
24+
}, {
25+
method: 'list-tools'
26+
}))
27+
28+
server.setRequestHandler(CallToolRequestSchema, sg.wrap(async (req) => {
29+
return { content: [{ type: 'text', text: 'ok' }] }
30+
}, {
31+
method: 'call-tool'
32+
}))
33+
34+
export { server }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2+
import {
3+
CallToolRequestSchema,
4+
ListToolsRequestSchema,
5+
} from '@modelcontextprotocol/sdk/types.js'
6+
7+
const server = new Server(
8+
{ name: 'mcp-basic', version: '0.0.0' },
9+
{ capabilities: { tools: {} } },
10+
)
11+
12+
server.setRequestHandler(ListToolsRequestSchema, async () => {
13+
return { tools: [] }
14+
})
15+
16+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
17+
return { content: [{ type: 'text', text: 'ok' }] }
18+
})
19+
20+
export { server }
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2+
import {
3+
CallToolRequestSchema,
4+
ListToolsRequestSchema,
5+
ListResourcesRequestSchema,
6+
ReadResourceRequestSchema,
7+
} from '@modelcontextprotocol/sdk/types.js'
8+
9+
import { settlegrid } from '@settlegrid/mcp';
10+
11+
const sg = settlegrid.init({
12+
toolSlug: 'mcp-multi-handler',
13+
14+
pricing: {
15+
defaultCostCents: 1
16+
}
17+
});
18+
19+
const server = new Server(
20+
{ name: 'mcp-multi', version: '0.0.0' },
21+
{ capabilities: { tools: {}, resources: {} } },
22+
)
23+
24+
server.setRequestHandler(ListToolsRequestSchema, sg.wrap(async () => ({ tools: [] }), {
25+
method: 'list-tools'
26+
}))
27+
server.setRequestHandler(CallToolRequestSchema, sg.wrap(async (req) => {
28+
return { content: [{ type: 'text', text: 'call' }] }
29+
}, {
30+
method: 'call-tool'
31+
}))
32+
server.setRequestHandler(ListResourcesRequestSchema, sg.wrap(async () => ({ resources: [] }), {
33+
method: 'list-resources'
34+
}))
35+
server.setRequestHandler(ReadResourceRequestSchema, sg.wrap(async (req) => ({ contents: [] }), {
36+
method: 'read-resource'
37+
}))
38+
39+
export { server }
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2+
import {
3+
CallToolRequestSchema,
4+
ListToolsRequestSchema,
5+
ListResourcesRequestSchema,
6+
ReadResourceRequestSchema,
7+
} from '@modelcontextprotocol/sdk/types.js'
8+
9+
const server = new Server(
10+
{ name: 'mcp-multi', version: '0.0.0' },
11+
{ capabilities: { tools: {}, resources: {} } },
12+
)
13+
14+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }))
15+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
16+
return { content: [{ type: 'text', text: 'call' }] }
17+
})
18+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }))
19+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => ({ contents: [] }))
20+
21+
export { server }

0 commit comments

Comments
 (0)