@vibekiln/slopburn-cli-staging 0.1.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 ADDED
@@ -0,0 +1,91 @@
1
+ # SlopBurn CLI
2
+
3
+ `slopburn` is a first-class CLI for SlopBurn with dedicated onboarding and MCP config, while reusing Cutline's auth/runtime components under the hood.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g @vibekiln/slopburn-cli
9
+ ```
10
+
11
+ For staging:
12
+
13
+ ```bash
14
+ npm i -g @vibekiln/slopburn-cli-staging
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ - `slopburn setup [--staging] [--serve-now]`
20
+ Writes MCP config entries for only the `slopburn` server, saves channel preference in `~/.slopburn/config.json`, writes project-local game defaults to `.cutline/slopburn.json`, and installs `.cursor/rules/slopburn-intents.mdc` so natural-language prompts route to SlopBurn MCP tools.
21
+ - `--serve-now` launches the MCP server immediately after setup to reduce handoff friction.
22
+ - `--verbose` prints the longer setup summary.
23
+ - `slopburn login [--staging]`
24
+ - `slopburn status [--staging]`
25
+ - `slopburn logout [--staging]`
26
+ - `slopburn serve [--staging] [--project-root <path>] [--http --host --port --path] [--verbose]`
27
+ - `slopburn bootstrap [--project-root <path>] [--full]`
28
+ - `slopburn event <title|level-up|game-over|encounter|victory>` (renders local terminal banners/audio cues)
29
+
30
+ ## How it works
31
+
32
+ - `setup` writes `slopburn` MCP entries separately from `cutline-*` entries.
33
+ - Generated MCP config calls `slopburn serve` directly instead of wrapping startup in `npx`.
34
+ - `serve` delegates to Cutline MCP components via:
35
+ - production: `@vibekiln/cutline-mcp-cli`
36
+ - staging: `@vibekiln/cutline-mcp-cli-staging`
37
+ - `slopburn` now bundles both Cutline CLI packages as dependencies, so one global install is enough.
38
+ - Runtime prefers the bundled local dependency and only falls back to `npx` resolution if needed.
39
+
40
+ This keeps SlopBurn onboarding/config independent while still leveraging shared Cutline internals.
41
+
42
+ ## Startup Model
43
+
44
+ - `~/.slopburn/config.json` stores machine-local CLI state like the preferred channel.
45
+ - `.cutline/slopburn.json` stores project-local game defaults such as:
46
+ - onboarding mode
47
+ - default character name/class
48
+ - optional project/product linkage
49
+ - quiet/auto-resume startup preferences
50
+ - MCP startup reads the project-local config, so the same defaults work whether SlopBurn is launched from `slopburn serve` or from your IDE MCP config.
51
+
52
+ ## First-Time Setup
53
+
54
+ ```bash
55
+ slopburn setup --serve-now
56
+ ```
57
+
58
+ Then refresh your MCP/chat session and say:
59
+
60
+ ```text
61
+ start slopburn
62
+ ```
63
+
64
+ If you want the longer reference output, run:
65
+
66
+ ```bash
67
+ slopburn bootstrap --full
68
+ ```
69
+
70
+ ## Returning User
71
+
72
+ If `.cutline/slopburn.json` already exists, setup reuses it and avoids repeating character questions:
73
+
74
+ ```bash
75
+ slopburn setup --serve-now
76
+ ```
77
+
78
+ Then refresh your MCP/chat session and say:
79
+
80
+ ```text
81
+ start slopburn
82
+ ```
83
+
84
+ ## Natural-language start prompts
85
+
86
+ After `slopburn setup`, you can say these directly in your AI chat:
87
+
88
+ - `start slopburn`
89
+ - `take a turn in slopburn for this feature idea`
90
+ - `roll tests as the dungeon check for my run`
91
+ - `show my spellbook and equip the best offered spell`
@@ -0,0 +1,6 @@
1
+ export declare const SLOPBURN_ASCII: {
2
+ readonly title: string;
3
+ readonly install: string;
4
+ readonly levelUp: string;
5
+ readonly gameOver: string;
6
+ };
package/dist/ascii.js ADDED
@@ -0,0 +1,33 @@
1
+ export const SLOPBURN_ASCII = {
2
+ title: String.raw `
3
+ .d8888b. 888 .d88888b. 8888888b. 888888b. 888 888 8888888b. 888b 888
4
+ d88P Y88b 888 d88P" "Y88b 888 Y88b 888 "88b 888 888 888 Y88b 8888b 888
5
+ Y88b. 888 888 888 888 888 888 .88P 888 888 888 888 88888b 888
6
+ "Y888b. 888 888 888 888 d88P 8888888K. 888 888 888 d88P 888Y88b 888
7
+ "Y88b. 888 888 888 8888888P" 888 "Y88b 888 888 8888888P" 888 Y88b888
8
+ "888 888 888 888 888 888 888 888 888 888 T88b 888 Y88888
9
+ Y88b d88P 888 Y88b. .d88P 888 888 d88P Y88b. .d88P 888 T88b 888 Y8888
10
+ "Y8888P" 88888888 "Y88888P" 888 8888888P" "Y88888P" 888 T88b 888 Y888
11
+ `,
12
+ install: String.raw `
13
+ ____ _ ___ ____ ____ _ _ ____ _ _
14
+ / ___|| | / _ \| _ \| __ )| | | | _ \| \ | |
15
+ \___ \| | | | | | |_) | _ \| | | | |_) | \| |
16
+ ___) | |___| |_| | __/| |_) | |_| | _ <| |\ |
17
+ |____/|_____|\___/|_| |____/ \___/|_| \_\_| \_|
18
+ `,
19
+ levelUp: String.raw `
20
+ _ _ _ _ ____
21
+ | | _____ _____| | | | | | _ \
22
+ | | / _ \ \ / / _ \ | | | | | |_) |
23
+ | |__| __/\ V / __/ | | |_| | __/
24
+ |_____\___| \_/ \___|_| \___/|_|
25
+ `,
26
+ gameOver: String.raw `
27
+ ____ ___
28
+ / ___| __ _ _ __ ___ ___ / _ \__ _____ _ __
29
+ | | _ / _\` | '_ \` _ \ / _ \ | | | \ \ / / _ \ '__|
30
+ | |_| | (_| | | | | | | __/ | |_| |\ V / __/ |
31
+ \____|\__,_|_| |_| |_|\___| \___/ \_/ \___|_|
32
+ `,
33
+ };
@@ -0,0 +1,6 @@
1
+ interface BootstrapOptions {
2
+ projectRoot?: string;
3
+ full?: boolean;
4
+ }
5
+ export declare function bootstrapCommand(options: BootstrapOptions): void;
6
+ export {};
@@ -0,0 +1,53 @@
1
+ import chalk from "chalk";
2
+ import { resolve } from "node:path";
3
+ import { getProjectGameConfigPath, readProjectGameConfig, resolveSavedOnboarding } from "../utils/config.js";
4
+ function buildPrimaryPrompt(onboarding) {
5
+ return "start slopburn";
6
+ }
7
+ export function bootstrapCommand(options) {
8
+ const projectRoot = resolve(String(options.projectRoot || process.cwd()));
9
+ const configPath = getProjectGameConfigPath(projectRoot);
10
+ const projectConfig = readProjectGameConfig(projectRoot);
11
+ const onboarding = resolveSavedOnboarding(projectRoot);
12
+ const hasConfiguredCharacter = Boolean(onboarding?.characterName && onboarding?.characterClass);
13
+ const primaryPrompt = buildPrimaryPrompt(onboarding);
14
+ console.log(chalk.bold("\nSlopBurn Bootstrap\n"));
15
+ console.log(chalk.green(" ✓"), chalk.dim(`Game config: ${configPath}`));
16
+ if (onboarding?.mode === "new_character" && hasConfiguredCharacter) {
17
+ console.log(chalk.green(" ✓"), chalk.dim(`Saved character: ${onboarding.characterName} (${onboarding.characterClass})`));
18
+ }
19
+ else if (onboarding?.mode === "existing_project") {
20
+ console.log(chalk.green(" ✓"), chalk.dim(`Saved mode: existing project${onboarding.productId ? ` (${onboarding.productId})` : ""}`));
21
+ }
22
+ else {
23
+ console.log(chalk.yellow(" !"), chalk.dim("No saved character defaults found; setup will fall back to interactive onboarding."));
24
+ }
25
+ console.log(chalk.dim(` In chat: "${primaryPrompt}"`));
26
+ console.log(chalk.dim(` Startup mode: ${projectConfig.startup?.autoResume === false ? "manual" : "auto-resume ready"}\n`));
27
+ if (!options.full) {
28
+ console.log(chalk.dim(" MCP path: slopburn_start_run -> slopburn_prepare_battle -> slopburn_roll_tests\n"));
29
+ return;
30
+ }
31
+ console.log(chalk.dim("Run these MCP tools from your AI client (not in shell):\n"));
32
+ console.log(chalk.cyan("1) slopburn_start_run"));
33
+ console.log(chalk.dim(" Canonical fast-start. Uses .cutline/slopburn.json defaults and auto-opens the first dungeon battle when possible"));
34
+ console.log();
35
+ console.log(chalk.cyan("2) slopburn_bootstrap_run"));
36
+ console.log(chalk.dim(" Only needed if Character Creation or onboarding still needs explicit input"));
37
+ console.log();
38
+ console.log(chalk.cyan("3) slopburn_prepare_battle"));
39
+ console.log(chalk.dim(" Optional if you want to change rooms/phases before the first roll"));
40
+ console.log();
41
+ console.log(chalk.cyan("4) slopburn_roll_tests"));
42
+ console.log(chalk.dim(" Roll verification tests for the prepared mission and start damaging monsters"));
43
+ console.log();
44
+ console.log(chalk.cyan("5) slopburn_take_turn / slopburn_status"));
45
+ console.log(chalk.dim(" Use these after the first fight for deep-dive story progression and async polling"));
46
+ console.log();
47
+ console.log(chalk.bold("Suggested prompts:"));
48
+ console.log(chalk.cyan(` - "${primaryPrompt}"`));
49
+ console.log(chalk.cyan(' - "take a turn in slopburn"'));
50
+ console.log(chalk.cyan(' - "roll tests as the dungeon check for my run"'));
51
+ console.log(chalk.cyan(' - "show my spellbook and equip the best offered spell"'));
52
+ console.log();
53
+ }
@@ -0,0 +1,5 @@
1
+ interface EventOptions {
2
+ projectRoot?: string;
3
+ }
4
+ export declare function eventCommand(type: string, options?: EventOptions): Promise<void>;
5
+ export {};
@@ -0,0 +1,92 @@
1
+ import chalk from "chalk";
2
+ import { spawn } from "node:child_process";
3
+ import { access } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { SLOPBURN_ASCII } from "../ascii.js";
6
+ import { readProjectGameConfig } from "../utils/config.js";
7
+ async function fileExists(filePath) {
8
+ try {
9
+ await access(filePath);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ async function maybePlayConfiguredAudioCue(type, projectRoot) {
17
+ if (process.platform !== "darwin") {
18
+ return false;
19
+ }
20
+ const projectConfig = readProjectGameConfig(projectRoot);
21
+ const audio = projectConfig.audio;
22
+ if (!audio?.enabled) {
23
+ return false;
24
+ }
25
+ const playerCommand = String(audio.playerCommand || "afplay").trim();
26
+ const configuredSound = type === "encounter"
27
+ ? String(audio.encounterSound || "/System/Library/Sounds/Sosumi.aiff").trim()
28
+ : type === "victory"
29
+ ? String(audio.victorySound || "/System/Library/Sounds/Hero.aiff").trim()
30
+ : String(audio.titleSound || "/System/Library/Sounds/Submarine.aiff").trim();
31
+ if (!playerCommand || !configuredSound) {
32
+ return false;
33
+ }
34
+ const soundPath = configuredSound.startsWith("/") ? configuredSound : resolve(projectRoot, configuredSound);
35
+ if (!(await fileExists(soundPath))) {
36
+ return false;
37
+ }
38
+ try {
39
+ const child = spawn(playerCommand, [soundPath], {
40
+ stdio: "ignore",
41
+ detached: true,
42
+ });
43
+ child.on("error", () => { });
44
+ child.unref();
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ export async function eventCommand(type, options = {}) {
52
+ const normalized = String(type || "").trim().toLowerCase();
53
+ const projectRoot = resolve(String(options.projectRoot || process.cwd()));
54
+ if (normalized === "title" || normalized === "intro" || normalized === "theme") {
55
+ const played = await maybePlayConfiguredAudioCue("title", projectRoot);
56
+ console.log(chalk.cyan(SLOPBURN_ASCII.title));
57
+ console.log(chalk.bold("SlopBurn"));
58
+ console.log(chalk.dim(played ? "Title theme triggered via terminal audio." : "Title theme not played. Check terminal audio setup in .cutline/slopburn.json."));
59
+ console.log();
60
+ return;
61
+ }
62
+ if (normalized === "level-up" || normalized === "levelup") {
63
+ console.log(chalk.green(SLOPBURN_ASCII.levelUp));
64
+ console.log(chalk.bold("Level Up!"));
65
+ console.log(chalk.dim("You gained power. Pick a perk and review spell offers."));
66
+ console.log();
67
+ return;
68
+ }
69
+ if (normalized === "game-over" || normalized === "gameover") {
70
+ console.log(chalk.red(SLOPBURN_ASCII.gameOver));
71
+ console.log(chalk.bold("Game Over"));
72
+ console.log(chalk.dim("Commit your checkpoint and take a short break."));
73
+ console.log();
74
+ return;
75
+ }
76
+ if (normalized === "encounter" || normalized === "monster-encounter" || normalized === "monster_encounter") {
77
+ const played = await maybePlayConfiguredAudioCue("encounter", projectRoot);
78
+ console.log(chalk.magenta.bold("Monster Encounter"));
79
+ console.log(chalk.dim(played ? "Encounter cue triggered via terminal audio." : "Encounter cue not played. Check terminal audio setup in .cutline/slopburn.json."));
80
+ console.log();
81
+ return;
82
+ }
83
+ if (normalized === "victory" || normalized === "monster-victory" || normalized === "monster_victory") {
84
+ const played = await maybePlayConfiguredAudioCue("victory", projectRoot);
85
+ console.log(chalk.green.bold("Monster Defeated"));
86
+ console.log(chalk.dim(played ? "Victory cue triggered via terminal audio." : "Victory cue not played. Check terminal audio setup in .cutline/slopburn.json."));
87
+ console.log();
88
+ return;
89
+ }
90
+ console.log(chalk.yellow(`Unknown event "${type}". Use "title", "level-up", "game-over", "encounter", or "victory".`));
91
+ process.exit(1);
92
+ }
@@ -0,0 +1,10 @@
1
+ interface SetupOptions {
2
+ staging?: boolean;
3
+ projectRoot?: string;
4
+ testWorkingDirectory?: string;
5
+ testCommand?: string;
6
+ serveNow?: boolean;
7
+ verbose?: boolean;
8
+ }
9
+ export declare function setupCommand(options: SetupOptions): Promise<void>;
10
+ export {};
@@ -0,0 +1,511 @@
1
+ import chalk from "chalk";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { runCutlineCliWithEnv } from "../utils/runtime.js";
7
+ import { ensureSlopburnProjectGitignore, getProjectGameConfigPath, readProjectGameConfig, resolveSavedOnboarding, writeProjectGameConfig, writeSlopburnConfig, } from "../utils/config.js";
8
+ const CLASS_OPTIONS = [
9
+ { id: "fighter", label: "Fighter (balanced damage + resilience)" },
10
+ { id: "wizard", label: "Wizard (high wisdom, strategic control)" },
11
+ { id: "rogue", label: "Rogue (high agility, precision strikes)" },
12
+ { id: "paladin", label: "Paladin (defensive and reliable tank)" },
13
+ ];
14
+ function detectCutlineProject(projectRoot) {
15
+ const configPath = join(projectRoot, ".cutline", "config.json");
16
+ if (!existsSync(configPath))
17
+ return {};
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
20
+ return {
21
+ productId: typeof parsed.product_id === "string" ? parsed.product_id : undefined,
22
+ projectName: typeof parsed.product_name === "string" ? parsed.product_name : undefined,
23
+ };
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ function buildServerConfig(channel, projectRoot) {
30
+ return {
31
+ slopburn: {
32
+ command: "slopburn",
33
+ args: [
34
+ "serve",
35
+ ...(channel === "staging" ? ["--staging"] : []),
36
+ "--project-root",
37
+ projectRoot,
38
+ ],
39
+ },
40
+ };
41
+ }
42
+ function buildCanonicalPrompt(_selection) {
43
+ return "start slopburn";
44
+ }
45
+ function readPackageScripts(packageRoot) {
46
+ const packagePath = join(packageRoot, "package.json");
47
+ if (!existsSync(packagePath))
48
+ return null;
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(packagePath, "utf-8"));
51
+ const scripts = parsed.scripts;
52
+ return scripts && typeof scripts === "object" ? scripts : {};
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function extractScriptNameFromCommand(command) {
59
+ const normalized = String(command || "").trim();
60
+ if (!normalized)
61
+ return null;
62
+ const npmRunMatch = normalized.match(/(?:^|\s)npm\s+run\s+([^\s]+)/);
63
+ if (npmRunMatch?.[1])
64
+ return npmRunMatch[1];
65
+ const pnpmRunMatch = normalized.match(/(?:^|\s)pnpm\s+run\s+([^\s]+)/);
66
+ if (pnpmRunMatch?.[1])
67
+ return pnpmRunMatch[1];
68
+ const yarnRunMatch = normalized.match(/(?:^|\s)yarn(?:\s+run)?\s+([^\s]+)/);
69
+ if (yarnRunMatch?.[1])
70
+ return yarnRunMatch[1];
71
+ return null;
72
+ }
73
+ function listPackageCandidateDirectories(projectRoot) {
74
+ const preferred = ["app"];
75
+ let entries = [];
76
+ try {
77
+ entries = readdirSync(projectRoot, { withFileTypes: true });
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const directories = entries
83
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
84
+ .map((entry) => entry.name);
85
+ const ordered = [
86
+ ...preferred.filter((name) => directories.includes(name)),
87
+ ...directories.filter((name) => !preferred.includes(name)),
88
+ ];
89
+ return ordered.map((name) => join(projectRoot, name));
90
+ }
91
+ function normalizeRelativeDirectory(projectRoot, targetDirectory) {
92
+ const resolvedProjectRoot = resolve(projectRoot);
93
+ const resolvedTarget = resolve(projectRoot, targetDirectory);
94
+ if (resolvedTarget === resolvedProjectRoot)
95
+ return ".";
96
+ if (!resolvedTarget.startsWith(`${resolvedProjectRoot}/`)) {
97
+ return targetDirectory;
98
+ }
99
+ return resolvedTarget.slice(resolvedProjectRoot.length + 1);
100
+ }
101
+ function detectVerificationConfig(projectRoot, options, existingProjectConfig) {
102
+ const existingVerification = existingProjectConfig.verification;
103
+ const explicitCommand = String(options.testCommand || "").trim();
104
+ const explicitWorkingDirectory = String(options.testWorkingDirectory || "").trim();
105
+ const command = explicitCommand || existingVerification?.testCommand || "npm run test:unit -- --reporter=json";
106
+ if (explicitWorkingDirectory) {
107
+ return {
108
+ testWorkingDirectory: normalizeRelativeDirectory(projectRoot, explicitWorkingDirectory),
109
+ testCommand: command,
110
+ detectionSource: "explicit_flag",
111
+ };
112
+ }
113
+ if (!explicitCommand && existingVerification?.testWorkingDirectory && existingVerification?.testCommand) {
114
+ return {
115
+ testWorkingDirectory: existingVerification.testWorkingDirectory,
116
+ testCommand: existingVerification.testCommand,
117
+ detectionSource: "existing_config",
118
+ };
119
+ }
120
+ const scriptName = extractScriptNameFromCommand(command);
121
+ const rootScripts = readPackageScripts(projectRoot);
122
+ if (scriptName && rootScripts?.[scriptName]) {
123
+ return {
124
+ testWorkingDirectory: ".",
125
+ testCommand: command,
126
+ detectionSource: "workspace_root",
127
+ };
128
+ }
129
+ if (scriptName) {
130
+ for (const candidate of listPackageCandidateDirectories(projectRoot)) {
131
+ const scripts = readPackageScripts(candidate);
132
+ if (scripts?.[scriptName]) {
133
+ return {
134
+ testWorkingDirectory: normalizeRelativeDirectory(projectRoot, candidate),
135
+ testCommand: command,
136
+ detectionSource: "package_script_match",
137
+ };
138
+ }
139
+ }
140
+ }
141
+ return {
142
+ testWorkingDirectory: existingVerification?.testWorkingDirectory || ".",
143
+ testCommand: command,
144
+ detectionSource: "fallback_default",
145
+ };
146
+ }
147
+ function isRelativeDirectoryInsideProject(projectRoot, targetDirectory) {
148
+ const resolvedProjectRoot = resolve(projectRoot);
149
+ const resolvedTarget = resolve(projectRoot, targetDirectory);
150
+ return resolvedTarget === resolvedProjectRoot || resolvedTarget.startsWith(`${resolvedProjectRoot}/`);
151
+ }
152
+ function directoryExists(targetDirectory) {
153
+ try {
154
+ return statSync(targetDirectory).isDirectory();
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ }
160
+ function buildDefaultAudioSetup(existingProjectConfig) {
161
+ const existingAudio = existingProjectConfig.audio;
162
+ if (typeof existingAudio?.enabled === "boolean") {
163
+ return {
164
+ enabled: existingAudio.enabled,
165
+ playerCommand: existingAudio.playerCommand,
166
+ titleSound: existingAudio.titleSound,
167
+ encounterSound: existingAudio.encounterSound,
168
+ victorySound: existingAudio.victorySound,
169
+ detectionSource: "existing_config",
170
+ };
171
+ }
172
+ if (process.platform === "darwin") {
173
+ return {
174
+ enabled: false,
175
+ playerCommand: "afplay",
176
+ titleSound: "/System/Library/Sounds/Submarine.aiff",
177
+ encounterSound: "/System/Library/Sounds/Sosumi.aiff",
178
+ victorySound: "/System/Library/Sounds/Hero.aiff",
179
+ detectionSource: "interactive_default",
180
+ };
181
+ }
182
+ return {
183
+ enabled: false,
184
+ detectionSource: "disabled_default",
185
+ };
186
+ }
187
+ async function askVerificationSetup(projectRoot, detected, options) {
188
+ const explicitOverridesProvided = Boolean(String(options.testWorkingDirectory || "").trim() || String(options.testCommand || "").trim());
189
+ if (explicitOverridesProvided || !process.stdin.isTTY || !process.stdout.isTTY) {
190
+ return detected;
191
+ }
192
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
193
+ try {
194
+ console.log(chalk.bold("\n Verification Test Setup"));
195
+ console.log(chalk.dim(` Default command: ${detected.testCommand}`));
196
+ console.log(chalk.dim(` Default working directory: ${detected.testWorkingDirectory}`));
197
+ if (detected.detectionSource === "fallback_default") {
198
+ console.log(chalk.yellow(" Could not confidently detect the test package. Confirm these values before continuing."));
199
+ }
200
+ else {
201
+ console.log(chalk.dim(` Detection source: ${detected.detectionSource}`));
202
+ }
203
+ let nextWorkingDirectory = detected.testWorkingDirectory;
204
+ while (true) {
205
+ const answer = (await rl.question(chalk.dim(` Test working directory [${nextWorkingDirectory}]: `))).trim();
206
+ const candidate = answer || nextWorkingDirectory;
207
+ if (!isRelativeDirectoryInsideProject(projectRoot, candidate)) {
208
+ console.log(chalk.red(" Directory must stay inside the project root."));
209
+ continue;
210
+ }
211
+ const resolvedCandidate = resolve(projectRoot, candidate);
212
+ if (!directoryExists(resolvedCandidate)) {
213
+ console.log(chalk.red(` Directory not found: ${candidate}`));
214
+ continue;
215
+ }
216
+ nextWorkingDirectory = normalizeRelativeDirectory(projectRoot, candidate);
217
+ break;
218
+ }
219
+ let nextCommand = detected.testCommand;
220
+ while (true) {
221
+ const answer = (await rl.question(chalk.dim(` Test command [${nextCommand}]: `))).trim();
222
+ const candidate = answer || nextCommand;
223
+ if (!candidate.trim()) {
224
+ console.log(chalk.red(" Test command cannot be empty."));
225
+ continue;
226
+ }
227
+ nextCommand = candidate.trim();
228
+ break;
229
+ }
230
+ const detectionSource = nextWorkingDirectory !== detected.testWorkingDirectory || nextCommand !== detected.testCommand
231
+ ? "interactive_override"
232
+ : detected.detectionSource;
233
+ return {
234
+ testWorkingDirectory: nextWorkingDirectory,
235
+ testCommand: nextCommand,
236
+ detectionSource,
237
+ };
238
+ }
239
+ finally {
240
+ rl.close();
241
+ }
242
+ }
243
+ async function askAudioSetup(existingProjectConfig) {
244
+ const detected = buildDefaultAudioSetup(existingProjectConfig);
245
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
246
+ return detected;
247
+ }
248
+ if (process.platform !== "darwin") {
249
+ return detected;
250
+ }
251
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
252
+ try {
253
+ console.log(chalk.bold("\n Terminal Audio Setup"));
254
+ console.log(chalk.dim(" Optional macOS-only intro, encounter, and victory cues via afplay."));
255
+ const defaultAnswer = detected.enabled ? "Y/n" : "y/N";
256
+ const answer = (await rl.question(chalk.dim(` Enable title/encounter/victory music in terminal? [${defaultAnswer}]: `))).trim().toLowerCase();
257
+ const wantsAudio = detected.enabled
258
+ ? !["n", "no"].includes(answer)
259
+ : ["y", "yes"].includes(answer);
260
+ if (!wantsAudio) {
261
+ return {
262
+ enabled: false,
263
+ detectionSource: detected.detectionSource === "existing_config" ? "existing_config" : "interactive_override",
264
+ };
265
+ }
266
+ return {
267
+ enabled: true,
268
+ playerCommand: detected.playerCommand ?? "afplay",
269
+ titleSound: detected.titleSound ?? "/System/Library/Sounds/Submarine.aiff",
270
+ encounterSound: detected.encounterSound ?? "/System/Library/Sounds/Sosumi.aiff",
271
+ victorySound: detected.victorySound ?? "/System/Library/Sounds/Hero.aiff",
272
+ detectionSource: detected.detectionSource === "existing_config" ? "existing_config" : "interactive_override",
273
+ };
274
+ }
275
+ finally {
276
+ rl.close();
277
+ }
278
+ }
279
+ async function askSetupPath(projectRoot) {
280
+ const detected = detectCutlineProject(projectRoot);
281
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
282
+ return {
283
+ mode: "new_character",
284
+ projectRoot,
285
+ projectName: detected.projectName,
286
+ productId: detected.productId,
287
+ };
288
+ }
289
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
290
+ try {
291
+ console.log(chalk.bold(" Character Setup Menu"));
292
+ const projectLabel = detected.projectName
293
+ ? `${detected.projectName}${detected.productId ? ` (${detected.productId})` : ""}`
294
+ : projectRoot;
295
+ console.log(chalk.cyan(" 1)"), `Use existing Cutline project: ${projectLabel}`);
296
+ console.log(chalk.cyan(" 2)"), "Create a new character");
297
+ const pathAnswer = (await rl.question(chalk.dim(" Choose 1 or 2 [2]: "))).trim();
298
+ const useExisting = pathAnswer === "1";
299
+ if (useExisting) {
300
+ return {
301
+ mode: "existing_project",
302
+ projectRoot,
303
+ projectName: detected.projectName,
304
+ productId: detected.productId,
305
+ };
306
+ }
307
+ const rawName = (await rl.question(chalk.dim(" Character name: "))).trim();
308
+ const characterName = rawName || "Unnamed Adventurer";
309
+ console.log(chalk.dim(" Choose class:"));
310
+ CLASS_OPTIONS.forEach((option, idx) => {
311
+ console.log(chalk.cyan(` ${idx + 1})`), option.label);
312
+ });
313
+ const classAnswer = (await rl.question(chalk.dim(" Pick 1-4 [1]: "))).trim();
314
+ const classIdx = Number.parseInt(classAnswer, 10);
315
+ const pickedClass = Number.isFinite(classIdx) && classIdx >= 1 && classIdx <= CLASS_OPTIONS.length
316
+ ? CLASS_OPTIONS[classIdx - 1].id
317
+ : "fighter";
318
+ return {
319
+ mode: "new_character",
320
+ projectRoot,
321
+ projectName: detected.projectName,
322
+ productId: detected.productId,
323
+ characterName,
324
+ characterClass: pickedClass,
325
+ };
326
+ }
327
+ finally {
328
+ rl.close();
329
+ }
330
+ }
331
+ function writeNaturalLanguageRule(projectRoot, selection) {
332
+ const rulesDir = join(projectRoot, ".cursor", "rules");
333
+ mkdirSync(rulesDir, { recursive: true });
334
+ const rulePath = join(rulesDir, "slopburn-intents.mdc");
335
+ const configuredCharacterBlock = selection.mode === "new_character" && selection.characterName && selection.characterClass
336
+ ? `
337
+ ## Configured Character Defaults
338
+ - Character name: \`${selection.characterName}\`
339
+ - Character class: \`${selection.characterClass}\`
340
+
341
+ When the user asks to start/play SlopBurn without overriding character details:
342
+ - Prefer the defaults stored in \`.cutline/slopburn.json\`.
343
+ - If a direct MCP call still needs explicit values, include BOTH \`character_name\` and \`character_class\`.
344
+ - If user explicitly asks for a different character, override these defaults.
345
+ `
346
+ : "";
347
+ const content = `# SlopBurn Natural Language Intents
348
+
349
+ When the user asks to play/start/resume SlopBurn, route to SlopBurn MCP tools directly.
350
+
351
+ ${configuredCharacterBlock}
352
+
353
+ ## Startup intents
354
+ - "start slopburn", "start a run", "begin campaign", "new character"
355
+ -> call \`slopburn_start_run\` first
356
+ - "resume slopburn", "resume campaign", "continue my run"
357
+ -> call \`slopburn_status\` with \`show_title_screen: true\`
358
+ - "show my run", "campaign status", "where am I in the game"
359
+ -> call \`slopburn_status\`
360
+
361
+ ## Core gameplay intents
362
+ - "take a turn", "continue narrative", "advance act"
363
+ -> call \`slopburn_take_turn\`
364
+ - "roll tests", "roll the dice", "run dungeon checks"
365
+ -> call \`slopburn_roll_tests\`
366
+
367
+ ## Progression intents
368
+ - "show spellbook", "list spells", "what can I equip"
369
+ -> call \`slopburn_spellbook_list\`
370
+ - "install spell <id>"
371
+ -> call \`slopburn_spellbook_install\`
372
+ - "equip spell <id>"
373
+ -> call \`slopburn_spellbook_equip\`
374
+ - "choose perk <id>"
375
+ -> call \`slopburn_choose_perk\`
376
+
377
+ ## Guardrails
378
+ - If the user asks to play but has no \`run_id\`, start with \`slopburn_start_run\`.
379
+ - Use \`slopburn_bootstrap_run\` only when the run explicitly asks for Character Creation or onboarding continuation.
380
+ - Prefer direct MCP tool calls over prose for actionable gameplay requests.
381
+ - If \`game_over\` is returned, surface the game-over message and suggestions clearly.
382
+ `;
383
+ writeFileSync(rulePath, `${content.trim()}\n`);
384
+ return rulePath;
385
+ }
386
+ function summarizeSelection(selection) {
387
+ if (selection.mode === "new_character") {
388
+ return `${selection.characterName ?? "Unnamed Adventurer"} (${selection.characterClass ?? "fighter"})`;
389
+ }
390
+ return selection.projectName
391
+ ? `existing project ${selection.projectName}`
392
+ : "existing Cutline project";
393
+ }
394
+ function mergeIdeConfig(filePath, serverConfig) {
395
+ let existing = {};
396
+ if (existsSync(filePath)) {
397
+ try {
398
+ existing = JSON.parse(readFileSync(filePath, "utf-8"));
399
+ }
400
+ catch {
401
+ existing = {};
402
+ }
403
+ }
404
+ else {
405
+ mkdirSync(join(filePath, ".."), { recursive: true });
406
+ }
407
+ const existingServers = (existing.mcpServers ?? {});
408
+ const cleaned = {};
409
+ for (const [key, value] of Object.entries(existingServers)) {
410
+ if (!key.startsWith("slopburn"))
411
+ cleaned[key] = value;
412
+ }
413
+ existing.mcpServers = { ...cleaned, ...serverConfig };
414
+ writeFileSync(filePath, `${JSON.stringify(existing, null, 2)}\n`);
415
+ }
416
+ export async function setupCommand(options) {
417
+ const channel = options.staging ? "staging" : "production";
418
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
419
+ const serverConfig = buildServerConfig(channel, projectRoot);
420
+ const projectConfigPath = getProjectGameConfigPath(projectRoot);
421
+ const existingProjectConfig = readProjectGameConfig(projectRoot);
422
+ const savedSelection = resolveSavedOnboarding(projectRoot);
423
+ const home = homedir();
424
+ const ideConfigs = [
425
+ { name: "Cursor", path: join(home, ".cursor", "mcp.json") },
426
+ { name: "Claude Code", path: join(home, ".claude.json") },
427
+ ];
428
+ const selection = savedSelection
429
+ ? {
430
+ ...savedSelection,
431
+ projectRoot,
432
+ projectName: savedSelection.projectName ?? detectCutlineProject(projectRoot).projectName,
433
+ productId: savedSelection.productId ?? detectCutlineProject(projectRoot).productId,
434
+ }
435
+ : await askSetupPath(projectRoot);
436
+ const reusedSavedConfig = Boolean(savedSelection);
437
+ const detectedVerificationConfig = detectVerificationConfig(projectRoot, options, existingProjectConfig);
438
+ const verificationConfig = await askVerificationSetup(projectRoot, detectedVerificationConfig, options);
439
+ const audioConfig = await askAudioSetup(existingProjectConfig);
440
+ for (const ide of ideConfigs) {
441
+ try {
442
+ mergeIdeConfig(ide.path, serverConfig);
443
+ if (options.verbose) {
444
+ console.log(chalk.green(` ✓ ${ide.name}`), chalk.dim(ide.path));
445
+ }
446
+ }
447
+ catch (error) {
448
+ console.log(chalk.red(` ✗ ${ide.name}`), chalk.dim(error?.message || "unknown error"));
449
+ }
450
+ }
451
+ writeSlopburnConfig({ channel });
452
+ writeProjectGameConfig(projectRoot, {
453
+ ...existingProjectConfig,
454
+ onboarding: {
455
+ ...selection,
456
+ projectRoot,
457
+ },
458
+ startup: {
459
+ quiet: existingProjectConfig.startup?.quiet ?? true,
460
+ autoResume: existingProjectConfig.startup?.autoResume ?? true,
461
+ },
462
+ verification: {
463
+ testWorkingDirectory: verificationConfig.testWorkingDirectory,
464
+ testCommand: verificationConfig.testCommand,
465
+ },
466
+ audio: audioConfig.enabled
467
+ ? {
468
+ enabled: true,
469
+ playerCommand: audioConfig.playerCommand,
470
+ titleSound: audioConfig.titleSound,
471
+ encounterSound: audioConfig.encounterSound,
472
+ victorySound: audioConfig.victorySound,
473
+ }
474
+ : { enabled: false },
475
+ });
476
+ ensureSlopburnProjectGitignore(projectRoot);
477
+ const rulePath = writeNaturalLanguageRule(projectRoot, selection);
478
+ let shouldServeNow = Boolean(options.serveNow);
479
+ if (!shouldServeNow && process.stdin.isTTY && process.stdout.isTTY) {
480
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
481
+ try {
482
+ const answer = (await rl.question(chalk.dim(" Start SlopBurn server now? [Y/n]: "))).trim().toLowerCase();
483
+ shouldServeNow = !["n", "no"].includes(answer);
484
+ }
485
+ finally {
486
+ rl.close();
487
+ }
488
+ }
489
+ console.log(chalk.bold("\nSlopBurn setup ready.\n"));
490
+ console.log(chalk.green(" ✓"), reusedSavedConfig ? "Using saved game config" : "Saved game config", chalk.dim(projectConfigPath));
491
+ console.log(chalk.green(" ✓"), chalk.dim(`Profile: ${summarizeSelection(selection)} · channel ${channel}`));
492
+ console.log(chalk.green(" ✓"), chalk.dim(`Verification: ${verificationConfig.testCommand} @ ${verificationConfig.testWorkingDirectory}`));
493
+ console.log(chalk.green(" ✓"), chalk.dim(audioConfig.enabled
494
+ ? `Terminal audio: ${audioConfig.playerCommand} (${audioConfig.titleSound} / ${audioConfig.encounterSound} / ${audioConfig.victorySound})`
495
+ : "Terminal audio: disabled"));
496
+ if (options.verbose) {
497
+ console.log(chalk.green(" ✓ Rule"), chalk.dim(rulePath));
498
+ console.log(chalk.green(" ✓ MCP entry"), chalk.dim(`slopburn -> slopburn serve${channel === "staging" ? " --staging" : ""} --project-root ${projectRoot}`));
499
+ console.log(chalk.green(" ✓ Verification source"), chalk.dim(verificationConfig.detectionSource));
500
+ console.log(chalk.green(" ✓ Audio source"), chalk.dim(audioConfig.detectionSource));
501
+ }
502
+ if (shouldServeNow) {
503
+ console.log(chalk.dim(` Refresh your MCP session, then say "${buildCanonicalPrompt(selection)}".\n`));
504
+ runCutlineCliWithEnv(["serve", "slopburn"], channel, {
505
+ SLOPBURN_PROJECT_ROOT: projectRoot,
506
+ SLOPBURN_QUIET_STARTUP: "1",
507
+ });
508
+ return;
509
+ }
510
+ console.log(chalk.dim(` Next: run slopburn serve, refresh your MCP session, then say "${buildCanonicalPrompt(selection)}".\n`));
511
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { resolveChannel } from "./utils/config.js";
7
+ import { runCutlineCli, runCutlineCliWithEnv } from "./utils/runtime.js";
8
+ import { setupCommand } from "./commands/setup.js";
9
+ import { bootstrapCommand } from "./commands/bootstrap.js";
10
+ import { eventCommand } from "./commands/event.js";
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
14
+ const program = new Command();
15
+ program
16
+ .name("slopburn")
17
+ .description("SlopBurn CLI: dedicated onboarding and run flow for the product quality engineering roguelike")
18
+ .version(pkg.version);
19
+ program
20
+ .command("login")
21
+ .description("Authenticate using Cutline auth flow")
22
+ .option("--staging", "Use staging channel/package")
23
+ .action((opts) => {
24
+ const channel = resolveChannel(Boolean(opts.staging));
25
+ const args = ["login"];
26
+ if (channel === "staging")
27
+ args.push("--staging");
28
+ runCutlineCliWithEnv(args, channel, { CUTLINE_MCP_AUTH_SOURCE: "slopburn" });
29
+ });
30
+ program
31
+ .command("logout")
32
+ .description("Remove stored credentials")
33
+ .option("--staging", "Use staging channel/package")
34
+ .action((opts) => {
35
+ const channel = resolveChannel(Boolean(opts.staging));
36
+ const args = ["logout"];
37
+ if (channel === "staging")
38
+ args.push("--staging");
39
+ runCutlineCli(args, channel);
40
+ });
41
+ program
42
+ .command("status")
43
+ .description("Show authentication/subscription status")
44
+ .option("--staging", "Use staging channel/package")
45
+ .action((opts) => {
46
+ const channel = resolveChannel(Boolean(opts.staging));
47
+ const args = ["status"];
48
+ if (channel === "staging")
49
+ args.push("--staging");
50
+ runCutlineCli(args, channel);
51
+ });
52
+ program
53
+ .command("setup")
54
+ .description("Write dedicated SlopBurn MCP config and persist channel preference")
55
+ .option("--staging", "Use staging channel/package")
56
+ .option("--project-root <path>", "Project root for generated natural-language rules (default: cwd)")
57
+ .option("--test-working-directory <path>", "Directory to run verification tests from, relative to project root")
58
+ .option("--test-command <command>", "Command used for local verification rolls")
59
+ .option("--serve-now", "Immediately start SlopBurn MCP server after setup")
60
+ .option("--verbose", "Show expanded setup output")
61
+ .action(setupCommand);
62
+ program
63
+ .command("serve")
64
+ .description("Serve only the SlopBurn MCP server")
65
+ .option("--staging", "Use staging channel/package")
66
+ .option("--project-root <path>", "Project root containing .cutline/slopburn.json (default: cwd)")
67
+ .option("--http", "Expose over HTTP bridge")
68
+ .option("--host <host>", "HTTP bind host")
69
+ .option("--port <port>", "HTTP bind port")
70
+ .option("--path <path>", "HTTP MCP path")
71
+ .option("--verbose", "Print the SlopBurn stdio startup banner")
72
+ .action((opts) => {
73
+ const channel = resolveChannel(Boolean(opts.staging));
74
+ const args = ["serve", "slopburn"];
75
+ if (opts.http)
76
+ args.push("--http");
77
+ if (opts.host)
78
+ args.push("--host", String(opts.host));
79
+ if (opts.port)
80
+ args.push("--port", String(opts.port));
81
+ if (opts.path)
82
+ args.push("--path", String(opts.path));
83
+ const projectRoot = resolve(String(opts.projectRoot || process.cwd()));
84
+ runCutlineCliWithEnv(args, channel, {
85
+ SLOPBURN_PROJECT_ROOT: projectRoot,
86
+ SLOPBURN_QUIET_STARTUP: opts.verbose ? "0" : "1",
87
+ });
88
+ });
89
+ program
90
+ .command("bootstrap")
91
+ .description("Print first-run MCP bootstrap sequence")
92
+ .option("--project-root <path>", "Project root containing .cutline/slopburn.json (default: cwd)")
93
+ .option("--full", "Show the full MCP tool sequence and extra prompts")
94
+ .action(bootstrapCommand);
95
+ program
96
+ .command("event <type>")
97
+ .description("Render local terminal event banner or audio cue (title | level-up | game-over | encounter | victory)")
98
+ .option("--project-root <path>", "Project root containing .cutline/slopburn.json (default: cwd)")
99
+ .action(eventCommand);
100
+ program.parse();
@@ -0,0 +1,45 @@
1
+ import type { CliChannel } from "./runtime.js";
2
+ export type SlopburnSetupMode = "existing_project" | "new_character";
3
+ export type SlopburnCharacterClass = "fighter" | "wizard" | "rogue" | "paladin";
4
+ export interface SlopburnOnboardingConfig {
5
+ mode: SlopburnSetupMode;
6
+ projectRoot?: string;
7
+ projectName?: string;
8
+ productId?: string;
9
+ characterSlotId?: string;
10
+ characterName?: string;
11
+ characterClass?: SlopburnCharacterClass;
12
+ playStyle?: "immersive" | "direct";
13
+ }
14
+ export interface SlopburnCliConfig {
15
+ channel?: CliChannel;
16
+ onboarding?: SlopburnOnboardingConfig;
17
+ }
18
+ export interface SlopburnProjectGameConfig {
19
+ version?: 1;
20
+ onboarding?: SlopburnOnboardingConfig;
21
+ startup?: {
22
+ quiet?: boolean;
23
+ autoResume?: boolean;
24
+ };
25
+ verification?: {
26
+ testWorkingDirectory?: string;
27
+ testCommand?: string;
28
+ };
29
+ audio?: {
30
+ enabled?: boolean;
31
+ playerCommand?: string;
32
+ titleSound?: string;
33
+ encounterSound?: string;
34
+ victorySound?: string;
35
+ };
36
+ updatedAt?: string;
37
+ }
38
+ export declare function readSlopburnConfig(): SlopburnCliConfig;
39
+ export declare function writeSlopburnConfig(partial: SlopburnCliConfig): void;
40
+ export declare function resolveChannel(explicitStaging?: boolean): CliChannel;
41
+ export declare function getProjectGameConfigPath(projectRoot: string): string;
42
+ export declare function readProjectGameConfig(projectRoot: string): SlopburnProjectGameConfig;
43
+ export declare function writeProjectGameConfig(projectRoot: string, partial: SlopburnProjectGameConfig): void;
44
+ export declare function ensureSlopburnProjectGitignore(projectRoot: string): void;
45
+ export declare function resolveSavedOnboarding(projectRoot: string): SlopburnOnboardingConfig | null;
@@ -0,0 +1,98 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".slopburn");
5
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
6
+ const PROJECT_CONFIG_RELATIVE_PATH = join(".cutline", "slopburn.json");
7
+ const SLOPBURN_DUNGEON_GITIGNORE_ENTRY = ".cutline/slopburn/";
8
+ export function readSlopburnConfig() {
9
+ try {
10
+ if (!existsSync(CONFIG_PATH))
11
+ return {};
12
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
13
+ const parsed = JSON.parse(raw);
14
+ return parsed && typeof parsed === "object" ? parsed : {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ export function writeSlopburnConfig(partial) {
21
+ const existing = readSlopburnConfig();
22
+ const next = {
23
+ ...existing,
24
+ ...partial,
25
+ onboarding: partial.onboarding ?? existing.onboarding,
26
+ };
27
+ mkdirSync(CONFIG_DIR, { recursive: true });
28
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
29
+ }
30
+ export function resolveChannel(explicitStaging) {
31
+ if (explicitStaging)
32
+ return "staging";
33
+ const cfg = readSlopburnConfig();
34
+ return cfg.channel ?? "production";
35
+ }
36
+ export function getProjectGameConfigPath(projectRoot) {
37
+ return join(resolve(projectRoot), PROJECT_CONFIG_RELATIVE_PATH);
38
+ }
39
+ function readJsonFile(filePath) {
40
+ try {
41
+ if (!existsSync(filePath))
42
+ return null;
43
+ const raw = readFileSync(filePath, "utf-8");
44
+ const parsed = JSON.parse(raw);
45
+ return parsed && typeof parsed === "object" ? parsed : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function readProjectGameConfig(projectRoot) {
52
+ return readJsonFile(getProjectGameConfigPath(projectRoot)) ?? {};
53
+ }
54
+ export function writeProjectGameConfig(projectRoot, partial) {
55
+ const existing = readProjectGameConfig(projectRoot);
56
+ const next = {
57
+ ...existing,
58
+ ...partial,
59
+ onboarding: partial.onboarding ?? existing.onboarding,
60
+ startup: partial.startup ?? existing.startup,
61
+ verification: partial.verification ?? existing.verification,
62
+ audio: partial.audio ?? existing.audio,
63
+ version: 1,
64
+ updatedAt: new Date().toISOString(),
65
+ };
66
+ const configPath = getProjectGameConfigPath(projectRoot);
67
+ mkdirSync(join(configPath, ".."), { recursive: true });
68
+ writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`);
69
+ }
70
+ export function ensureSlopburnProjectGitignore(projectRoot) {
71
+ const ignorePath = join(resolve(projectRoot), ".gitignore");
72
+ const existing = existsSync(ignorePath) ? readFileSync(ignorePath, "utf-8") : "";
73
+ if (existing.split(/\r?\n/).includes(SLOPBURN_DUNGEON_GITIGNORE_ENTRY))
74
+ return;
75
+ const suffix = existing.endsWith("\n") || existing.length === 0 ? "" : "\n";
76
+ const block = `${suffix}# SlopBurn local dungeon state\n${SLOPBURN_DUNGEON_GITIGNORE_ENTRY}\n`;
77
+ writeFileSync(ignorePath, `${existing}${block}`);
78
+ }
79
+ export function resolveSavedOnboarding(projectRoot) {
80
+ const projectConfig = readProjectGameConfig(projectRoot);
81
+ if (projectConfig.onboarding?.mode) {
82
+ return {
83
+ ...projectConfig.onboarding,
84
+ projectRoot: resolve(projectConfig.onboarding.projectRoot ?? projectRoot),
85
+ };
86
+ }
87
+ const globalConfig = readSlopburnConfig();
88
+ const legacy = globalConfig.onboarding;
89
+ if (!legacy?.mode)
90
+ return null;
91
+ const legacyRoot = legacy.projectRoot ? resolve(legacy.projectRoot) : null;
92
+ if (legacyRoot && legacyRoot !== resolve(projectRoot))
93
+ return null;
94
+ return {
95
+ ...legacy,
96
+ projectRoot: resolve(projectRoot),
97
+ };
98
+ }
@@ -0,0 +1,4 @@
1
+ export type CliChannel = "production" | "staging";
2
+ export declare function packageForChannel(channel: CliChannel): string;
3
+ export declare function runCutlineCli(args: string[], channel: CliChannel): never;
4
+ export declare function runCutlineCliWithEnv(args: string[], channel: CliChannel, extraEnv: Record<string, string>): never;
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ export function packageForChannel(channel) {
6
+ return channel === "staging"
7
+ ? "@vibekiln/cutline-mcp-cli-staging"
8
+ : "@vibekiln/cutline-mcp-cli";
9
+ }
10
+ function runInstalledCutlineCli(args, channel, env) {
11
+ const pkg = packageForChannel(channel);
12
+ try {
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const packageRoot = resolve(__dirname, "..", "..");
16
+ const packageJsonPath = resolve(packageRoot, "node_modules", pkg, "package.json");
17
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
18
+ const binPath = typeof packageJson.bin === "string"
19
+ ? packageJson.bin
20
+ : packageJson.bin?.["cutline-mcp"];
21
+ if (!binPath)
22
+ return null;
23
+ const entry = resolve(packageRoot, "node_modules", pkg, binPath);
24
+ const result = spawnSync(process.execPath, [entry, ...args], {
25
+ stdio: "inherit",
26
+ env,
27
+ });
28
+ if (result.error)
29
+ return null;
30
+ return result.status ?? 0;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function runViaNpx(args, channel, env) {
37
+ const pkg = packageForChannel(channel);
38
+ const result = spawnSync("npx", ["-y", pkg, ...args], {
39
+ stdio: "inherit",
40
+ env,
41
+ });
42
+ if (result.error) {
43
+ console.error(result.error.message);
44
+ return 1;
45
+ }
46
+ return result.status ?? 0;
47
+ }
48
+ export function runCutlineCli(args, channel) {
49
+ const localStatus = runInstalledCutlineCli(args, channel, process.env);
50
+ const exitCode = localStatus ?? runViaNpx(args, channel, process.env);
51
+ process.exit(exitCode);
52
+ }
53
+ export function runCutlineCliWithEnv(args, channel, extraEnv) {
54
+ const mergedEnv = {
55
+ ...process.env,
56
+ ...extraEnv,
57
+ };
58
+ const localStatus = runInstalledCutlineCli(args, channel, mergedEnv);
59
+ const exitCode = localStatus ?? runViaNpx(args, channel, mergedEnv);
60
+ process.exit(exitCode);
61
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@vibekiln/slopburn-cli-staging",
3
+ "version": "0.1.0",
4
+ "description": "First-class SlopBurn CLI with dedicated onboarding and MCP config, powered by Cutline components.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "slopburn": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/**/*",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "start": "node dist/index.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {
21
+ "@vibekiln/cutline-mcp-cli": "^0.13.0",
22
+ "@vibekiln/cutline-mcp-cli-staging": "^0.5.0",
23
+ "chalk": "^4.1.2",
24
+ "commander": "^11.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20.0.0",
28
+ "typescript": "^5.3.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }