specdacular 0.12.0 → 0.13.1

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/bin/specd.js CHANGED
@@ -3,23 +3,22 @@
3
3
  // bin/specd.js — unified CLI entry point
4
4
  // Usage:
5
5
  // specd llm-init [--local] — install commands/agents/workflows
6
- // specd install-runner — install runner dependencies (express, ws, electron)
7
- // specd runner — launch Electron app
6
+ // specd install-runner — download Specd Runner app from GitHub Releases
7
+ // specd runner — launch Specd Runner app
8
8
  // specd runner register <path> — register a folder
9
9
  // specd runner unregister <id> — remove a project
10
10
  // specd runner projects — list projects
11
11
  // specd runner status — show task status
12
12
 
13
13
  import { resolve, join } from 'path';
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
15
- import { homedir, platform } from 'os';
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, createWriteStream, unlinkSync } from 'fs';
15
+ import { homedir, platform, arch } from 'os';
16
16
  import { execSync, spawn } from 'child_process';
17
+ import { get } from 'https';
17
18
 
18
19
  const args = process.argv.slice(2);
19
20
  const command = args[0];
20
21
 
21
- const runnerDir = join(import.meta.dirname, '..', 'runner');
22
-
23
22
  function getAppDataDir() {
24
23
  if (platform() === 'darwin') {
25
24
  return join(homedir(), 'Library', 'Application Support', 'Specd');
@@ -27,6 +26,27 @@ function getAppDataDir() {
27
26
  return join(homedir(), '.config', 'specd');
28
27
  }
29
28
 
29
+ function getAppInstallDir() {
30
+ if (platform() === 'darwin') {
31
+ return '/Applications';
32
+ }
33
+ return join(getAppDataDir(), 'app');
34
+ }
35
+
36
+ function getRunnerAppPath() {
37
+ if (platform() === 'darwin') {
38
+ return join(getAppInstallDir(), 'Specd Runner.app');
39
+ }
40
+ if (platform() === 'linux') {
41
+ return join(getAppInstallDir(), 'Specd Runner.AppImage');
42
+ }
43
+ return join(getAppInstallDir(), 'Specd Runner.exe');
44
+ }
45
+
46
+ function isRunnerInstalled() {
47
+ return existsSync(getRunnerAppPath());
48
+ }
49
+
30
50
  function getDbPath() {
31
51
  return join(getAppDataDir(), 'db.json');
32
52
  }
@@ -43,15 +63,139 @@ function saveDb(data) {
43
63
  writeFileSync(dbPath, JSON.stringify(data, null, 2));
44
64
  }
45
65
 
46
- function isRunnerInstalled() {
47
- return existsSync(join(runnerDir, 'node_modules', 'express'));
66
+ function fetchJson(url) {
67
+ return new Promise((resolve, reject) => {
68
+ get(url, { headers: { 'User-Agent': 'specd-cli' } }, (res) => {
69
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
70
+ return fetchJson(res.headers.location).then(resolve, reject);
71
+ }
72
+ let data = '';
73
+ res.on('data', (chunk) => data += chunk);
74
+ res.on('end', () => {
75
+ try { resolve(JSON.parse(data)); }
76
+ catch (e) { reject(new Error(`Failed to parse response: ${data.slice(0, 200)}`)); }
77
+ });
78
+ }).on('error', reject);
79
+ });
48
80
  }
49
81
 
50
- function requireRunner() {
51
- if (!isRunnerInstalled()) {
52
- console.error('Runner dependencies not installed. Run: specd install-runner');
82
+ function download(url, dest) {
83
+ return new Promise((resolve, reject) => {
84
+ const follow = (url) => {
85
+ get(url, { headers: { 'User-Agent': 'specd-cli' } }, (res) => {
86
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
87
+ return follow(res.headers.location);
88
+ }
89
+ if (res.statusCode !== 200) {
90
+ return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
91
+ }
92
+ const total = parseInt(res.headers['content-length'], 10);
93
+ let downloaded = 0;
94
+ const file = createWriteStream(dest);
95
+ res.on('data', (chunk) => {
96
+ downloaded += chunk.length;
97
+ if (total) {
98
+ const pct = Math.round(downloaded / total * 100);
99
+ process.stdout.write(`\rDownloading... ${pct}%`);
100
+ }
101
+ });
102
+ res.pipe(file);
103
+ file.on('finish', () => {
104
+ file.close();
105
+ console.log('\rDownload complete. ');
106
+ resolve();
107
+ });
108
+ }).on('error', reject);
109
+ };
110
+ follow(url);
111
+ });
112
+ }
113
+
114
+ async function installRunner() {
115
+ const os = platform();
116
+ const cpuArch = arch();
117
+
118
+ console.log(`Fetching latest Specd Runner release...`);
119
+
120
+ const release = await fetchJson(
121
+ 'https://api.github.com/repos/victorbalan/specdacular/releases/latest'
122
+ ).catch(() => null);
123
+
124
+ // Also check for runner-specific tags
125
+ const releases = await fetchJson(
126
+ 'https://api.github.com/repos/victorbalan/specdacular/releases?per_page=10'
127
+ ).catch(() => []);
128
+
129
+ const runnerRelease = releases.find(r => r.tag_name?.startsWith('runner-v')) || release;
130
+
131
+ if (!runnerRelease || !runnerRelease.assets?.length) {
132
+ console.error('No runner release found. The Specd Runner app has not been published yet.');
133
+ console.error('For local development, run from the repo: cd runner && npm install && npm run dev');
134
+ process.exit(1);
135
+ }
136
+
137
+ // Find the right asset for this platform
138
+ let assetName;
139
+ if (os === 'darwin') {
140
+ // Prefer dmg, fall back to zip
141
+ const dmg = runnerRelease.assets.find(a => a.name.endsWith('.dmg') && (
142
+ a.name.includes('arm64') ? cpuArch === 'arm64' : cpuArch === 'x64'
143
+ ));
144
+ const universalDmg = runnerRelease.assets.find(a => a.name.endsWith('.dmg') && a.name.includes('universal'));
145
+ const anyDmg = runnerRelease.assets.find(a => a.name.endsWith('.dmg'));
146
+ assetName = dmg || universalDmg || anyDmg;
147
+ } else if (os === 'linux') {
148
+ assetName = runnerRelease.assets.find(a => a.name.endsWith('.AppImage'));
149
+ } else if (os === 'win32') {
150
+ assetName = runnerRelease.assets.find(a => a.name.endsWith('.exe'));
151
+ }
152
+
153
+ if (!assetName) {
154
+ console.error(`No runner build found for ${os}/${cpuArch}.`);
155
+ console.error('Available assets:', runnerRelease.assets.map(a => a.name).join(', '));
53
156
  process.exit(1);
54
157
  }
158
+
159
+ const tmpDir = join(getAppDataDir(), 'tmp');
160
+ mkdirSync(tmpDir, { recursive: true });
161
+ const tmpFile = join(tmpDir, assetName.name);
162
+
163
+ console.log(`Downloading ${assetName.name} (${(assetName.size / 1024 / 1024).toFixed(1)} MB)...`);
164
+ await download(assetName.browser_download_url, tmpFile);
165
+
166
+ if (os === 'darwin') {
167
+ if (assetName.name.endsWith('.dmg')) {
168
+ console.log('Mounting DMG and copying app...');
169
+ const mountOutput = execSync(`hdiutil attach "${tmpFile}" -nobrowse -quiet`, { encoding: 'utf-8' });
170
+ const mountPoint = mountOutput.trim().split('\t').pop().trim();
171
+
172
+ try {
173
+ execSync(`cp -R "${mountPoint}/Specd Runner.app" "/Applications/"`, { stdio: 'pipe' });
174
+ } finally {
175
+ execSync(`hdiutil detach "${mountPoint}" -quiet`, { stdio: 'pipe' });
176
+ }
177
+ } else {
178
+ // zip
179
+ execSync(`unzip -o "${tmpFile}" -d "/Applications/"`, { stdio: 'pipe' });
180
+ }
181
+ console.log('Specd Runner installed to /Applications/Specd Runner.app');
182
+ } else if (os === 'linux') {
183
+ const installDir = getAppInstallDir();
184
+ mkdirSync(installDir, { recursive: true });
185
+ const dest = join(installDir, 'Specd Runner.AppImage');
186
+ execSync(`cp "${tmpFile}" "${dest}" && chmod +x "${dest}"`);
187
+ console.log(`Specd Runner installed to ${dest}`);
188
+ } else {
189
+ const installDir = getAppInstallDir();
190
+ mkdirSync(installDir, { recursive: true });
191
+ execSync(`cp "${tmpFile}" "${join(installDir, 'Specd Runner.exe')}"`);
192
+ console.log(`Specd Runner installed to ${installDir}`);
193
+ }
194
+
195
+ // Cleanup
196
+ try { unlinkSync(tmpFile); } catch {}
197
+
198
+ console.log('Done! Run: specd runner');
55
199
  }
56
200
 
57
201
  if (command === 'llm-init') {
@@ -61,19 +205,7 @@ if (command === 'llm-init') {
61
205
  await import(installScript);
62
206
 
63
207
  } else if (command === 'install-runner') {
64
- console.log('Installing runner dependencies...');
65
- execSync('npm install --omit=dev', { cwd: runnerDir, stdio: 'inherit' });
66
-
67
- // Also install renderer deps and build
68
- const rendererDir = join(runnerDir, 'renderer');
69
- if (existsSync(join(rendererDir, 'package.json'))) {
70
- console.log('Installing renderer dependencies...');
71
- execSync('npm install', { cwd: rendererDir, stdio: 'inherit' });
72
- console.log('Building renderer...');
73
- execSync('npm run build', { cwd: rendererDir, stdio: 'inherit' });
74
- }
75
-
76
- console.log('Runner installed. Run: specd runner');
208
+ await installRunner();
77
209
 
78
210
  } else if (command === 'runner') {
79
211
  const subcommand = args[1];
@@ -133,26 +265,26 @@ if (command === 'llm-init') {
133
265
  console.error('Runner not running. Start it with: specd runner');
134
266
  }
135
267
  } else {
136
- // No subcommand — launch Electron app
137
- requireRunner();
138
-
139
- const electronPath = join(runnerDir, 'node_modules', '.bin', 'electron');
140
- if (!existsSync(electronPath)) {
141
- console.error('Electron not installed. Run: specd install-runner');
268
+ // No subcommand — launch the app
269
+ if (!isRunnerInstalled()) {
270
+ console.error('Specd Runner not installed. Run: specd install-runner');
142
271
  process.exit(1);
143
272
  }
144
273
 
145
- const child = spawn(electronPath, [runnerDir], {
146
- detached: true,
147
- stdio: 'ignore',
148
- });
149
- child.unref();
274
+ const appPath = getRunnerAppPath();
275
+ if (platform() === 'darwin') {
276
+ spawn('open', ['-a', appPath], { detached: true, stdio: 'ignore' }).unref();
277
+ } else if (platform() === 'linux') {
278
+ spawn(appPath, [], { detached: true, stdio: 'ignore' }).unref();
279
+ } else {
280
+ spawn(appPath, [], { detached: true, stdio: 'ignore' }).unref();
281
+ }
150
282
  console.log('Specd Runner launched.');
151
283
  }
152
284
  } else {
153
285
  console.log('Usage:');
154
286
  console.log(' specd llm-init [--local] Install Claude Code commands/agents');
155
- console.log(' specd install-runner Install runner dependencies');
287
+ console.log(' specd install-runner Download and install the Specd Runner app');
156
288
  console.log(' specd runner Launch the Specd Runner app');
157
289
  console.log(' specd runner register <path> Register a project folder');
158
290
  console.log(' specd runner unregister <id> Remove a project');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specdacular",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Feature planning system for existing codebases. Map, understand, and plan features in large projects.",
5
5
  "bin": {
6
6
  "specd": "bin/specd.js"
@@ -11,10 +11,6 @@
11
11
  "agents",
12
12
  "specdacular",
13
13
  "hooks",
14
- "runner/main",
15
- "runner/renderer/dist",
16
- "runner/preload.js",
17
- "runner/package.json",
18
14
  "README.md"
19
15
  ],
20
16
  "keywords": [
@@ -1,39 +0,0 @@
1
- import { EventEmitter } from 'events';
2
-
3
- export class StreamParser extends EventEmitter {
4
- constructor() {
5
- super();
6
- this.inBlock = null;
7
- this.blockLines = [];
8
- }
9
-
10
- feed(line) {
11
- if (line.startsWith('```specd-status')) {
12
- this.inBlock = 'status';
13
- this.blockLines = [];
14
- return;
15
- }
16
- if (line.startsWith('```specd-result')) {
17
- this.inBlock = 'result';
18
- this.blockLines = [];
19
- return;
20
- }
21
- if (line === '```' && this.inBlock) {
22
- const content = this.blockLines.join('\n');
23
- try {
24
- const parsed = JSON.parse(content);
25
- this.emit(this.inBlock, parsed);
26
- } catch (err) {
27
- this.emit('error', err);
28
- }
29
- this.inBlock = null;
30
- this.blockLines = [];
31
- return;
32
- }
33
- if (this.inBlock) {
34
- this.blockLines.push(line);
35
- } else {
36
- this.emit('output', line);
37
- }
38
- }
39
- }
@@ -1,137 +0,0 @@
1
- import { spawn } from 'child_process';
2
- import { EventEmitter } from 'events';
3
- import { createWriteStream } from 'fs';
4
- import { StreamParser } from './parser.js';
5
-
6
- export class AgentRunner extends EventEmitter {
7
- constructor({ cmd, input_mode, output_format, system_prompt, timeout, stuck_timeout }) {
8
- super();
9
- this.cmd = cmd;
10
- this.inputMode = input_mode || 'stdin';
11
- this.outputFormat = output_format || 'stream_json';
12
- this.systemPrompt = system_prompt || '';
13
- this.timeout = (timeout || 3600) * 1000;
14
- this.stuckTimeout = (stuck_timeout || 1800) * 1000;
15
- }
16
-
17
- async run(prompt, { cwd, logPath } = {}) {
18
- return new Promise((resolve, reject) => {
19
- const fullPrompt = this.systemPrompt ? `${this.systemPrompt}\n\n${prompt}` : prompt;
20
- const args = this.cmd.split(' ').slice(1);
21
- const bin = this.cmd.split(' ')[0];
22
-
23
- const proc = spawn(bin, args, {
24
- cwd,
25
- shell: true,
26
- stdio: ['pipe', 'pipe', 'pipe'],
27
- env: { ...process.env },
28
- });
29
-
30
- const logStream = logPath ? createWriteStream(logPath, { flags: 'a' }) : null;
31
- let lastOutputAt = Date.now();
32
- let result = null;
33
-
34
- const parser = new StreamParser();
35
- parser.on('status', (s) => {
36
- lastOutputAt = Date.now();
37
- this.emit('status', s);
38
- });
39
- parser.on('result', (r) => {
40
- lastOutputAt = Date.now();
41
- result = r;
42
- this.emit('result', r);
43
- });
44
- parser.on('output', (line) => {
45
- lastOutputAt = Date.now();
46
- this.emit('output', line);
47
- });
48
-
49
- const handleLine = (line) => {
50
- if (logStream) logStream.write(line + '\n');
51
-
52
- if (this.outputFormat === 'stream_json') {
53
- try {
54
- const event = JSON.parse(line);
55
- if (event.type === 'assistant' && event.message?.content) {
56
- for (const block of event.message.content) {
57
- if (block.type === 'text') {
58
- for (const textLine of block.text.split('\n')) {
59
- parser.feed(textLine);
60
- }
61
- }
62
- }
63
- } else if (event.type === 'result' && event.result) {
64
- for (const block of event.result) {
65
- if (block.type === 'text') {
66
- for (const textLine of block.text.split('\n')) {
67
- parser.feed(textLine);
68
- }
69
- }
70
- }
71
- }
72
- } catch {
73
- parser.feed(line);
74
- }
75
- } else {
76
- parser.feed(line);
77
- }
78
- };
79
-
80
- let stdout = '';
81
- proc.stdout.on('data', (chunk) => {
82
- stdout += chunk.toString();
83
- const lines = stdout.split('\n');
84
- stdout = lines.pop();
85
- for (const line of lines) {
86
- if (line.trim()) handleLine(line.trim());
87
- }
88
- });
89
-
90
- proc.stderr.on('data', (chunk) => {
91
- if (logStream) logStream.write(`[stderr] ${chunk}`);
92
- });
93
-
94
- if (this.inputMode === 'stdin') {
95
- proc.stdin.write(fullPrompt);
96
- proc.stdin.end();
97
- }
98
-
99
- // Global timeout
100
- const globalTimer = setTimeout(() => {
101
- proc.kill('SIGTERM');
102
- setTimeout(() => proc.kill('SIGKILL'), 5000);
103
- }, this.timeout);
104
-
105
- // Stuck detection
106
- const stuckCheck = setInterval(() => {
107
- if (Date.now() - lastOutputAt > this.stuckTimeout) {
108
- this.emit('error', new Error('Agent stuck — no output'));
109
- proc.kill('SIGTERM');
110
- setTimeout(() => proc.kill('SIGKILL'), 5000);
111
- }
112
- }, 30000);
113
-
114
- proc.on('close', (code) => {
115
- clearTimeout(globalTimer);
116
- clearInterval(stuckCheck);
117
- if (logStream) logStream.end();
118
- if (stdout.trim()) handleLine(stdout.trim());
119
-
120
- if (result) {
121
- resolve(result);
122
- } else if (code === 0) {
123
- resolve({ status: 'success', summary: 'Agent completed without explicit result' });
124
- } else {
125
- resolve({ status: 'failure', summary: `Agent exited with code ${code}` });
126
- }
127
- });
128
-
129
- proc.on('error', (err) => {
130
- clearTimeout(globalTimer);
131
- clearInterval(stuckCheck);
132
- if (logStream) logStream.end();
133
- reject(err);
134
- });
135
- });
136
- }
137
- }
@@ -1,16 +0,0 @@
1
- export function resolveTemplate(template, variables) {
2
- return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
3
- const value = path.trim().split('.').reduce((obj, key) => obj?.[key], variables);
4
- return value !== undefined ? String(value) : match;
5
- });
6
- }
7
-
8
- export function buildTemplateContext(task, stage, pipeline, paths) {
9
- return {
10
- task: { id: task.id, name: task.name, spec: task.spec || '' },
11
- stage: { name: stage.stage, index: stage.index, total: stage.total },
12
- pipeline: { name: pipeline.name },
13
- status_file: paths?.statusJson || '',
14
- log_dir: paths?.logsDir || '',
15
- };
16
- }
@@ -1,69 +0,0 @@
1
- import { mkdirSync, writeFileSync, existsSync } from 'fs';
2
- import { join } from 'path';
3
-
4
- const DEFAULT_CONFIG = {
5
- server: { port: 3700 },
6
- notifications: { telegram: { enabled: false } },
7
- defaults: {
8
- pipeline: 'default',
9
- failure_policy: 'skip',
10
- timeout: 3600,
11
- stuck_timeout: 1800,
12
- max_parallel: 1,
13
- },
14
- };
15
-
16
- const DEFAULT_AGENTS = {
17
- 'claude-planner': {
18
- cmd: 'claude -p --dangerously-skip-permissions',
19
- input_mode: 'stdin',
20
- output_format: 'stream_json',
21
- system_prompt: 'You are a feature planner working on: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nResearch the codebase thoroughly, then create a detailed implementation plan.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
22
- },
23
- 'claude-implementer': {
24
- cmd: 'claude -p --dangerously-skip-permissions',
25
- input_mode: 'stdin',
26
- output_format: 'stream_json',
27
- system_prompt: 'You are an implementer working on: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nImplement the plan from the previous stage. Write clean, tested code.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
28
- },
29
- 'claude-reviewer': {
30
- cmd: 'claude -p --dangerously-skip-permissions',
31
- input_mode: 'stdin',
32
- output_format: 'stream_json',
33
- system_prompt: 'You are a code reviewer for: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nReview the implementation from the previous stage. Check for bugs, style, tests.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
34
- },
35
- };
36
-
37
- const DEFAULT_PIPELINE = {
38
- name: 'default',
39
- stages: [
40
- { stage: 'plan', agent: 'claude-planner', critical: true },
41
- { stage: 'implement', agent: 'claude-implementer', critical: true },
42
- { stage: 'review', agent: 'claude-reviewer', on_fail: 'retry', max_retries: 2 },
43
- ],
44
- };
45
-
46
- function writeIfMissing(filePath, data) {
47
- if (!existsSync(filePath)) {
48
- writeFileSync(filePath, JSON.stringify(data, null, 2));
49
- }
50
- }
51
-
52
- export async function bootstrap(paths) {
53
- // Create directories
54
- mkdirSync(paths.agentTemplatesDir, { recursive: true });
55
- mkdirSync(paths.pipelineTemplatesDir, { recursive: true });
56
- mkdirSync(paths.projectsDir, { recursive: true });
57
-
58
- // Write default files
59
- writeIfMissing(paths.db, { projects: [] });
60
- writeIfMissing(paths.config, DEFAULT_CONFIG);
61
-
62
- // Write default agent templates
63
- for (const [name, agent] of Object.entries(DEFAULT_AGENTS)) {
64
- writeIfMissing(join(paths.agentTemplatesDir, `${name}.json`), agent);
65
- }
66
-
67
- // Write default pipeline template
68
- writeIfMissing(join(paths.pipelineTemplatesDir, 'default.json'), DEFAULT_PIPELINE);
69
- }
package/runner/main/db.js DELETED
@@ -1,45 +0,0 @@
1
- import { readFileSync, writeFileSync } from 'fs';
2
- import { randomUUID } from 'crypto';
3
-
4
- export class ProjectDB {
5
- constructor(dbPath) {
6
- this.dbPath = dbPath;
7
- this.data = JSON.parse(readFileSync(dbPath, 'utf-8'));
8
- }
9
-
10
- register(name, folderPath) {
11
- const project = {
12
- id: randomUUID().slice(0, 8),
13
- name,
14
- path: folderPath,
15
- active: true,
16
- registeredAt: new Date().toISOString(),
17
- };
18
- this.data.projects.push(project);
19
- this._save();
20
- return project;
21
- }
22
-
23
- unregister(id) {
24
- this.data.projects = this.data.projects.filter(p => p.id !== id);
25
- this._save();
26
- }
27
-
28
- get(id) {
29
- return this.data.projects.find(p => p.id === id) || null;
30
- }
31
-
32
- findByPath(folderPath) {
33
- return this.data.projects.find(p =>
34
- folderPath === p.path || folderPath.startsWith(p.path + '/')
35
- ) || null;
36
- }
37
-
38
- list() {
39
- return this.data.projects;
40
- }
41
-
42
- _save() {
43
- writeFileSync(this.dbPath, JSON.stringify(this.data, null, 2));
44
- }
45
- }