@werewolf-solutions/dev 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,124 @@
1
- # Temporary Holding Version
1
+ # @werewolf-solutions/dev
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect your machine, or your own AI harness, to [Werewolf Solutions](https://werewolf.solutions)
4
+ as a developer. You build website drafts for the businesses assigned to you. Drafts are
5
+ held for your review; only your **Accept** on the developer page sends one to the owner.
6
+
7
+ Needs Node 20 or newer. Nothing else to install: `npx` fetches it.
8
+
9
+ ## Connect your machine (runner token)
10
+
11
+ One command connects your machine. Leave it running.
12
+
13
+ 1. On the developer page, under **Website runner**, press **Create runner token**. It is shown
14
+ once. It only works for your website runs: it can check in, take your runs and upload drafts
15
+ held for your review, nothing else.
16
+ 2. Run:
17
+
18
+ ```sh
19
+ WEREWOLF_DEV_TOKEN=ws_sk_… npx @werewolf-solutions/dev runner --api https://staging.werewolf.solutions
20
+ ```
21
+
22
+ For production use `--api https://werewolf.solutions`.
23
+ 3. The developer page shows the machine as connected straight away, with what it offers: the
24
+ `claude` CLI, the `codex` CLI (once you've signed in) and Ollama (when `ollama serve` is
25
+ running), whichever it has.
26
+ 4. On a request, pick one with a model and effort, read the prompt and press **Start**. The
27
+ runner builds it with exactly that and uploads the draft held for your review. Only your
28
+ **Accept** sends it to the owner.
29
+
30
+ Ctrl+C stops it. A run in progress goes back to the queue, and the developer page shows the
31
+ machine as gone at once. You can connect more than one machine; each takes only runs for what
32
+ it offers.
33
+
34
+ | | |
35
+ |---|---|
36
+ | `--api <site>` | The site, with or without `/api/v1`. Or set `WEREWOLF_API_URL`. https only, except localhost. |
37
+ | `--exec "<command>"` | Offer any other CLI agent: it gets the prompt on stdin, the workspace as its working directory and screenshot paths in `WEREWOLF_IMAGES`, and prints `{"files": [...]}` on stdout. It shows as **Your command (name)**. |
38
+ | `--host <name>` | The name this machine shows on the developer page. Default: its hostname. |
39
+ | `AI_BUILDER_CLI_TIMEOUT_MS` | How long one model call may take. Default 600000 (10 minutes). |
40
+ | `OLLAMA_HOST`, `CODEX_HOME` | Where Ollama and Codex live, if not the defaults. |
41
+
42
+ The rules for a site that works online (the brief, the checklist, the output format, the
43
+ contact widget) come from the server with each run, so every agent builds to the same rules.
44
+
45
+ ### Optional: let your harness build, over MCP
46
+
47
+ The same token also works as an MCP server inside Claude Code, Codex, Cursor or any MCP
48
+ client. That connection offers the harness itself as well as the CLIs. Pick the harness on a
49
+ request, then ask it to "take the next website job".
50
+
51
+ ```sh
52
+ claude mcp add werewolf-dev --env WEREWOLF_DEV_TOKEN=ws_sk_… \
53
+ -- npx -y @werewolf-solutions/dev mcp --api https://staging.werewolf.solutions
54
+ ```
55
+
56
+ Other clients take `command: "npx"`, `args: ["-y", "@werewolf-solutions/dev", "mcp", "--api",
57
+ "<site>"]` and `env: { WEREWOLF_DEV_TOKEN: "ws_sk_…" }`. It lives only while the harness is
58
+ open.
59
+
60
+ | Tool | What it does |
61
+ |---|---|
62
+ | `list_website_requests` | Your open requests, with business, kind and run state. Read-only |
63
+ | `next_website_job` | Takes the next run you started for the harness: brief, checklist, screenshots |
64
+ | `submit_website_draft` | Uploads the files for a job, held for your review; returns the preview link |
65
+ | `report_website_failure` | Gives a job back with the reason; it is queued once more, then marked failed |
66
+
67
+ ## MCP server (business key)
68
+
69
+ Connects Claude Code (or any MCP client) to one business's site builder. Claude generates
70
+ website files itself and saves/publishes them through the platform: the business data
71
+ stays the single source of truth, and publishing mints a domain-locked publishable key
72
+ automatically.
73
+
74
+ ### Setup
75
+
76
+ You need a **secret API key** (`ws_sk_...`) for the business: dashboard →
77
+ Generate API Key → type "Secret", tied to the business.
78
+
79
+ Register with Claude Code:
80
+
81
+ ```sh
82
+ claude mcp add werewolf \
83
+ --env WEREWOLF_API_URL=http://localhost:3000/api/v1 \
84
+ --env WEREWOLF_API_KEY=ws_sk_... \
85
+ -- npx -y @werewolf-solutions/dev mcp
86
+ ```
87
+
88
+ For production use `WEREWOLF_API_URL=https://werewolf.solutions/api/v1`.
89
+
90
+ For a status-only PSM integration, set `WEREWOLF_MCP_PROFILE=psm-readonly`.
91
+ That profile registers only `get_psm_service_status`; site-builder read/write tools
92
+ are not advertised to the MCP client.
93
+
94
+ ### Tools
95
+
96
+ | Tool | What it does |
97
+ |---|---|
98
+ | `get_business` | Business profile/services/branding — the content source |
99
+ | `get_psm_service_status` | Read-only PSM cloud billing/storage readiness and aggregate usage |
100
+ | `list_projects` / `get_project` | Browse builder projects + current files |
101
+ | `create_project` | New website project; optional `sourceUrl` imports an existing site's content |
102
+ | `save_revision` | Save files Claude generated as the new draft revision |
103
+ | `generate_revision` | Ask the platform to generate server-side instead |
104
+ | `publish_project` | Go live; pass `domains` for tenant hosts |
105
+
106
+ ### Example session in Claude Code
107
+
108
+ > "Get my business info, then create a website project importing
109
+ > https://old-site.example.com, generate a modern single-page site from the
110
+ > imported content and business data, save it as a revision, and publish it
111
+ > with domains [mybusiness.werewolf.solutions]."
112
+
113
+ ### Notes
114
+
115
+ - Generated files must keep the `__WEREWOLF_PUBLISHABLE_KEY__` and
116
+ `__WEREWOLF_PLATFORM_URL__` placeholders in the widget snippet verbatim —
117
+ publishing substitutes the real values so keys never enter prompts.
118
+ - The secret key is scoped: it can only drive the builder for its own business.
119
+
120
+ ## Development
121
+
122
+ This package lives in `mcp/` in the Werewolf Solutions app repository. The server's tests
123
+ run the runner against a real server, and `npm test` here runs the package's own tests.
124
+ Releases: bump `version` in `package.json`, then push a tag `dev-v<version>`.
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * werewolf-dev: connect your machine or your own AI harness to Werewolf Solutions as a
4
+ * developer.
5
+ *
6
+ * werewolf-dev runner --api <site> build website drafts here with claude, codex, ollama or --exec
7
+ * werewolf-dev mcp --api <site> MCP server for Claude Code, Codex, Cursor and other harnesses
8
+ */
9
+ const path = require('path');
10
+ const { pathToFileURL } = require('url');
11
+ const { version } = require('../package.json');
12
+
13
+ const USAGE = `werewolf-dev ${version}
14
+
15
+ werewolf-dev runner --api <site> [--exec "<command>"] build website drafts on this machine
16
+ werewolf-dev mcp [--api <site>] MCP server for your own harness
17
+ werewolf-dev --version
18
+
19
+ Both sign in with WEREWOLF_DEV_TOKEN, your runner token from the developer page.
20
+ Run "werewolf-dev runner --help" for the runner options.`;
21
+
22
+ async function main(argv) {
23
+ const [command, ...rest] = argv;
24
+ if (command === '--version' || command === '-v') { console.log(version); return 0; }
25
+ if (command === 'runner') return require('../src/cli').runnerMain(rest);
26
+ if (command === 'mcp') {
27
+ const api = require('../src/cli').argValue(rest, '--api');
28
+ if (api) process.env.WEREWOLF_API_URL = api;
29
+ await import(pathToFileURL(path.join(__dirname, '..', 'werewolf-mcp.mjs')).href);
30
+ return null; // the MCP server keeps running on stdio
31
+ }
32
+ console.log(USAGE);
33
+ return command && command !== '--help' && command !== '-h' ? 1 : 0;
34
+ }
35
+
36
+ main(process.argv.slice(2)).then((code) => { if (code !== null) process.exitCode = code; }, (error) => { console.error(error); process.exitCode = 1; });
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@werewolf-solutions/dev",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Connect your machine or your own AI harness (Claude Code, Codex, Cursor, any MCP client) to Werewolf Solutions as a developer: build website drafts for the businesses assigned to you.",
5
+ "license": "UNLICENSED",
6
+ "homepage": "https://werewolf.solutions",
7
+ "keywords": ["werewolf-solutions", "website", "mcp", "claude", "codex", "agent"],
8
+ "main": "src/runner.js",
9
+ "bin": {
10
+ "werewolf-dev": "bin/werewolf-dev.js",
11
+ "werewolf-mcp": "werewolf-mcp.mjs"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "src/",
16
+ "werewolf-mcp.mjs",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test test/*.test.js"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.12.0",
27
+ "zod": "^3.24.0"
28
+ }
29
+ }
package/src/agents.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The coding agents the runner can drive: the `claude` CLI, the `codex` CLI, a local Ollama
3
+ * server, and any other CLI through --exec. Each run is one locked-down turn: the prompt goes
4
+ * in, text comes out. No dependencies beyond Node.
5
+ */
6
+ const fs = require('fs/promises');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { execFile, spawn } = require('child_process');
10
+ const { promisify } = require('util');
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const TIMEOUT_MS = () => parseInt(process.env.AI_BUILDER_CLI_TIMEOUT_MS || '600000', 10);
14
+ const MAX_OUTPUT = 32 * 1024 * 1024;
15
+ const codexHome = (env) => env.CODEX_HOME || path.join(os.homedir(), '.codex');
16
+ const ollamaHost = (env) => (env.OLLAMA_HOST ? (/^https?:\/\//.test(env.OLLAMA_HOST) ? env.OLLAMA_HOST : `http://${env.OLLAMA_HOST}`) : 'http://127.0.0.1:11434').replace(/\/$/, '');
17
+
18
+ const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
19
+ const CLAUDE_MODELS = [
20
+ { id: 'opus', label: 'Opus (newest)' },
21
+ { id: 'sonnet', label: 'Sonnet (newest)' },
22
+ { id: 'fable', label: 'Fable (newest)' },
23
+ { id: 'haiku', label: 'Haiku (newest)' },
24
+ { id: 'claude-opus-5-5', label: 'claude-opus-5-5' },
25
+ { id: 'claude-sonnet-5-5', label: 'claude-sonnet-5-5' },
26
+ { id: 'claude-fable-5-1', label: 'claude-fable-5-1' },
27
+ { id: 'claude-haiku-5-5', label: 'claude-haiku-5-5' },
28
+ ].map((model) => ({ ...model, efforts: CLAUDE_EFFORTS }));
29
+ const OLLAMA_EFFORTS = ['low', 'medium', 'high'];
30
+
31
+ const installed = async (command) => {
32
+ try { await execFileAsync(command, ['--version'], { timeout: 10000 }); return true; } catch { return false; }
33
+ };
34
+
35
+ // Runs a command with `input` on stdin; resolves with stdout. Kills it after the timeout.
36
+ function pipe(command, args, { cwd, input, env, shell = false, timeoutMs = TIMEOUT_MS() }) {
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(command, args, { cwd, env, shell, stdio: ['pipe', 'pipe', 'pipe'] });
39
+ let stdout = '';
40
+ let errors = '';
41
+ let timedOut = false;
42
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeoutMs);
43
+ child.stdout.on('data', (chunk) => { stdout += chunk; if (stdout.length > MAX_OUTPUT) child.kill('SIGTERM'); });
44
+ child.stderr.on('data', (chunk) => { errors = `${errors}${chunk}`.slice(-2000); });
45
+ child.on('error', (error) => { clearTimeout(timer); reject(error); });
46
+ child.on('close', (code) => {
47
+ clearTimeout(timer);
48
+ if (timedOut) return reject(new Error(`${shell ? command : path.basename(command)} timed out after ${Math.round(timeoutMs / 1000)}s`));
49
+ if (code === 0) return resolve(stdout);
50
+ return reject(new Error(`${shell ? command.split(/\s+/)[0] : path.basename(command)} exited with ${code}: ${errors.trim().split('\n').slice(-3).join(' ')}`));
51
+ });
52
+ child.stdin.on('error', () => {});
53
+ child.stdin.end(input || '');
54
+ });
55
+ }
56
+
57
+ // ── Claude CLI ─────────────────────────────────────────────────────────────────────────
58
+ // No tools, no MCP, no local instructions. Prompts with screenshots go over stdin as a
59
+ // stream-json user message, which is the CLI's only way to accept images.
60
+ function claudeArgs({ prompt, model, effort, streamed }) {
61
+ const args = streamed
62
+ ? ['--restricted', '--safe-mode', '--strict-mcp-config', '--tools', '', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--max-turns', '1']
63
+ : ['--restricted', '--safe-mode', '--strict-mcp-config', '--tools', '', '-p', prompt, '--output-format', 'json', '--max-turns', '1'];
64
+ if (model) args.push('--model', model);
65
+ if (effort) args.push('--effort', effort);
66
+ return args;
67
+ }
68
+
69
+ function parseClaude(stdout, streamed) {
70
+ if (!streamed) return JSON.parse(stdout);
71
+ const lines = String(stdout).split('\n').filter((line) => line.trim());
72
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
73
+ let event;
74
+ try { event = JSON.parse(lines[index]); } catch { continue; }
75
+ if (event.type === 'result') return event;
76
+ }
77
+ throw new Error('claude CLI returned no result');
78
+ }
79
+
80
+ async function runClaude({ prompt, images, cwd, model, effort }) {
81
+ const streamed = images.length > 0;
82
+ const input = streamed ? `${JSON.stringify({
83
+ type: 'user',
84
+ message: {
85
+ role: 'user',
86
+ content: [
87
+ { type: 'text', text: prompt },
88
+ ...images.map((image) => ({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.data } })),
89
+ ],
90
+ },
91
+ })}\n` : '';
92
+ const envelope = parseClaude(await pipe('claude', claudeArgs({ prompt, model, effort, streamed }), { cwd, input }), streamed);
93
+ if (envelope.is_error) throw new Error(`claude CLI error: ${String(envelope.result).slice(0, 300)}`);
94
+ return String(envelope.result || '');
95
+ }
96
+
97
+ // ── Codex CLI ──────────────────────────────────────────────────────────────────────────
98
+ // Codex keeps the models it can use, with their effort levels, in its own cache.
99
+ async function codexModels(env) {
100
+ const home = codexHome(env);
101
+ let models = [];
102
+ try {
103
+ const cache = JSON.parse(await fs.readFile(path.join(home, 'models_cache.json'), 'utf8'));
104
+ models = (cache.models || [])
105
+ .filter((model) => model && typeof model.slug === 'string')
106
+ .map((model) => ({
107
+ id: model.slug,
108
+ label: model.display_name || model.slug,
109
+ efforts: (model.supported_reasoning_levels || []).map((level) => level.effort).filter((effort) => typeof effort === 'string'),
110
+ defaultEffort: model.default_reasoning_level,
111
+ }));
112
+ } catch { /* no cache yet: the developer can still use Codex's own default */ }
113
+ let config = '';
114
+ try { config = await fs.readFile(path.join(home, 'config.toml'), 'utf8'); } catch { /* no config */ }
115
+ const setting = (key) => (config.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm')) || [])[1];
116
+ return { models, defaultModel: setting('model'), defaultEffort: setting('model_reasoning_effort') };
117
+ }
118
+
119
+ // Keep in step with codexArgs in the server's services/siteBuilder/agentRunners.js, which
120
+ // shows this command on the developer page (a server test compares the two).
121
+ function codexArgs({ model, effort, cwd, output, imageFiles = [] }) {
122
+ return [
123
+ 'exec', '--skip-git-repo-check', '--ephemeral', '--sandbox', 'read-only', '--ignore-rules', '--color', 'never',
124
+ '-C', cwd, '-m', model,
125
+ ...(effort ? ['-c', `model_reasoning_effort="${effort}"`] : []),
126
+ ...imageFiles.flatMap((file) => ['-i', file]),
127
+ '-o', output, '-',
128
+ ];
129
+ }
130
+
131
+ // Screenshots as files in a scratch folder, for CLIs that take image paths.
132
+ async function writeImages(scratch, images) {
133
+ const files = [];
134
+ for (const [index, image] of images.entries()) {
135
+ const file = path.join(scratch, `screenshot-${index + 1}.${(image.mediaType || 'image/png').split('/')[1] || 'png'}`);
136
+ await fs.writeFile(file, Buffer.from(image.data, 'base64'));
137
+ files.push(file);
138
+ }
139
+ return files;
140
+ }
141
+
142
+ async function runCodex({ prompt, images, cwd, model, effort }) {
143
+ const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'website-runner-codex-'));
144
+ try {
145
+ const output = path.join(scratch, 'reply.txt');
146
+ await pipe('codex', codexArgs({ model, effort, cwd, output, imageFiles: await writeImages(scratch, images) }), { cwd, input: prompt });
147
+ return await fs.readFile(output, 'utf8');
148
+ } finally {
149
+ await fs.rm(scratch, { recursive: true, force: true });
150
+ }
151
+ }
152
+
153
+ // ── Ollama ─────────────────────────────────────────────────────────────────────────────
154
+ async function ollamaModels(env) {
155
+ try {
156
+ const response = await fetch(`${ollamaHost(env)}/api/tags`, { signal: AbortSignal.timeout(1500) });
157
+ const data = await response.json();
158
+ const models = (data.models || []).map((model) => ({ id: model.name, label: model.name, efforts: OLLAMA_EFFORTS }));
159
+ return models.length ? { available: true, models } : { available: false, reason: 'Ollama has no models. Pull one with: ollama pull <model>', models };
160
+ } catch {
161
+ return { available: false, reason: 'Ollama is not running. Start it with: ollama serve', models: [] };
162
+ }
163
+ }
164
+
165
+ async function runOllama({ prompt, images, model, effort, env = process.env }) {
166
+ const response = await fetch(`${ollamaHost(env)}/api/chat`, {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({
170
+ model,
171
+ stream: false,
172
+ messages: [{ role: 'user', content: prompt, ...(images.length ? { images: images.map((image) => image.data) } : {}) }],
173
+ ...(effort ? { think: effort } : {}),
174
+ options: { num_ctx: parseInt(env.OLLAMA_NUM_CTX || '32768', 10) },
175
+ }),
176
+ signal: AbortSignal.timeout(TIMEOUT_MS()),
177
+ });
178
+ if (!response.ok) throw new Error(`ollama answered ${response.status}`);
179
+ const data = await response.json();
180
+ return String(data?.message?.content || '');
181
+ }
182
+
183
+ // ── --exec: any other CLI agent ────────────────────────────────────────────────────────
184
+ // The prompt goes in on stdin; the reply (the files JSON) comes back on stdout. Screenshots
185
+ // are written next to the workspace, their paths in WEREWOLF_IMAGES (one per line).
186
+ // The command's program name, e.g. "aider" for `aider --yes` or "node" for `"/usr/bin/node" agent.js`.
187
+ const execName = (command) => {
188
+ const line = String(command || '').trim();
189
+ const program = /^["']/.test(line) ? line.slice(1, line.indexOf(line[0], 1) > 0 ? line.indexOf(line[0], 1) : undefined) : line.split(/\s+/)[0];
190
+ return program.split(/[\\/]/).pop() || 'exec';
191
+ };
192
+
193
+ async function runExec({ command, prompt, images, cwd }) {
194
+ if (!command) throw new Error('This run is for --exec, but the runner was started without --exec');
195
+ const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'website-runner-exec-'));
196
+ try {
197
+ const imageFiles = await writeImages(scratch, images);
198
+ return await pipe(command, [], { cwd, input: prompt, shell: true, env: { ...process.env, WEREWOLF_IMAGES: imageFiles.join('\n') } });
199
+ } finally {
200
+ await fs.rm(scratch, { recursive: true, force: true });
201
+ }
202
+ }
203
+
204
+ /**
205
+ * What this machine can run, with a reason when a runner can't be used right now.
206
+ * With `exec`, the --exec command is offered too, as one model named after the command.
207
+ */
208
+ async function listRunners({ env = process.env, exec } = {}) {
209
+ const [claude, codex, codexInfo, ollama] = await Promise.all([installed('claude'), installed('codex'), codexModels(env), ollamaModels(env)]);
210
+ const runners = [
211
+ { id: 'claude-cli', label: 'Claude CLI', available: claude, ...(claude ? {} : { reason: 'The claude CLI is not installed or not on PATH.' }), models: CLAUDE_MODELS, defaultModel: 'opus', defaultEffort: 'high' },
212
+ { id: 'codex-cli', label: 'Codex CLI', available: codex && codexInfo.models.length > 0, ...(!codex ? { reason: 'The codex CLI is not installed or not on PATH.' } : codexInfo.models.length ? {} : { reason: 'Codex has no model list yet. Run codex once to sign in.' }), models: codexInfo.models, defaultModel: codexInfo.defaultModel, defaultEffort: codexInfo.defaultEffort },
213
+ { id: 'ollama', label: 'Ollama (local)', ...ollama, defaultModel: ollama.models[0]?.id, defaultEffort: '' },
214
+ ];
215
+ if (exec) runners.push({ id: 'exec', label: `Your command (${execName(exec)})`, available: true, models: [{ id: execName(exec), label: exec.slice(0, 120), efforts: [] }], defaultModel: execName(exec), defaultEffort: '' });
216
+ return runners;
217
+ }
218
+
219
+ /** One turn with the chosen runner; returns the model's reply text. */
220
+ async function runAgent({ provider, model, effort, prompt, images = [], cwd, exec }) {
221
+ if (provider === 'claude-cli') return runClaude({ prompt, images, cwd, model, effort });
222
+ if (provider === 'codex-cli') return runCodex({ prompt, images, cwd, model, effort });
223
+ if (provider === 'ollama') return runOllama({ prompt, images, model, effort });
224
+ if (provider === 'exec') return runExec({ command: exec, prompt, images, cwd });
225
+ throw new Error(`This runner can't run ${provider}`);
226
+ }
227
+
228
+ module.exports = { listRunners, runAgent, codexArgs, codexModels, claudeArgs, execName, pipe };
package/src/api.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Talking to a Werewolf Solutions server with a runner token. Plain fetch, no dependencies.
3
+ * Failed calls throw an Error with `response: { status, data }`, so callers can tell a refusal
4
+ * (the server answered) from a server that isn't reachable (no `response`).
5
+ */
6
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
7
+
8
+ // "https://staging.werewolf.solutions" or ".../api/v1" both work. Tokens only travel over
9
+ // HTTPS, except to this machine.
10
+ function apiBase(value) {
11
+ let url;
12
+ try { url = new URL(String(value || '')); } catch { throw new Error('Pass the site address, for example --api https://staging.werewolf.solutions'); }
13
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))) throw new Error('The API address must use https (http only for localhost)');
14
+ const trimmed = `${url.origin}${url.pathname}`.replace(/\/+$/, '');
15
+ return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`;
16
+ }
17
+
18
+ const problemOf = (error) => error.response?.data?.message || error.message || String(error);
19
+
20
+ function createClient({ api, token, fetch: fetchImpl = globalThis.fetch, timeoutMs = 120000 }) {
21
+ if (!token || !/^ws_sk_/.test(token)) throw new Error('Set WEREWOLF_DEV_TOKEN to your website runner token (create it on the developer page)');
22
+ const baseURL = apiBase(api);
23
+ const request = async (method, path, body, { timeout = timeoutMs } = {}) => {
24
+ const response = await fetchImpl(`${baseURL}${path}`, {
25
+ method,
26
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
27
+ body: body === undefined ? undefined : JSON.stringify(body),
28
+ signal: AbortSignal.timeout(timeout),
29
+ });
30
+ const data = await response.json().catch(() => ({}));
31
+ if (!response.ok) {
32
+ throw Object.assign(new Error(data.message || `${method} ${path} failed (${response.status})`), { response: { status: response.status, data } });
33
+ }
34
+ return { status: response.status, data };
35
+ };
36
+ return {
37
+ baseURL,
38
+ get: (path, options) => request('GET', path, undefined, options),
39
+ post: (path, body = {}, options) => request('POST', path, body, options),
40
+ };
41
+ }
42
+
43
+ module.exports = { apiBase, createClient, problemOf };
package/src/cli.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `werewolf-dev runner`: build website drafts for the businesses assigned to you, on this
3
+ * machine, for the server at --api.
4
+ */
5
+ const { createRemoteRunner } = require('./runner');
6
+ const { listRunners } = require('./agents');
7
+ const { buildDraft } = require('./draft');
8
+
9
+ const RUNNER_USAGE = `usage: WEREWOLF_DEV_TOKEN=ws_sk_… werewolf-dev runner --api <site> [--exec "<command>"] [--host <name>]
10
+
11
+ --api the site, e.g. https://staging.werewolf.solutions (or set WEREWOLF_API_URL)
12
+ --exec any other CLI agent: gets the prompt on stdin, prints the files JSON on stdout;
13
+ screenshot paths are in WEREWOLF_IMAGES, one per line
14
+ --host the name this machine shows on the developer page (default: its hostname)
15
+
16
+ Get the token on the developer page, under Website runner.`;
17
+
18
+ const log = (...parts) => console.log(`[website-runner ${new Date().toISOString().slice(11, 19)}]`, ...parts);
19
+
20
+ function argValue(argv, name) {
21
+ const index = argv.indexOf(name);
22
+ if (index > -1) return argv[index + 1];
23
+ const inline = argv.find((item) => item.startsWith(`${name}=`));
24
+ return inline ? inline.slice(name.length + 1) : undefined;
25
+ }
26
+
27
+ // Ctrl+C, closing the terminal or kill: run `cleanup` (at most 5s), then exit. A second
28
+ // Ctrl+C skips the cleanup. Child CLIs (claude, codex) share the terminal and get it too.
29
+ function onStop(cleanup) {
30
+ let stopping = false;
31
+ const stop = async () => {
32
+ if (stopping) process.exit(130);
33
+ stopping = true;
34
+ log('stopping');
35
+ const timeout = new Promise((resolve) => { setTimeout(resolve, 5000).unref(); });
36
+ await Promise.race([Promise.resolve().then(cleanup).catch((error) => log(`cleanup failed: ${error.message}`)), timeout]);
37
+ log('stopped');
38
+ process.exit(0);
39
+ };
40
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);
41
+ return () => stopping;
42
+ }
43
+
44
+ async function runnerMain(argv, env = process.env) {
45
+ if (argv.includes('--help') || argv.includes('-h')) { console.log(RUNNER_USAGE); return 0; }
46
+ // Full-site generation with the checklist takes longer than a quick revision.
47
+ env.AI_BUILDER_CLI_TIMEOUT_MS = env.AI_BUILDER_CLI_TIMEOUT_MS || '600000';
48
+ const exec = argValue(argv, '--exec');
49
+ let runner;
50
+ try {
51
+ runner = createRemoteRunner({
52
+ api: argValue(argv, '--api') || env.WEREWOLF_API_URL,
53
+ token: env.WEREWOLF_DEV_TOKEN,
54
+ exec,
55
+ ...(argValue(argv, '--host') ? { host: argValue(argv, '--host') } : {}),
56
+ listRunners: () => listRunners({ env, exec }),
57
+ buildDraft,
58
+ log,
59
+ });
60
+ } catch (error) {
61
+ console.error(`\n refused: ${error.message}\n\n${RUNNER_USAGE}\n`);
62
+ return 1;
63
+ }
64
+ const stopping = onStop(async () => {
65
+ if (runner.busy()) {
66
+ log('handing the current run back to the server');
67
+ await runner.release().catch((error) => log(`could not hand it back: ${error.message}`));
68
+ }
69
+ await runner.disconnect().catch(() => {});
70
+ });
71
+ try {
72
+ await runner.loop();
73
+ } catch (error) {
74
+ if (stopping()) await new Promise(() => {});
75
+ console.error(`\n stopped: ${error.message}\n`);
76
+ return 1;
77
+ }
78
+ return 0;
79
+ }
80
+
81
+ module.exports = { runnerMain, argValue, RUNNER_USAGE };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Reading a model's reply into website files. A copy of the two helpers in the server's
3
+ * services/siteBuilder/providers/contract.js, so the runner needs nothing from the server.
4
+ * The server checks every upload again, so these are a convenience, not the guard.
5
+ */
6
+ const MAX_FILES = 20;
7
+ const MAX_TOTAL_BYTES = 5 * 1024 * 1024;
8
+
9
+ const contentTypeFor = (path) => (path.endsWith('.html') ? 'text/html; charset=utf-8'
10
+ : path.endsWith('.css') ? 'text/css'
11
+ : path.endsWith('.xml') ? 'application/xml'
12
+ : 'text/plain; charset=utf-8');
13
+
14
+ const extractJson = (text) => {
15
+ const start = text.indexOf('{');
16
+ const end = text.lastIndexOf('}');
17
+ if (start === -1 || end <= start) throw new Error('No JSON object in model output');
18
+ return JSON.parse(text.slice(start, end + 1));
19
+ };
20
+
21
+ const sanitizeFiles = (rawFiles) => {
22
+ if (!Array.isArray(rawFiles) || rawFiles.length === 0) throw new Error('Model returned no files');
23
+ let total = 0;
24
+ const files = rawFiles.slice(0, MAX_FILES).map((file) => {
25
+ const path = String(file.path || '').replace(/^\/+/, '');
26
+ if (!path || path.includes('..') || path.startsWith('/') || !/^[\w./-]+$/.test(path)) {
27
+ throw new Error(`Unsafe file path from model: ${file.path}`);
28
+ }
29
+ const content = String(file.content || '');
30
+ total += Buffer.byteLength(content, 'utf8');
31
+ if (total > MAX_TOTAL_BYTES) throw new Error('Generated output too large');
32
+ return { path, contentType: contentTypeFor(path), content, size: Buffer.byteLength(content, 'utf8') };
33
+ });
34
+ if (!files.some((file) => file.path === 'index.html')) throw new Error('Model output is missing index.html');
35
+ return files;
36
+ };
37
+
38
+ module.exports = { extractJson, sanitizeFiles, contentTypeFor };