
Ce Polish
- 1 installs
- 23.9k repo stars
- Updated August 5, 2026
- everyinc/compounding-engineering-plugin
Starts the dev server, opens the feature in a browser, and iterates on UI improvements conversationally as the user points out what feels off.
About
Detects the framework and port, launches the dev server, opens the feature, then loops on hot-reloaded fixes as the user browses and describes improvements. A developer uses it to polish a feature interactively before shipping.
- Auto-detects framework via launch.json or project probes
- Conversational iterate loop with agent-browser screenshots and hot reload
Ce Polish by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,914 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/everyinc/compounding-engineering-plugin --skill ce-polishAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 23.9k |
| Last updated | August 5, 2026 |
| Repository | everyinc/compounding-engineering-plugin ↗ |
What it does
Starts the dev server, opens the feature in a browser, and iterates on UI improvements conversationally as the user points out what feels off.
Files
Polish
Start the dev server, open the feature in a browser, and iterate. You use the feature, say what feels off, and fixes happen.
Phase 0: Get on the right branch
1. If a PR number or branch name was provided, check it out (probe for existing worktrees first). 2. If blank, use the current branch. 3. Verify the current branch is not main/master.
Phase 1: Start the dev server
1.1 Check for .claude/launch.json
Run bash scripts/read-launch-json.sh. If it finds a configuration, use it — the user already told us how to start the project.
1.2 Auto-detect (when no launch.json)
Run bash scripts/detect-project-type.sh to identify the framework.
Route by type to the matching recipe reference for start command and port defaults:
| Type | Recipe |
|---|---|
rails | references/dev-server-rails.md |
next | references/dev-server-next.md |
vite | references/dev-server-vite.md |
nuxt | references/dev-server-nuxt.md |
astro | references/dev-server-astro.md |
remix | references/dev-server-remix.md |
sveltekit | references/dev-server-sveltekit.md |
procfile | references/dev-server-procfile.md |
unknown | Ask the user how to start the project |
For framework types that need a package manager, run bash scripts/resolve-package-manager.sh and substitute the result into the start command.
Resolve the port with bash scripts/resolve-port.sh --type <type>.
1.3 Start the server
Start the dev server in the background, log output to a temp file. Probe http://localhost:<port> for up to 30 seconds. If it doesn't come up, show the last 20 lines of the log and ask the user what to do.
1.4 Open in browser
Load references/ide-detection.md for the env-var probe table. Open the browser using the IDE's mechanism (Claude Code → open, Cursor → Cursor browser, VS Code → Simple Browser).
Tell the user:
Dev server running on http://localhost:<port>
Browse the feature and tell me what could be better.Phase 2: Iterate
This is the core loop. The user browses the feature and tells you what to improve. You fix it. Repeat until they're happy.
- When the user describes something to fix → make the change, the dev server hot-reloads
- When the user asks to check something → use
agent-browserto screenshot or inspect the page - When the user says they're done → commit the fixes and stop
No checklist. No envelope. Just conversation.
References
Reference files (loaded on demand):
references/launch-json-schema.md— launch.json schema + per-framework stubsreferences/ide-detection.md— host IDE detection and browser-handoffreferences/dev-server-detection.md— port resolution documentationreferences/dev-server-rails.md— Rails dev-server defaultsreferences/dev-server-next.md— Next.js dev-server defaultsreferences/dev-server-vite.md— Vite dev-server defaultsreferences/dev-server-nuxt.md— Nuxt dev-server defaultsreferences/dev-server-astro.md— Astro dev-server defaultsreferences/dev-server-remix.md— Remix dev-server defaultsreferences/dev-server-sveltekit.md— SvelteKit dev-server defaultsreferences/dev-server-procfile.md— Procfile-based dev-server defaults
Scripts (invoked via bash scripts/<name>):
scripts/read-launch-json.sh— launch.json readerscripts/detect-project-type.sh— project-type classifierscripts/resolve-package-manager.sh— lockfile-based package-manager resolverscripts/resolve-port.sh— port resolution cascade
Astro dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns astro and there is no .claude/launch.json to consult.
Signature
astro.config.js,astro.config.mjs, orastro.config.tsexistspackage.jsoncontains anastrodependency
Start command
Standard:
npm run devThe dev script in package.json typically wraps astro dev. Also valid (read package.json scripts to confirm which the project uses):
pnpm dev
yarn dev
bun run devPrefer the package manager indicated by the lockfile:
pnpm-lock.yaml->pnpm devyarn.lock->yarn devbun.lock/bun.lockb->bun run devpackage-lock.jsonor none ->npm run dev
Port
Default: 4321. Astro respects --port <port> and the server.port field in astro.config.*. Overrides follow the cascade in references/dev-server-detection.md.
Stub generation
{
"version": "0.2.0",
"configurations": [
{
"name": "Astro dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 4321
}
]
}Substitute the resolved package manager (npm / pnpm / yarn / bun) and port.
Common gotchas
- SSR vs SSG:
astro devruns identically for both output modes; the difference only matters at build time. Polish does not need to distinguish between them. - Astro config takes precedence over Vite config: Astro uses Vite under the hood but ships its own config file. The
astrotype takes precedence overvitewhen bothastro.config.*andvite.config.*exist. This is rare -- Astro projects do not usually have a separate Vite config file. - Dev toolbar (Astro 4+): Astro 4+ includes a dev toolbar that adds overlay UI in the browser. It does not affect port binding or URL routing -- polish can ignore it.
Dev-server port detection
Port resolution runs via scripts/resolve-port.sh. This document explains the probe order, framework defaults, and intentional divergences from the test-browser skill's inline cascade.
This cascade runs only when .claude/launch.json is absent or has no port field for the resolved configuration. When launch.json specifies a port, use it verbatim and skip this cascade entirely.
Priority order
1. Explicit `--port` flag -- if the caller passed --port <n>, use it directly. 2. Framework config files -- next.config.*, vite.config.*, nuxt.config.*, astro.config.* scanned with a conservative regex matching only numeric literal port values. Variable references (process.env.PORT, getPort()) are deliberately not matched. 3. Rails `config/puma.rb` -- grep for port <n>. 4. `Procfile.dev` -- web line scanned for -p <n> / --port <n> / -p=<n> / --port=<n>. 5. `docker-compose.yml` -- line-anchored grep for "<n>:<n>" port mapping patterns. Not full YAML parsing. 6. `package.json` -- dev/start scripts scanned for --port <n> / -p <n> / --port=<n> / -p=<n>. 7. `.env` files -- checked in override order: .env.local -> .env.development -> .env (first hit wins). Parses PORT=<n> with quote stripping and comment truncation. 8. Framework default lookup table -- see table below.
Framework defaults
| Framework | Default port |
|---|---|
| Rails | 3000 |
| Next.js | 3000 |
| Nuxt | 3000 |
| Remix (classic) | 3000 |
| Vite | 5173 |
| SvelteKit | 5173 |
| Astro | 4321 |
| Procfile | 3000 |
| Unknown | 3000 |
Sync-note block
resolve-port.sh and the test-browser skill's inline cascade overlap in purpose but diverge in three specific ways. These divergences are intentional -- do not "fix" one to match the other without understanding the rationale.
(a) Quote stripping on `.env` values. resolve-port.sh strips surrounding " and ' from PORT= values (so PORT="3001" resolves to 3001). The test-browser inline cascade does not strip quotes. The script version is more robust for real-world .env files where quoting is common.
(b) Comment stripping on `.env` values. resolve-port.sh truncates at # after trimming whitespace (so PORT=3001 # dev only resolves to 3001). The test-browser inline cascade does not strip comments. Same rationale: real .env files frequently contain inline comments.
(c) Removal of the `AGENTS.md`/`CLAUDE.md` grep. resolve-port.sh does not scan instruction files for port references. The test-browser inline cascade does. Instruction files carry natural language that may mention ports in contexts unrelated to the dev server (documentation, examples, troubleshooting), producing false positives that are hard to debug. Framework config files and .env are more reliable sources of truth.
Next.js dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns next and there is no .claude/launch.json to consult.
Signature
next.config.js,next.config.mjs,next.config.ts, ornext.config.cjsexistspackage.jsoncontains anextdependency
Start command
Standard:
npm run devAlso valid (read package.json scripts to confirm which the project uses):
pnpm dev
yarn dev
bun run devPrefer the package manager indicated by the lockfile:
pnpm-lock.yaml->pnpm devyarn.lock->yarn devbun.lock/bun.lockb->bun run devpackage-lock.jsonor none ->npm run dev
Port
Default: 3000. Next.js respects -p <port> / --port <port> and the PORT env var. Overrides follow the cascade in references/dev-server-detection.md.
Turbopack
Next.js 14+ supports --turbo (and 15+ makes it default). If the dev script in package.json includes --turbo, preserve it. Turbopack changes reload behavior but not port or URL conventions.
Stub generation
{
"version": "0.2.0",
"configurations": [
{
"name": "Next dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}Substitute the resolved package manager (npm / pnpm / yarn / bun) and port.
Common gotchas
- App Router vs Pages Router: dev-server behavior is the same; polish doesn't care. Checklist generation (Unit 5) does — pages in
app/andpages/are different surfaces. - Monorepo roots: in a pnpm/Turborepo monorepo,
npm run devat the root typically fans out to multiple packages. Users should setcwdin.claude/launch.jsonto the specific Next app (cwd: "apps/web"). - Env loading:
.env.localis loaded automatically by Next; polish does not need to export it.
Nuxt dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns nuxt and there is no .claude/launch.json to consult.
Signature
nuxt.config.js,nuxt.config.mjs, ornuxt.config.tsexistspackage.jsoncontains anuxtdependency
Start command
Standard:
npm run devAlso valid (read package.json scripts to confirm which the project uses):
pnpm dev
yarn dev
bun run devPrefer the package manager indicated by the lockfile:
pnpm-lock.yaml->pnpm devyarn.lock->yarn devbun.lock/bun.lockb->bun run devpackage-lock.jsonor none ->npm run dev
Port
Default: 3000. Nuxt respects --port <port> and the PORT env var. Overrides follow the cascade in references/dev-server-detection.md.
Stub generation
{
"version": "0.2.0",
"configurations": [
{
"name": "Nuxt dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}Substitute the resolved package manager (npm / pnpm / yarn / bun) and port.
Common gotchas
- Nitro server engine: Nitro (Nuxt's server engine) adds its own dev server behind Nuxt's; polish only cares about the Nuxt port. Do not probe the Nitro internal port separately.
- Port auto-increment: Nuxt auto-increments the port if 3000 is already taken (unlike Next.js which errors). Polish's kill-by-port step handles this by reclaiming the port before starting, so the auto-increment behavior does not cause issues in practice.
- Nuxt 3 vs Nuxt 2: Nuxt 3 uses
nuxt.config.ts, Nuxt 2 usesnuxt.config.js-- both are detected by the signature check. The dev-server command and port defaults are the same across both versions.
Procfile / Overmind dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns procfile and there is no .claude/launch.json to consult. Rails apps with bin/dev take precedence over the bare Procfile path (see dev-server-rails.md).
Signature
ProcfileorProcfile.devexists at the repo rootbin/devis not present (if it is, use the Rails recipe)
Start command
Prefer overmind when available — it handles socket files, supports hot-restart per process, and is the community default for multi-process dev:
overmind start -f Procfile.devFallback to foreman when overmind is not installed:
foreman start -f Procfile.devIf both are missing, prompt the user for the start command rather than guessing.
Port
Default: 3000. Procfile-based projects list their processes in Procfile.dev, so the authoritative port comes from the web: line:
web: bundle exec puma -p 3000 -C config/puma.rb
worker: bundle exec sidekiqParse the web: line for -p <n> or --port <n>. If neither is present, fall through to the cascade in references/dev-server-detection.md.
Stub generation
{
"version": "0.2.0",
"configurations": [
{
"name": "Overmind dev",
"runtimeExecutable": "overmind",
"runtimeArgs": ["start", "-f", "Procfile.dev"],
"port": 3000
}
]
}Substitute foreman if overmind is unavailable on the user's machine — the stub represents what the user will run, not a canonical recipe.
Common gotchas
- Socket files:
overmindwrites a socket to.overmind.sockby default. Polish's kill-by-port logic reclaims the port but does not clean up the socket. If overmind is already running and polish restarts it, the new process may fail with "connection refused" until the stale socket is removed. TheOVERMIND_SOCKETenv var can redirect the socket to a per-run path if needed. - Procfile vs Procfile.dev: production and development Procfiles often differ. Always prefer
Procfile.devfor polish. - Multiple web processes: some Procfiles split web traffic across multiple processes (API + frontend). Polish can only open one URL — users with multi-web setups should author
.claude/launch.jsonexplicitly to select which process is "the dev server" for polish.
Rails dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns rails and there is no .claude/launch.json to consult.
Signature
bin/devexists and is executableGemfileexists
Start command
bin/devbin/dev is the Rails 7+ convention for "start everything" (web + assets watcher + optional workers). It is a one-liner script that invokes foreman start -f Procfile.dev under the hood, so Procfile.dev is the canonical place to read the actual command if bin/dev is missing or non-executable.
Port
Default: 3000. Overrides follow the cascade in references/dev-server-detection.md: 1. Procfile.dev web: line may contain -p <n> 2. config/puma.rb may bind to a non-default port 3. .env / .env.development PORT=<n> 4. AGENTS.md / CLAUDE.md project instructions
Stub generation for .claude/launch.json
When the user accepts "Save this as .claude/launch.json?", emit the Rails stub from launch-json-schema.md:
{
"version": "0.2.0",
"configurations": [
{
"name": "Rails dev",
"runtimeExecutable": "bin/dev",
"runtimeArgs": [],
"port": 3000
}
]
}If the cascade resolved a non-3000 port, substitute it in the stub's port field before writing.
Common gotchas
- Bundler path: some machines require
bundle exec bin/dev. Ifbin/devfails with a load-path error, fall back tobundle exec bin/dev. - Foreman vs overmind:
ProcfilevsProcfile.devoften both exist. Rails'bin/devresolves toProcfile.dev; if the project usesovermindexplicitly, preferovermind start -f Procfile.dev(seedev-server-procfile.md). - SSL dev server:
rails swith--sslchanges the URL scheme. Polish's reachability probe useshttp://; users with SSL dev servers should setportexplicitly in.claude/launch.jsonand note the scheme in the checklist.
Remix dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns remix and there is no .claude/launch.json to consult.
Signature
remix.config.jsorremix.config.tsexists (classic Remix)- Remix 2.x+ on Vite has no
remix.config.*-- it usesvite.config.tswith the Remix plugin, so it resolves asvitetype, notremix
Start command
Standard:
npm run devThe dev script in package.json typically wraps remix dev. Also valid (read package.json scripts to confirm which the project uses):
pnpm dev
yarn dev
bun run devPrefer the package manager indicated by the lockfile:
pnpm-lock.yaml->pnpm devyarn.lock->yarn devbun.lock/bun.lockb->bun run devpackage-lock.jsonor none ->npm run dev
Port
Default: 3000. Remix respects --port <port> flag. Classic Remix dev server also reads the PORT env var. Overrides follow the cascade in references/dev-server-detection.md.
Stub generation
{
"version": "0.2.0",
"configurations": [
{
"name": "Remix dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}Substitute the resolved package manager (npm / pnpm / yarn / bun) and port.
Common gotchas
- Classic vs Vite: Classic Remix uses
remix.config.js; new Remix (v2+) uses Vite -- detected asvitetype, notremix. Theremixtype is specifically for classic Remix projects that still have aremix.config.*file. - Remix v1 vs v2 dev server:
remix devin v2 starts an Express-based dev server that binds a port;remix devin v1 was a watcher only (no server). Polish needs v2+ for the dev server to bind a port and respond to reachability probes. - Remix on Vite inherits Vite's port: When Remix runs on Vite (no
remix.config.*), the default port is 5173 (Vite's default), not 3000. That case is handled by theviterecipe, not this one.
SvelteKit dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns sveltekit and there is no .claude/launch.json to consult.
Signature
svelte.config.js,svelte.config.mjs, orsvelte.config.tsexistspackage.jsoncontains a@sveltejs/kitdependency
Start command
Standard:
npm run devThe dev script in package.json typically wraps vite dev via SvelteKit. Also valid (read package.json scripts to confirm which the project uses):
pnpm dev
yarn dev
bun run devPrefer the package manager indicated by the lockfile:
pnpm-lock.yaml->pnpm devyarn.lock->yarn devbun.lock/bun.lockb->bun run devpackage-lock.jsonor none ->npm run dev
Port
Default: 5173 (inherited from Vite). SvelteKit respects --port <port> flag and Vite's server.port config in vite.config.ts. Overrides follow the cascade in references/dev-server-detection.md.
Stub generation
{
"version": "0.2.0",
"configurations": [
{
"name": "SvelteKit dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}Substitute the resolved package manager (npm / pnpm / yarn / bun) and port.
Common gotchas
- Vite under the hood: SvelteKit uses Vite internally -- same port default (5173), same HMR behavior. The
sveltekittype exists becausesvelte.config.jsis a more precise signal than a genericvite.config.ts, allowing polish to generate a SvelteKit-specific stub name and label. - Adapter does not matter for dev:
adapter-auto,adapter-node,adapter-static, and other adapters all produce the same dev server. The adapter only affects the production build output. - `svelte.config.js` is the primary signature:
svelte.config.jsalways exists in SvelteKit projects, even whenvite.config.tsalso exists. This is the file that distinguishes a SvelteKit project from a plain Vite project.
Vite dev-server recipe (auto-detect fallback)
Loaded when detect-project-type.sh returns vite and there is no .claude/launch.json to consult.
Signature
vite.config.js,vite.config.ts,vite.config.mjs, orvite.config.cjsexists
Start command
Standard:
npm run devThe dev script in package.json typically wraps vite directly. Prefer the package manager indicated by the lockfile (see the Next.js recipe for the lockfile → command mapping).
Port
Default: 5173. Vite respects --port <n> and the VITE_PORT env var. The cascade in references/dev-server-detection.md picks up --port from package.json scripts and PORT from .env*.
Vite's --strictPort flag causes the dev server to fail rather than increment to the next available port when the requested port is in use. Polish's kill-by-port step will reclaim the port before starting, so strictPort is not a problem in practice — but users who disable port reclamation and run multiple Vite instances will see the port auto-increment unless strictPort: true is set in vite.config.ts.
Host binding
Vite binds to 127.0.0.1 by default. For polish running inside a devcontainer or WSL, users may need --host 0.0.0.0 in runtimeArgs. The checklist can note this if relevant to the diff.
Stub generation
{
"version": "0.2.0",
"configurations": [
{
"name": "Vite dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}Common gotchas
- HMR websocket port: Vite's HMR uses a separate websocket that inherits the dev-server port by default. If the project pins
server.hmr.portinvite.config.ts, the polish reachability probe against the dev-server port still works, but the embedded browser may need additional configuration to reach HMR. - Framework on top of Vite: SvelteKit, SolidStart, Qwik City, and Astro all use Vite but add their own dev scripts. The
vitesignature catches them, andnpm run devis the right command for all of them. Different default ports apply (SvelteKit: 5173, Astro: 4321, Qwik: 5173) — rely on the cascade to pick up the actual port frompackage.jsonor.env.
IDE detection for browser handoff
Polish attempts to hand the running dev-server URL off to an IDE's embedded browser so the user can test without a context switch. Detection is best-effort — failure falls through to printing the URL in the interactive summary.
Detection order
Probe environment variables in this order and stop at the first positive match. Earlier entries are more specific; later entries are general fallbacks.
| Order | Signal | IDE | Handoff method |
|---|---|---|---|
| 1 | CLAUDE_CODE env var set (any value) | Claude Code desktop | Print claude-code://browser?url=http://localhost:<port> as a clickable hint; Claude Code's desktop app intercepts claude-code:// URLs. |
| 2 | CURSOR_TRACE_ID env var set | Cursor | Emit cursor://anysphere.cursor-retrieval/open?url=... if Cursor's URL scheme is stable in the user's version; otherwise print the URL with a note to open it in Cursor's simple-browser view. |
| 3 | TERM_PROGRAM=vscode AND no Cursor/Claude Code signal | Plain VS Code | Print the URL with a hint: Open in VS Code: Ctrl+Shift+P → "Simple Browser: Show" → paste URL. |
| 4 | None of the above | Terminal / unknown IDE | Print the URL. No handoff attempt. |
Why env-var probe, not a fancier approach
- Env vars are cross-platform (macOS, Linux, Windows/WSL)
- They fail open — if a probe returns nothing, polish still works
- They don't require any IDE API or socket connection
- They encode "is this shell running inside a known IDE" without guessing
Codex and other platforms
Codex (Claude Agent SDK, Gemini CLI, etc.) do not yet expose an embedded-browser handoff. For these platforms, polish falls through to the terminal branch (print the URL). When a convention emerges, add a new row to the detection table above.
Detection failure is never fatal
If environment probing fails or returns ambiguous results, polish prints the URL verbatim and continues. The dev server is already running by this point — the user can always copy-paste the URL into any browser. The IDE handoff is a convenience, not a gate.
Probe pattern (reference)
The skill consumes these probes inline rather than via a shell script (no state, no parsing, one-shot reads). Typical usage:
if [ -n "${CLAUDE_CODE:-}" ]; then
IDE="claude-code"
elif [ -n "${CURSOR_TRACE_ID:-}" ]; then
IDE="cursor"
elif [ "${TERM_PROGRAM:-}" = "vscode" ]; then
IDE="vscode"
else
IDE="none"
fiNever chain probes with || between different variables — a missing env var must resolve to "no signal", not "error". The ${VAR:-} default-to-empty pattern is mandatory under set -u.
.claude/launch.json schema
Polish reads .claude/launch.json at the repo root to resolve the dev-server start command. The schema is a subset of VS Code's launch.json format — chosen because Claude Code, Cursor, and VS Code all understand it and because users often already have one for editor integration.
Top-level shape
{
"version": "0.2.0",
"configurations": [
{
"name": "<human label>",
"runtimeExecutable": "<binary>",
"runtimeArgs": ["<arg>", "<arg>"],
"port": <number>,
"cwd": "<optional, repo-relative>",
"env": { "<key>": "<value>" }
}
]
}Fields polish consumes
| Field | Required | Purpose |
|---|---|---|
name | yes (when multiple configurations) | Used to disambiguate when the array has more than one entry. Polish asks the user to pick by name. |
runtimeExecutable | yes | The binary polish spawns (e.g., bin/dev, npm, overmind, bun). |
runtimeArgs | no | Array of arguments passed to runtimeExecutable. Default: empty array. |
port | yes | The port the dev server will listen on. Polish probes http://localhost:<port> for reachability and uses it for the IDE browser handoff. |
cwd | no | Repo-relative working directory for the dev server. Default: repo root. Useful for monorepos (apps/web, packages/frontend). |
env | no | Additional environment variables for the dev-server process. Default: inherit polish's environment. |
Stub template (written on first run when user accepts)
When polish auto-detects a project type and the user confirms "Save this as .claude/launch.json?", polish writes a minimal stub derived from the detected type. These templates intentionally hard-code common defaults — users can edit them later.
Rails stub
{
"version": "0.2.0",
"configurations": [
{
"name": "Rails dev",
"runtimeExecutable": "bin/dev",
"runtimeArgs": [],
"port": 3000
}
]
}Next.js stub
{
"version": "0.2.0",
"configurations": [
{
"name": "Next dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}Vite stub
{
"version": "0.2.0",
"configurations": [
{
"name": "Vite dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}Procfile / Overmind stub
{
"version": "0.2.0",
"configurations": [
{
"name": "Overmind dev",
"runtimeExecutable": "overmind",
"runtimeArgs": ["start", "-f", "Procfile.dev"],
"port": 3000
}
]
}Nuxt stub
{
"version": "0.2.0",
"configurations": [
{
"name": "Nuxt dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}Astro stub
{
"version": "0.2.0",
"configurations": [
{
"name": "Astro dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 4321
}
]
}Remix stub
{
"version": "0.2.0",
"configurations": [
{
"name": "Remix dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}SvelteKit stub
{
"version": "0.2.0",
"configurations": [
{
"name": "SvelteKit dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}Why a subset of VS Code's schema
Polish does not use type, request, console, stopOnEntry, or any of the other VS Code fields. Including them is harmless — polish ignores them — but the stub writer never adds them. The fields polish cares about are the ones that describe how to start a long-running dev server on a known port, which is a smaller surface than what VS Code uses for debug-stepping.
Cross-IDE notes
.claude/launch.json is not yet a fully unified standard across Claude Code, Cursor, VS Code, and Codex. Polish leads with .claude/launch.json because:
- Claude Code, Cursor, and VS Code can all read it as a launch config
- It sits at a clean repo-root trust boundary (user-authored, not auto-detected)
- Users who prefer
.vscode/launch.jsoncan symlink or mirror the two files manually
If a cross-IDE standard emerges (e.g., .workspace/launch.json), the stub writer and reader can swap paths without touching the rest of the skill.
#!/usr/bin/env bash
#
# detect-project-type.sh — inspect signature files at the repo root (and, if
# no root match is found, probe shallow subdirectories) to emit a project-type
# identifier on stdout.
#
# Usage:
# detect-project-type.sh
#
# Output grammar (one line on stdout):
#
# <type> — single signature match at root
# e.g. "next", "rails", "vite"
#
# <type>@<relative-dir> — single monorepo hit (no root match)
# e.g. "next@apps/web"
#
# multiple — two or more disjoint root signatures
# (caller must prompt for disambiguation)
#
# multiple:<type>@<dir>,<type>@<dir> — multiple monorepo hits (no root match)
# e.g. "multiple:next@apps/web,rails@apps/api"
#
# unknown — no signatures found at root or in probe
#
# Supported root types: rails, next, vite, nuxt, astro, remix, sveltekit, procfile
#
# Monorepo probe:
# Runs only when root detection finds ZERO matches. Searches subdirectories
# up to depth 3 (e.g. services/api/server/vite.config.ts) for framework
# signature files. Deeper nesting is ignored to avoid false positives.
#
# Excluded directories (not real project roots):
# node_modules .git vendor dist build coverage .next .nuxt
# .svelte-kit .turbo tmp fixtures
#
# `multiple` vs `rails`: Rails apps commonly ship a Procfile.dev alongside
# bin/dev. To avoid treating every Rails app as a monorepo, the `rails`
# signature takes precedence over a bare `procfile` match. `multiple` is
# reserved for genuine disambiguation cases (e.g., Rails + Next, Next + Vite).
set -u
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "ERROR: not in a git repository" >&2
exit 1
fi
cd "$REPO_ROOT" || { echo "ERROR: cannot cd to repo root" >&2; exit 1; }
MATCHES=()
# Rails: bin/dev AND Gemfile together. A Gemfile alone (or bin/dev alone) is
# insufficient -- plenty of gems have Gemfiles without bin/dev, and bin/dev
# may exist in non-Rails projects.
if [ -f "bin/dev" ] && [ -f "Gemfile" ]; then
MATCHES+=("rails")
fi
# Next.js
if [ -f "next.config.js" ] || [ -f "next.config.mjs" ] || [ -f "next.config.ts" ] || [ -f "next.config.cjs" ]; then
MATCHES+=("next")
fi
# Vite
if [ -f "vite.config.js" ] || [ -f "vite.config.ts" ] || [ -f "vite.config.mjs" ] || [ -f "vite.config.cjs" ]; then
MATCHES+=("vite")
fi
# Nuxt
if [ -f "nuxt.config.js" ] || [ -f "nuxt.config.mjs" ] || [ -f "nuxt.config.ts" ]; then
MATCHES+=("nuxt")
fi
# Astro
if [ -f "astro.config.js" ] || [ -f "astro.config.mjs" ] || [ -f "astro.config.ts" ]; then
MATCHES+=("astro")
fi
# Remix (classic — Remix on Vite uses vite.config.ts, detected as vite)
if [ -f "remix.config.js" ] || [ -f "remix.config.ts" ]; then
MATCHES+=("remix")
fi
# SvelteKit
if [ -f "svelte.config.js" ] || [ -f "svelte.config.mjs" ] || [ -f "svelte.config.ts" ]; then
MATCHES+=("sveltekit")
fi
# Procfile / Overmind / Foreman — only if we didn't already detect rails
if [ ${#MATCHES[@]} -eq 0 ] || [ "${MATCHES[0]}" != "rails" ]; then
if [ -f "Procfile" ] || [ -f "Procfile.dev" ]; then
MATCHES+=("procfile")
fi
fi
# ── Root result ──────────────────────────────────────────────────────────────
case ${#MATCHES[@]} in
0)
# No root match — run monorepo probe (shallow find, depth <= 3).
;;
1)
echo "${MATCHES[0]}"
exit 0
;;
*)
echo "multiple"
exit 0
;;
esac
# ── Monorepo probe ─────────────────────────────────────────────────────────
# When root detection returns zero matches, descend up to depth 3 looking for
# framework signatures in workspace directories. Common layouts:
# apps/web/next.config.js (depth 2)
# packages/frontend/vite.config.ts (depth 2)
# services/api/server/vite.config.ts (depth 3)
#
# Exclusion list: directories that ship framework configs as fixtures or build
# output, not as real project roots.
EXCLUDE_DIRS="node_modules .git vendor dist build coverage .next .nuxt .svelte-kit .turbo tmp fixtures"
EXCLUDE_ARGS=""
for d in $EXCLUDE_DIRS; do
EXCLUDE_ARGS="$EXCLUDE_ARGS -path './$d' -prune -o -path '*/$d' -prune -o"
done
# Signature file patterns to look for
SIGNATURE_PATTERNS=(
"next.config.js" "next.config.mjs" "next.config.ts" "next.config.cjs"
"vite.config.js" "vite.config.ts" "vite.config.mjs" "vite.config.cjs"
"nuxt.config.js" "nuxt.config.mjs" "nuxt.config.ts"
"astro.config.js" "astro.config.mjs" "astro.config.ts"
"remix.config.js" "remix.config.ts"
"svelte.config.js" "svelte.config.mjs" "svelte.config.ts"
)
# Build the find -name arguments
NAME_ARGS=""
for i in "${!SIGNATURE_PATTERNS[@]}"; do
if [ "$i" -gt 0 ]; then
NAME_ARGS="$NAME_ARGS -o"
fi
NAME_ARGS="$NAME_ARGS -name '${SIGNATURE_PATTERNS[$i]}'"
done
# Run find. Use eval because the dynamically built arguments contain quoted
# strings that must be expanded by the shell.
FOUND_FILES=$(eval "find . -maxdepth 4 $EXCLUDE_ARGS \\( $NAME_ARGS \\) -print" 2>/dev/null | sort)
# Also check for Rails signature (bin/dev + Gemfile in the same subdir)
RAILS_HITS=""
# Find all Gemfiles at depth <= 3, check each dir for bin/dev
while IFS= read -r gemfile; do
[ -z "$gemfile" ] && continue
gdir=$(dirname "$gemfile")
if [ -f "$gdir/bin/dev" ]; then
RAILS_HITS="$RAILS_HITS
$gdir"
fi
done < <(eval "find . -maxdepth 4 $EXCLUDE_ARGS -name 'Gemfile' -print" 2>/dev/null)
# Parse found files into (type, relative-dir) pairs. Use a newline-delimited
# string instead of an associative array so the script works on macOS's default
# Bash 3.2 as well as newer Bash versions.
MONO_HITS=""
add_mono_hit() {
hit="$1"
if printf '%s\n' "$MONO_HITS" | grep -Fxq "$hit"; then
return 0
fi
MONO_HITS="${MONO_HITS}
${hit}"
}
if [ -n "$FOUND_FILES" ]; then
for f in $FOUND_FILES; do
[ -z "$f" ] && continue
fname=$(basename "$f")
fdir=$(dirname "$f")
# Normalize dir: strip leading ./
fdir="${fdir#./}"
# Enforce depth cap of 3: count slashes in the relative path of the file.
# A file at apps/web/next.config.js has dir apps/web (1 slash = depth 2).
# A file at a/b/c/d/next.config.js has dir a/b/c/d (3 slashes = depth 4 = too deep).
# We want maxdepth 3 for the directory, meaning at most 2 slashes in fdir.
slash_count=$(echo "$fdir" | tr -cd '/' | wc -c | tr -d ' ')
if [ "$slash_count" -gt 2 ]; then
continue
fi
case "$fname" in
next.config.*) ftype="next" ;;
vite.config.*) ftype="vite" ;;
nuxt.config.*) ftype="nuxt" ;;
astro.config.*) ftype="astro" ;;
remix.config.*) ftype="remix" ;;
svelte.config.*) ftype="sveltekit" ;;
*) continue ;;
esac
# Skip root hits (those would have been caught by root detection)
if [ "$fdir" = "." ]; then continue; fi
add_mono_hit "${ftype}@${fdir}"
done
fi
# Add Rails monorepo hits
if [ -n "$RAILS_HITS" ]; then
for rdir in $RAILS_HITS; do
[ -z "$rdir" ] && continue
rdir="${rdir#./}"
if [ "$rdir" != "." ] && [ -n "$rdir" ]; then
# Enforce depth cap for Rails hits too
slash_count=$(echo "$rdir" | tr -cd '/' | wc -c | tr -d ' ')
if [ "$slash_count" -le 2 ]; then
add_mono_hit "rails@${rdir}"
fi
fi
done
fi
MONO_HITS=$(printf '%s\n' "$MONO_HITS" | sed '/^$/d' | sort)
MONO_COUNT=$(printf '%s\n' "$MONO_HITS" | sed '/^$/d' | wc -l | tr -d ' ')
case $MONO_COUNT in
0)
echo "unknown"
;;
1)
# Single monorepo hit: emit type@cwd
printf '%s\n' "$MONO_HITS"
;;
*)
# Multiple hits: emit multiple:type1@cwd1,type2@cwd2,...
result=$(printf '%s\n' "$MONO_HITS" | paste -sd, -)
echo "multiple:$result"
;;
esac
#!/usr/bin/env bash
#
# read-launch-json.sh — read .claude/launch.json from the repo root and emit
# the selected configuration as JSON on stdout, or a sentinel on failure.
#
# Usage:
# read-launch-json.sh [config-name]
#
# Arguments:
# config-name (optional) — if multiple configurations exist and this arg
# matches a configuration's `name`, emit that one.
# If omitted and there are multiple configurations,
# emit a __MULTIPLE_CONFIGS__ sentinel followed by a
# JSON array of configuration names on the next line.
#
# Output contract:
# Success: single-line JSON object on stdout representing the chosen
# configuration. Shape mirrors VS Code's launch.json entry:
# {name, runtimeExecutable, runtimeArgs, port, cwd, env}.
# Sentinels (printed to stdout, one per line):
# __NO_LAUNCH_JSON__ - file not found
# __INVALID_LAUNCH_JSON__ - file exists but fails JSON parsing
# __MISSING_CONFIGURATIONS__ - valid JSON but no `configurations` array
# __MULTIPLE_CONFIGS__ - ambiguity, needs caller disambiguation.
# Followed by a JSON array of names on line 2.
# __CONFIG_NOT_FOUND__ - caller-provided name doesn't match any entry
#
# The script never exits non-zero for a missing or malformed file -- callers
# parse the sentinel and decide how to proceed. Exit code 1 is reserved for
# genuine operational failures (missing `jq`, git root not found).
set -u
REQUESTED_NAME="${1:-}"
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "ERROR: not in a git repository" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required but not installed" >&2
exit 1
fi
LAUNCH_PATH="$REPO_ROOT/.claude/launch.json"
if [ ! -f "$LAUNCH_PATH" ]; then
echo "__NO_LAUNCH_JSON__"
exit 0
fi
# Validate JSON. We parse with `jq empty` so malformed JSON is caught
# before any downstream query runs.
if ! jq empty "$LAUNCH_PATH" >/dev/null 2>&1; then
echo "__INVALID_LAUNCH_JSON__"
exit 0
fi
CONFIG_COUNT=$(jq '(.configurations // []) | length' "$LAUNCH_PATH")
if [ "$CONFIG_COUNT" = "0" ]; then
echo "__MISSING_CONFIGURATIONS__"
exit 0
fi
if [ "$CONFIG_COUNT" = "1" ]; then
jq -c '.configurations[0]' "$LAUNCH_PATH"
exit 0
fi
# Multiple configurations. If the caller named one, emit it. Otherwise, emit
# the sentinel + name list so the caller can prompt the user.
if [ -n "$REQUESTED_NAME" ]; then
MATCH=$(jq -c --arg name "$REQUESTED_NAME" '.configurations[] | select(.name == $name)' "$LAUNCH_PATH")
if [ -z "$MATCH" ]; then
echo "__CONFIG_NOT_FOUND__"
exit 0
fi
echo "$MATCH"
exit 0
fi
echo "__MULTIPLE_CONFIGS__"
jq -c '[.configurations[].name]' "$LAUNCH_PATH"
exit 0
#!/usr/bin/env bash
#
# resolve-package-manager.sh — detect which JS package manager a project uses
# by inspecting lockfiles, and emit the binary name plus canonical command tail.
#
# Usage:
# resolve-package-manager.sh [path]
#
# Arguments:
# path (optional) — directory to inspect. When omitted, defaults to the
# repo root via `git rev-parse --show-toplevel`.
#
# Output contract (two lines on stdout):
# Line 1: package-manager binary token (`npm` | `pnpm` | `yarn` | `bun`)
# Line 2: canonical argv tail for running a dev script
# - npm: "run dev" (npm requires the `run` verb)
# - pnpm: "dev" (pnpm allows bare script names)
# - yarn: "dev" (yarn allows bare script names)
# - bun: "run dev" (bun requires the `run` verb)
#
# Lockfile priority order (first match wins):
# 1. pnpm-lock.yaml -> pnpm
# 2. yarn.lock -> yarn
# 3. bun.lock -> bun (text format, preferred — newer canonical)
# 4. bun.lockb -> bun (binary format, legacy)
# 5. package-lock.json -> npm
# When both bun.lock and bun.lockb are present, bun.lock (text) is checked
# first and wins because it is the newer canonical format.
#
# Sentinel (stdout, exit 0):
# __NO_PACKAGE_JSON__ — the target directory has no package.json
#
# Errors (stderr, exit 1):
# ERROR: <message> — path does not exist, is not a directory, or
# no positional arg and not inside a git repo
set -u
TARGET_PATH="${1:-}"
# Resolve target directory: positional arg or git repo root.
if [ -n "$TARGET_PATH" ]; then
if [ ! -d "$TARGET_PATH" ]; then
echo "ERROR: path does not exist or is not a directory: $TARGET_PATH" >&2
exit 1
fi
else
TARGET_PATH=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$TARGET_PATH" ]; then
echo "ERROR: not in a git repository and no path argument provided" >&2
exit 1
fi
fi
# Sentinel: no package.json means this is not a JS/TS project.
if [ ! -f "$TARGET_PATH/package.json" ]; then
echo "__NO_PACKAGE_JSON__"
exit 0
fi
# Check lockfiles in priority order.
if [ -f "$TARGET_PATH/pnpm-lock.yaml" ]; then
echo "pnpm"
echo "dev"
exit 0
fi
if [ -f "$TARGET_PATH/yarn.lock" ]; then
echo "yarn"
echo "dev"
exit 0
fi
if [ -f "$TARGET_PATH/bun.lock" ]; then
echo "bun"
echo "run dev"
exit 0
fi
if [ -f "$TARGET_PATH/bun.lockb" ]; then
echo "bun"
echo "run dev"
exit 0
fi
if [ -f "$TARGET_PATH/package-lock.json" ]; then
echo "npm"
echo "run dev"
exit 0
fi
# Fallback: package.json present but no recognized lockfile.
echo "npm"
echo "run dev"
exit 0
#!/usr/bin/env bash
#
# resolve-port.sh -- resolve the dev-server port for a project.
#
# Usage:
# resolve-port.sh [path] [--type <type>] [--port <n>]
#
# Arguments:
# path (optional) -- project root directory. Defaults to the git repo root.
# --type (optional) -- framework type to scope probes (rails|next|vite|nuxt|
# astro|remix|sveltekit|procfile). Unset runs all probes.
# --port (optional) -- explicit port override. Emitted immediately when present.
#
# Output:
# Single line on stdout: the resolved port number.
# stderr is reserved for ERROR: messages only.
#
# Probe order (FIRST HIT WINS):
#
# 1. Explicit --port flag
# 2. Framework config files (next.config.*, vite.config.*, nuxt.config.*,
# astro.config.*) -- conservative regex matching only numeric literal
# port values. Variable references like process.env.PORT or getPort()
# are deliberately not matched; the probe falls through.
# 3. Rails: config/puma.rb for `port <n>`
# 4. Procfile.dev: web line scanned for -p/-p=<n>/--port/--port=<n>
# 5. docker-compose.yml: line-anchored grep for "- "<n>:<n>"" port mapping
# 6. package.json: dev/start script for --port/-p flags
# 7. .env files in override order: .env.local -> .env.development -> .env
# (first hit wins). Values are parsed with quote stripping (" and ')
# and comment truncation (at #, after trimming whitespace).
# 8. Framework default lookup table
#
# Why config-before-prose: framework config files are the most reliable source
# of truth for the intended port; instruction files and env files are often
# stale or overridden. Prose files (AGENTS.md, CLAUDE.md) are deliberately NOT
# scanned -- they carry natural language that may mention ports in contexts
# unrelated to the dev server (documentation, examples, troubleshooting).
# Scanning them produces false positives that are hard to debug.
#
# .env parsing contract: surrounding double or single quotes are stripped.
# Inline comments (# ...) are truncated after trimming whitespace. This is
# intentionally more aggressive than the test-browser skill's inline cascade,
# which does neither. See dev-server-detection.md for the divergence notes.
set -u
# ── Argument parsing ─────────────────────────────────────────────────────────
PROJECT_ROOT=""
PROJ_TYPE=""
EXPLICIT_PORT=""
while [ $# -gt 0 ]; do
case "$1" in
--type)
PROJ_TYPE="${2:-}"
shift 2
;;
--port)
EXPLICIT_PORT="${2:-}"
shift 2
;;
*)
if [ -z "$PROJECT_ROOT" ]; then
PROJECT_ROOT="$1"
fi
shift
;;
esac
done
# Default to git repo root when no positional path is given.
if [ -z "$PROJECT_ROOT" ]; then
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$PROJECT_ROOT" ]; then
echo "ERROR: not in a git repository and no path provided" >&2
exit 1
fi
fi
if [ ! -d "$PROJECT_ROOT" ]; then
echo "ERROR: path does not exist: $PROJECT_ROOT" >&2
exit 1
fi
# ── Helpers ──────────────────────────────────────────────────────────────────
# should_probe TYPE PROBE_NAME
# Returns 0 (true) if the probe should run for the given --type.
should_probe() {
local ptype="$1"
local probe="$2"
if [ -z "$ptype" ]; then
return 0 # no type filter -- run all probes
fi
case "$ptype" in
rails)
case "$probe" in
puma|procfile|docker-compose|env|default) return 0 ;;
*) return 1 ;;
esac
;;
next|nuxt|astro|remix|vite|sveltekit)
case "$probe" in
framework-config|package-json|env|default) return 0 ;;
*) return 1 ;;
esac
;;
procfile)
case "$probe" in
procfile|docker-compose|env|default) return 0 ;;
*) return 1 ;;
esac
;;
*)
return 0 # unknown type -- run all probes
;;
esac
}
# parse_env_port FILE
# Parses PORT=<n> from the given file. Strips surrounding quotes and inline
# comments. Prints the port on stdout or nothing.
parse_env_port() {
local envfile="$1"
if [ ! -f "$envfile" ]; then
return
fi
local line
line=$(grep -E '^PORT=' "$envfile" 2>/dev/null | tail -1)
if [ -z "$line" ]; then
return
fi
# Extract value after PORT=
local value
value="${line#PORT=}"
# Trim whitespace, then truncate at # (inline comment) -- comment stripping
# must happen BEFORE quote stripping so PORT="3001" # comment -> "3001" -> 3001
value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*#.*$//;s/[[:space:]]*$//')
# Strip surrounding double quotes
value="${value%\"}"
value="${value#\"}"
# Strip surrounding single quotes
value="${value%\'}"
value="${value#\'}"
# Trim any remaining whitespace
value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -n "$value" ]; then
printf '%s' "$value"
fi
}
# ── Probe 1: Explicit --port flag ────────────────────────────────────────────
if [ -n "$EXPLICIT_PORT" ]; then
echo "$EXPLICIT_PORT"
exit 0
fi
# ── Probe 2: Framework config files ─────────────────────────────────────────
if should_probe "$PROJ_TYPE" "framework-config"; then
for cfg in \
"$PROJECT_ROOT"/next.config.js \
"$PROJECT_ROOT"/next.config.ts \
"$PROJECT_ROOT"/next.config.mjs \
"$PROJECT_ROOT"/next.config.cjs \
"$PROJECT_ROOT"/vite.config.js \
"$PROJECT_ROOT"/vite.config.ts \
"$PROJECT_ROOT"/vite.config.mjs \
"$PROJECT_ROOT"/vite.config.cjs \
"$PROJECT_ROOT"/nuxt.config.js \
"$PROJECT_ROOT"/nuxt.config.ts \
"$PROJECT_ROOT"/nuxt.config.mjs \
"$PROJECT_ROOT"/nuxt.config.cjs \
"$PROJECT_ROOT"/astro.config.js \
"$PROJECT_ROOT"/astro.config.ts \
"$PROJECT_ROOT"/astro.config.mjs \
"$PROJECT_ROOT"/astro.config.cjs \
; do
if [ ! -f "$cfg" ]; then
continue
fi
# Conservative regex: match "port:" + digits, then verify nothing non-numeric
# follows (rejects variable references like "port: process.env.PORT || 3000").
local_line=$(grep -E 'port:[[:space:]]*["'"'"']?[0-9]+' "$cfg" 2>/dev/null | head -1)
if [ -z "$local_line" ]; then continue; fi
local_port=$(printf '%s' "$local_line" | grep -Eo 'port:[[:space:]]*["'"'"']?[0-9]+["'"'"']?' | head -1 | grep -Eo '[0-9]+')
if [ -n "$local_port" ]; then
local_after=$(printf '%s' "$local_line" | sed "s/.*port:[[:space:]]*[\"']*${local_port}[\"']*//" )
if [ -z "$local_after" ] || printf '%s' "$local_after" | grep -qE '^[[:space:],})]*$'; then
echo "$local_port"
exit 0
fi
fi
done
fi
# ── Probe 3: Rails config/puma.rb ───────────────────────────────────────────
if should_probe "$PROJ_TYPE" "puma"; then
puma_file="$PROJECT_ROOT/config/puma.rb"
if [ -f "$puma_file" ]; then
puma_port=$(grep -Eo 'port[[:space:]]+[0-9]+' "$puma_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+')
if [ -n "$puma_port" ]; then
echo "$puma_port"
exit 0
fi
fi
fi
# ── Probe 4: Procfile.dev ───────────────────────────────────────────────────
if should_probe "$PROJ_TYPE" "procfile"; then
procfile="$PROJECT_ROOT/Procfile.dev"
if [ -f "$procfile" ]; then
# Extract the web line
web_line=$(grep -E '^web:' "$procfile" 2>/dev/null | head -1)
if [ -n "$web_line" ]; then
# Match -p <n>, -p<n>, --port <n>, -p=<n>, --port=<n>
proc_port=$(printf '%s' "$web_line" | grep -Eo '(-p[= ]*|--port[= ]+)[0-9]+' | head -1 | grep -Eo '[0-9]+')
if [ -n "$proc_port" ]; then
echo "$proc_port"
exit 0
fi
fi
fi
fi
# ── Probe 5: docker-compose.yml ─────────────────────────────────────────────
if should_probe "$PROJ_TYPE" "docker-compose"; then
compose_file="$PROJECT_ROOT/docker-compose.yml"
if [ -f "$compose_file" ]; then
# Simple line-anchored grep for port mappings: - "NNNN:NNNN" or - NNNN:NNNN
compose_port=$(grep -Eo '"[0-9]+:[0-9]+"' "$compose_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+' | head -1)
if [ -n "$compose_port" ]; then
echo "$compose_port"
exit 0
fi
fi
fi
# ── Probe 6: package.json scripts ───────────────────────────────────────────
if should_probe "$PROJ_TYPE" "package-json"; then
pkg_file="$PROJECT_ROOT/package.json"
if [ -f "$pkg_file" ]; then
# Look for --port or -p in dev/start scripts
pkg_port=$(grep -Eo '(-p[= ]+|--port[= ]+)[0-9]+' "$pkg_file" 2>/dev/null | head -1 | grep -Eo '[0-9]+')
if [ -n "$pkg_port" ]; then
echo "$pkg_port"
exit 0
fi
fi
fi
# ── Probe 7: .env files ─────────────────────────────────────────────────────
if should_probe "$PROJ_TYPE" "env"; then
for envfile in \
"$PROJECT_ROOT/.env.local" \
"$PROJECT_ROOT/.env.development" \
"$PROJECT_ROOT/.env" \
; do
env_port=$(parse_env_port "$envfile")
if [ -n "$env_port" ]; then
echo "$env_port"
exit 0
fi
done
fi
# ── Probe 8: Framework default lookup table ──────────────────────────────────
if should_probe "$PROJ_TYPE" "default"; then
case "$PROJ_TYPE" in
rails|next|nuxt|remix|procfile|"")
echo "3000"
;;
vite|sveltekit)
echo "5173"
;;
astro)
echo "4321"
;;
*)
echo "3000"
;;
esac
exit 0
fi
# Final fallback (should not normally be reached)
echo "3000"
exit 0