syntropic 0.7.3 → 0.8.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/syntropic.js CHANGED
@@ -19,6 +19,11 @@ const COMMANDS = {
19
19
  health: () => require('../commands/health'),
20
20
  telemetry: () => require('../commands/telemetry'),
21
21
  report: () => require('../commands/report'),
22
+ login: () => require('../commands/login'),
23
+ logout: () => require('../commands/logout'),
24
+ whoami: () => require('../commands/whoami'),
25
+ analyse: () => require('../commands/analyse'),
26
+ analyze: () => require('../commands/analyse'), // US spelling alias
22
27
  };
23
28
 
24
29
  // Version flag
@@ -36,19 +41,21 @@ if (!command || args.includes('--help') || args.includes('-h')) {
36
41
  Usage:
37
42
  syntropic init [project-name] Scaffold a new project with the Syntropic pipeline
38
43
  syntropic add <tool> [tool...] Add support for another AI tool
44
+ syntropic analyse Analyse your product using 8 philosophy lenses
45
+ syntropic login Sign in to Syntropic (required for analyse)
46
+ syntropic logout Sign out
47
+ syntropic whoami Show current auth status
39
48
  syntropic health Run a local health check
40
- syntropic telemetry [cmd] Manage anonymous PRISM telemetry (enable/disable/status)
41
- syntropic report [flags] Submit an anonymous PRISM cycle report
49
+ syntropic telemetry [cmd] Manage pseudonymised PRISM telemetry (enable/disable/status)
50
+ syntropic report [flags] Submit a PRISM cycle report
42
51
  syntropic --version Show version
43
52
  syntropic --help Show this help
44
53
 
45
54
  What you get:
46
55
  - Instruction files for Claude Code, Cursor, Windsurf, GitHub Copilot, and/or OpenAI Codex
47
- - SKILL.md for automatic discovery by Claude Code and Codex
48
- - Evergreen Rules (EG1-EG9) — a disciplined dev pipeline
49
- - Generic agents: dev, qa, research, plan, devops, security (Claude Code)
50
- - Daily health check workflow with auto-remediation
56
+ - Evergreen Rules (EG1-EG13) a disciplined dev pipeline
51
57
  - PRISM efficiency tracking methodology
58
+ - Market analysis via 8 philosophy lenses (syntropic analyse)
52
59
 
53
60
  Flags (init):
54
61
  --tools claude,cursor,windsurf,copilot,codex Select AI tools (default: all)
@@ -58,6 +65,12 @@ if (!command || args.includes('--help') || args.includes('-h')) {
58
65
  --prod-url / Production page path
59
66
  --yes Skip interactive prompts
60
67
 
68
+ Flags (analyse):
69
+ --focus "question" Focus the analysis on a specific question
70
+ --context "extra info" Provide additional business context
71
+ --dry-run Output product profile without sending
72
+ --yes Skip confirmation prompts
73
+
61
74
  Learn more: https://www.syntropicworks.com
62
75
  `);
63
76
  process.exit(0);
@@ -0,0 +1,536 @@
1
+ /**
2
+ * syntropic analyse
3
+ *
4
+ * Reads local codebase, generates a product profile, fetches analysis
5
+ * prompts from the server, and outputs everything for the user's AI
6
+ * coding tool to run.
7
+ *
8
+ * Usage:
9
+ * syntropic analyse # Analyse current directory
10
+ * syntropic analyse --focus "question" # With specific focus
11
+ * syntropic analyse --context "extra info" # Additional context
12
+ * syntropic analyse --dry-run # Output profile only, don't fetch
13
+ * syntropic analyse --yes # Skip confirmation
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const https = require('https');
19
+ const readline = require('readline');
20
+ const { getAuth, isAuthExpired } = require('./config-utils');
21
+
22
+ const API_BASE = 'https://www.syntropicworks.com';
23
+
24
+ // ── Codebase Reading ──────────────────────────────────────
25
+
26
+ const IGNORE_DIRS = new Set([
27
+ 'node_modules', '.git', '.next', 'dist', 'build', '.vercel',
28
+ 'vendor', '__pycache__', '.cache', 'coverage', '.turbo',
29
+ '.svelte-kit', '.nuxt', '.output', 'target', 'out',
30
+ ]);
31
+
32
+ const IGNORE_FILES = new Set([
33
+ '.env', '.env.local', '.env.production', '.env.development',
34
+ '.DS_Store', 'Thumbs.db',
35
+ ]);
36
+
37
+ function readFileSafe(filePath, maxBytes = 10000) {
38
+ try {
39
+ const stat = fs.statSync(filePath);
40
+ if (stat.size > maxBytes) return null;
41
+ return fs.readFileSync(filePath, 'utf8');
42
+ } catch (_) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function countFiles(dir, ext, depth = 0) {
48
+ if (depth > 4) return 0;
49
+ let count = 0;
50
+ try {
51
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
52
+ if (IGNORE_DIRS.has(entry.name)) continue;
53
+ if (entry.isDirectory()) {
54
+ count += countFiles(path.join(dir, entry.name), ext, depth + 1);
55
+ } else if (!ext || entry.name.endsWith(ext)) {
56
+ count++;
57
+ }
58
+ }
59
+ } catch (_) {}
60
+ return count;
61
+ }
62
+
63
+ function listDir(dir, depth = 0, maxDepth = 2) {
64
+ if (depth > maxDepth) return [];
65
+ const entries = [];
66
+ try {
67
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
68
+ if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
69
+ if (entry.isDirectory()) {
70
+ entries.push(entry.name + '/');
71
+ if (depth < maxDepth) {
72
+ const children = listDir(path.join(dir, entry.name), depth + 1, maxDepth);
73
+ entries.push(...children.map(c => ' ' + c));
74
+ }
75
+ } else {
76
+ entries.push(entry.name);
77
+ }
78
+ }
79
+ } catch (_) {}
80
+ return entries;
81
+ }
82
+
83
+ function detectRoutes(targetDir) {
84
+ const routes = [];
85
+ const routeDirs = ['pages', 'app', 'src/routes', 'src/pages', 'routes'];
86
+
87
+ for (const routeDir of routeDirs) {
88
+ const fullPath = path.join(targetDir, routeDir);
89
+ if (!fs.existsSync(fullPath)) continue;
90
+
91
+ const files = listDir(fullPath, 0, 3);
92
+ for (const f of files) {
93
+ const clean = f.trim();
94
+ if (clean.endsWith('.js') || clean.endsWith('.tsx') || clean.endsWith('.ts') ||
95
+ clean.endsWith('.vue') || clean.endsWith('.svelte') || clean.endsWith('/')) {
96
+ const isApi = clean.includes('api/') || clean.includes('api\\');
97
+ routes.push({
98
+ path: clean.replace(/\.(js|tsx|ts|vue|svelte)$/, ''),
99
+ type: isApi ? 'api' : 'page',
100
+ });
101
+ }
102
+ }
103
+ break; // Use first found route directory
104
+ }
105
+ return routes;
106
+ }
107
+
108
+ function detectDataModel(targetDir) {
109
+ const entities = [];
110
+
111
+ // Prisma schema
112
+ const prismaPath = path.join(targetDir, 'prisma', 'schema.prisma');
113
+ const prismaContent = readFileSafe(prismaPath, 50000);
114
+ if (prismaContent) {
115
+ const models = prismaContent.match(/model\s+(\w+)\s*\{([^}]+)\}/g);
116
+ if (models) {
117
+ for (const model of models) {
118
+ const nameMatch = model.match(/model\s+(\w+)/);
119
+ const fields = model.match(/^\s+(\w+)\s/gm);
120
+ if (nameMatch) {
121
+ entities.push({
122
+ entity: nameMatch[1],
123
+ fields: (fields || []).map(f => f.trim()).filter(f => f && f !== 'model'),
124
+ });
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // SQL migrations — extract CREATE TABLE
131
+ const migrationDirs = ['supabase/migrations', 'migrations', 'db/migrations'];
132
+ for (const migDir of migrationDirs) {
133
+ const fullPath = path.join(targetDir, migDir);
134
+ if (!fs.existsSync(fullPath)) continue;
135
+
136
+ try {
137
+ const files = fs.readdirSync(fullPath).filter(f => f.endsWith('.sql')).sort();
138
+ for (const file of files.slice(-5)) { // Last 5 migrations
139
+ const content = readFileSafe(path.join(fullPath, file), 50000);
140
+ if (!content) continue;
141
+ const tables = content.match(/CREATE TABLE[^(]+\(([^;]+)\)/gi);
142
+ if (tables) {
143
+ for (const table of tables) {
144
+ const nameMatch = table.match(/CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)/i);
145
+ if (nameMatch && !entities.find(e => e.entity === nameMatch[1])) {
146
+ entities.push({ entity: nameMatch[1], fields: ['(from SQL migration)'] });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ } catch (_) {}
152
+ break;
153
+ }
154
+
155
+ return entities;
156
+ }
157
+
158
+ function readProductProfile(targetDir) {
159
+ const profile = {
160
+ profile_version: '1.0',
161
+ project_name: path.basename(targetDir),
162
+ description: '',
163
+ tech_stack: {},
164
+ features: [],
165
+ data_model: [],
166
+ dependencies_count: 0,
167
+ key_dependencies: [],
168
+ infrastructure: {},
169
+ file_structure_summary: {},
170
+ };
171
+
172
+ const found = [];
173
+
174
+ // package.json
175
+ const pkgPath = path.join(targetDir, 'package.json');
176
+ const pkg = readFileSafe(pkgPath);
177
+ if (pkg) {
178
+ try {
179
+ const parsed = JSON.parse(pkg);
180
+ profile.project_name = parsed.name || profile.project_name;
181
+ profile.description = parsed.description || '';
182
+
183
+ const allDeps = {
184
+ ...(parsed.dependencies || {}),
185
+ ...(parsed.devDependencies || {}),
186
+ };
187
+ profile.dependencies_count = Object.keys(allDeps).length;
188
+
189
+ // Detect framework
190
+ if (allDeps.next) profile.tech_stack.framework = `Next.js ${allDeps.next}`;
191
+ else if (allDeps.nuxt) profile.tech_stack.framework = `Nuxt ${allDeps.nuxt}`;
192
+ else if (allDeps.react) profile.tech_stack.framework = `React ${allDeps.react}`;
193
+ else if (allDeps.vue) profile.tech_stack.framework = `Vue ${allDeps.vue}`;
194
+ else if (allDeps.svelte || allDeps['@sveltejs/kit']) profile.tech_stack.framework = 'SvelteKit';
195
+ else if (allDeps.express) profile.tech_stack.framework = 'Express';
196
+
197
+ // Detect language
198
+ if (allDeps.typescript) profile.tech_stack.language = 'TypeScript';
199
+ else profile.tech_stack.language = 'JavaScript';
200
+
201
+ // Key deps (top 10 by name recognition)
202
+ const importantDeps = Object.keys(allDeps)
203
+ .filter(d => !d.startsWith('@types/') && !d.startsWith('eslint'))
204
+ .slice(0, 10);
205
+ profile.key_dependencies = importantDeps;
206
+
207
+ found.push(`package.json — ${profile.tech_stack.framework || 'Node.js'}, ${profile.dependencies_count} dependencies`);
208
+ } catch (_) {
209
+ found.push('package.json — found (parse error)');
210
+ }
211
+ }
212
+
213
+ // Python: requirements.txt or pyproject.toml
214
+ const reqPath = path.join(targetDir, 'requirements.txt');
215
+ if (fs.existsSync(reqPath)) {
216
+ const content = readFileSafe(reqPath);
217
+ if (content) {
218
+ const deps = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
219
+ profile.dependencies_count = deps.length;
220
+ profile.tech_stack.language = 'Python';
221
+ profile.key_dependencies = deps.slice(0, 10).map(d => d.split('==')[0].trim());
222
+ found.push(`requirements.txt — Python, ${deps.length} dependencies`);
223
+ }
224
+ }
225
+
226
+ // Go: go.mod
227
+ const goModPath = path.join(targetDir, 'go.mod');
228
+ if (fs.existsSync(goModPath)) {
229
+ profile.tech_stack.language = 'Go';
230
+ found.push('go.mod — Go');
231
+ }
232
+
233
+ // Rust: Cargo.toml
234
+ const cargoPath = path.join(targetDir, 'Cargo.toml');
235
+ if (fs.existsSync(cargoPath)) {
236
+ profile.tech_stack.language = 'Rust';
237
+ found.push('Cargo.toml — Rust');
238
+ }
239
+
240
+ // README.md
241
+ const readmePath = path.join(targetDir, 'README.md');
242
+ const readme = readFileSafe(readmePath);
243
+ if (readme) {
244
+ // Extract first paragraph as description if not set
245
+ if (!profile.description) {
246
+ const lines = readme.split('\n').filter(l => l.trim() && !l.startsWith('#'));
247
+ profile.description = lines.slice(0, 3).join(' ').trim().substring(0, 500);
248
+ }
249
+ found.push('README.md — product description');
250
+ }
251
+
252
+ // Routes
253
+ const routes = detectRoutes(targetDir);
254
+ if (routes.length > 0) {
255
+ const pages = routes.filter(r => r.type === 'page').length;
256
+ const apis = routes.filter(r => r.type === 'api').length;
257
+ profile.features = routes.slice(0, 30); // Cap at 30
258
+ profile.file_structure_summary.pages = pages;
259
+ profile.file_structure_summary.api_routes = apis;
260
+ found.push(`routes — ${pages} pages, ${apis} API routes`);
261
+ }
262
+
263
+ // Data model
264
+ const dataModel = detectDataModel(targetDir);
265
+ if (dataModel.length > 0) {
266
+ profile.data_model = dataModel;
267
+ found.push(`schema — ${dataModel.length} entities`);
268
+ }
269
+
270
+ // Infrastructure
271
+ if (fs.existsSync(path.join(targetDir, 'vercel.json'))) {
272
+ profile.infrastructure.hosting = 'Vercel';
273
+ found.push('vercel.json — Vercel hosting');
274
+ }
275
+ if (fs.existsSync(path.join(targetDir, 'netlify.toml'))) {
276
+ profile.infrastructure.hosting = 'Netlify';
277
+ }
278
+ if (fs.existsSync(path.join(targetDir, 'docker-compose.yml')) || fs.existsSync(path.join(targetDir, 'Dockerfile'))) {
279
+ profile.infrastructure.containers = 'Docker';
280
+ found.push('Docker — containerised');
281
+ }
282
+ if (fs.existsSync(path.join(targetDir, '.github', 'workflows'))) {
283
+ profile.infrastructure.ci = 'GitHub Actions';
284
+ }
285
+
286
+ // Database detection
287
+ if (fs.existsSync(path.join(targetDir, 'prisma'))) {
288
+ profile.tech_stack.database = 'Prisma';
289
+ }
290
+ if (fs.existsSync(path.join(targetDir, 'supabase'))) {
291
+ profile.infrastructure.database = 'Supabase';
292
+ }
293
+
294
+ // Component count
295
+ const componentDirs = ['components', 'src/components', 'app/components'];
296
+ for (const dir of componentDirs) {
297
+ const fullPath = path.join(targetDir, dir);
298
+ if (fs.existsSync(fullPath)) {
299
+ const count = countFiles(fullPath, null);
300
+ profile.file_structure_summary.components = count;
301
+ break;
302
+ }
303
+ }
304
+
305
+ // Lib/utils count
306
+ const libDirs = ['lib', 'src/lib', 'src/utils', 'utils'];
307
+ for (const dir of libDirs) {
308
+ const fullPath = path.join(targetDir, dir);
309
+ if (fs.existsSync(fullPath)) {
310
+ const count = countFiles(fullPath, null);
311
+ profile.file_structure_summary.lib_utils = count;
312
+ break;
313
+ }
314
+ }
315
+
316
+ return { profile, found };
317
+ }
318
+
319
+ // ── API Communication ──────────────────────────────────────
320
+
321
+ function fetchPrompts(accessToken) {
322
+ return new Promise((resolve, reject) => {
323
+ const url = new URL('/api/v1/analyse/prompts', API_BASE);
324
+ const options = {
325
+ hostname: url.hostname,
326
+ port: 443,
327
+ path: url.pathname,
328
+ method: 'GET',
329
+ headers: {
330
+ 'Authorization': `Bearer ${accessToken}`,
331
+ 'Content-Type': 'application/json',
332
+ },
333
+ };
334
+
335
+ const req = https.request(options, (res) => {
336
+ let data = '';
337
+ res.on('data', (chunk) => { data += chunk; });
338
+ res.on('end', () => {
339
+ try {
340
+ if (res.statusCode === 200) {
341
+ resolve(JSON.parse(data));
342
+ } else {
343
+ const parsed = JSON.parse(data);
344
+ reject(new Error(parsed.error || `Server returned ${res.statusCode}`));
345
+ }
346
+ } catch (e) {
347
+ reject(new Error(`Invalid response from server (${res.statusCode})`));
348
+ }
349
+ });
350
+ });
351
+
352
+ req.on('error', reject);
353
+ req.end();
354
+ });
355
+ }
356
+
357
+ // ── Interactive Prompts ──────────────────────────────────────
358
+
359
+ function ask(question) {
360
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
361
+ return new Promise((resolve) => {
362
+ rl.question(question, (answer) => {
363
+ rl.close();
364
+ resolve(answer.trim());
365
+ });
366
+ });
367
+ }
368
+
369
+ // ── Flag Parsing ──────────────────────────────────────
370
+
371
+ function parseFlags(args) {
372
+ const flags = {};
373
+ for (let i = 0; i < args.length; i++) {
374
+ if (args[i].startsWith('--')) {
375
+ const key = args[i].replace('--', '');
376
+ flags[key] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
377
+ }
378
+ }
379
+ return flags;
380
+ }
381
+
382
+ // ── Main ──────────────────────────────────────
383
+
384
+ async function run(args) {
385
+ const flags = parseFlags(args || []);
386
+ const isInteractive = process.stdin.isTTY !== false && !flags.yes;
387
+
388
+ console.log('\n Syntropic Analyse — powered by your AI coding tool\n');
389
+
390
+ // Check auth (dry-run doesn't need auth)
391
+ const auth = getAuth();
392
+ if (!flags['dry-run']) {
393
+ if (!auth || !auth.access_token) {
394
+ console.log(' Not signed in. Run `syntropic login` first.\n');
395
+ process.exit(1);
396
+ }
397
+
398
+ if (isAuthExpired(auth)) {
399
+ console.log(' Session expired. Run `syntropic login` to refresh.\n');
400
+ process.exit(1);
401
+ }
402
+ }
403
+
404
+ // Read codebase
405
+ console.log(' Reading codebase...');
406
+ const targetDir = process.cwd();
407
+ const { profile, found } = readProductProfile(targetDir);
408
+
409
+ for (const item of found) {
410
+ console.log(` ✓ ${item}`);
411
+ }
412
+
413
+ if (found.length === 0) {
414
+ console.log(' No recognisable project files found.');
415
+ console.log(' Run this from your project root directory.\n');
416
+ process.exit(1);
417
+ }
418
+
419
+ // Show profile summary
420
+ console.log(`\n Product Profile:`);
421
+ console.log(` Name: ${profile.project_name}`);
422
+ if (profile.description) console.log(` Description: ${profile.description.substring(0, 100)}...`);
423
+ if (profile.tech_stack.framework) console.log(` Stack: ${profile.tech_stack.framework} (${profile.tech_stack.language || 'JS'})`);
424
+ if (profile.file_structure_summary.pages) {
425
+ console.log(` Features: ${profile.file_structure_summary.pages} pages, ${profile.file_structure_summary.api_routes || 0} API routes`);
426
+ }
427
+ if (profile.data_model.length) console.log(` Data Model: ${profile.data_model.length} entities`);
428
+
429
+ // Dry run — output profile and stop
430
+ if (flags['dry-run']) {
431
+ console.log('\n Product Profile (JSON):\n');
432
+ console.log(JSON.stringify(profile, null, 2));
433
+ console.log('\n Dry run complete. No data sent.\n');
434
+ return;
435
+ }
436
+
437
+ // Confirmation
438
+ if (isInteractive) {
439
+ const confirm = await ask('\n Send profile to Syntropic for analysis prompts? [Y/n] ');
440
+ if (confirm.toLowerCase() === 'n') {
441
+ console.log(' Cancelled.\n');
442
+ return;
443
+ }
444
+ }
445
+
446
+ // Optional focus question
447
+ let focus = flags.focus || '';
448
+ if (!focus && isInteractive) {
449
+ focus = await ask(' Focus question (optional, press Enter to skip): ');
450
+ }
451
+
452
+ const context = flags.context || '';
453
+
454
+ // Fetch analysis prompts
455
+ console.log('\n Fetching analysis prompts...');
456
+
457
+ let prompts;
458
+ try {
459
+ prompts = await fetchPrompts(auth.access_token);
460
+ console.log(' ✓ Analysis prompts received\n');
461
+ } catch (err) {
462
+ console.error(` Error: ${err.message}`);
463
+ if (err.message.includes('401') || err.message.includes('Unauthorized')) {
464
+ console.log(' Session may have expired. Run `syntropic login` to refresh.\n');
465
+ }
466
+ process.exit(1);
467
+ }
468
+
469
+ // Output the full analysis context for the AI tool
470
+ console.log(' ═══════════════════════════════════════════════════════');
471
+ console.log(' SYNTROPIC ANALYSIS — Your AI tool will now run this');
472
+ console.log(' ═══════════════════════════════════════════════════════\n');
473
+
474
+ // Build the combined prompt
475
+ const analysisOutput = buildAnalysisOutput(profile, prompts, focus, context);
476
+ console.log(analysisOutput);
477
+
478
+ console.log('\n ═══════════════════════════════════════════════════════');
479
+ console.log(' Your AI coding tool should now process the above.');
480
+ console.log(' The analysis uses 8 philosophy lenses for deep insight.');
481
+ console.log(' ═══════════════════════════════════════════════════════');
482
+ console.log(`\n Feedback: hello@syntropicworks.com\n`);
483
+ }
484
+
485
+ function buildAnalysisOutput(profile, prompts, focus, context) {
486
+ const sections = [];
487
+
488
+ sections.push(`# Syntropic Analysis: ${profile.project_name}`);
489
+ sections.push(`*Run this analysis using the instructions below.*\n`);
490
+
491
+ // Product profile context
492
+ sections.push(`## Product Profile\n`);
493
+ sections.push('```json');
494
+ sections.push(JSON.stringify(profile, null, 2));
495
+ sections.push('```\n');
496
+
497
+ if (focus) {
498
+ sections.push(`## Focus Question\n${focus}\n`);
499
+ }
500
+ if (context) {
501
+ sections.push(`## Additional Context\n${context}\n`);
502
+ }
503
+
504
+ // Instructions
505
+ sections.push(`## Instructions\n`);
506
+ sections.push(prompts.instructions);
507
+ sections.push('');
508
+
509
+ // Report prompt
510
+ sections.push(`## Step 1: Generate Analysis Report\n`);
511
+ sections.push(`Use this system prompt to analyse the product profile above:\n`);
512
+ sections.push('---');
513
+ sections.push(prompts.report_prompt);
514
+ sections.push('---\n');
515
+
516
+ // Lens prompts
517
+ sections.push(`## Step 2: Philosophy Lens Deep Dives\n`);
518
+ sections.push(`Run each of the following 8 lenses against the report from Step 1:\n`);
519
+
520
+ for (const key of prompts.lens_order) {
521
+ const lens = prompts.lens_prompts[key];
522
+ if (!lens) continue;
523
+ sections.push(`### ${lens.label}\n`);
524
+ sections.push(lens.prompt);
525
+ sections.push('');
526
+ }
527
+
528
+ // Validation
529
+ sections.push(`## Step 3: Validation Plan\n`);
530
+ sections.push(prompts.validation_prompt);
531
+ sections.push('');
532
+
533
+ return sections.join('\n');
534
+ }
535
+
536
+ module.exports = run;
@@ -56,6 +56,34 @@ function hmacSign(payload, hmacKey) {
56
56
  return crypto.createHmac('sha256', hmacKey).update(payload).digest('hex');
57
57
  }
58
58
 
59
+ /**
60
+ * Auth token helpers for CLI authentication
61
+ */
62
+ function getAuth() {
63
+ const config = loadConfig();
64
+ return config.auth || null;
65
+ }
66
+
67
+ function saveAuth(auth) {
68
+ const config = loadConfig();
69
+ config.auth = auth;
70
+ saveConfig(config);
71
+ // Set restrictive permissions (owner read/write only)
72
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch (_) {}
73
+ }
74
+
75
+ function clearAuth() {
76
+ const config = loadConfig();
77
+ delete config.auth;
78
+ saveConfig(config);
79
+ }
80
+
81
+ function isAuthExpired(auth) {
82
+ if (!auth || !auth.expires_at) return true;
83
+ // Expired if within 5 minutes of expiry
84
+ return new Date(auth.expires_at) < new Date(Date.now() + 5 * 60 * 1000);
85
+ }
86
+
59
87
  module.exports = {
60
88
  CONFIG_DIR,
61
89
  CONFIG_FILE,
@@ -64,4 +92,8 @@ module.exports = {
64
92
  saveConfig,
65
93
  clientHash,
66
94
  hmacSign,
95
+ getAuth,
96
+ saveAuth,
97
+ clearAuth,
98
+ isAuthExpired,
67
99
  };
package/commands/init.js CHANGED
@@ -86,6 +86,33 @@ function replaceInFile(filePath, replacements) {
86
86
  fs.writeFileSync(filePath, content, 'utf8');
87
87
  }
88
88
 
89
+ function installPostCommitHook(targetDir) {
90
+ const hooksDir = path.join(targetDir, '.git', 'hooks');
91
+ if (!fs.existsSync(hooksDir)) return false; // Not a git repo
92
+
93
+ const hookPath = path.join(hooksDir, 'post-commit');
94
+ if (fs.existsSync(hookPath)) {
95
+ // Don't overwrite existing hook — check if it already has syntropic
96
+ const existing = fs.readFileSync(hookPath, 'utf8');
97
+ if (existing.includes('syntropic report')) return 'exists';
98
+ // Append to existing hook
99
+ fs.appendFileSync(hookPath, '\n# Syntropic PRISM — anonymous cycle reporting\ncommand -v syntropic >/dev/null 2>&1 && syntropic report >/dev/null 2>&1 &\n');
100
+ console.log(` append .git/hooks/post-commit (added PRISM reporting)`);
101
+ return 'appended';
102
+ }
103
+
104
+ // Create new hook
105
+ const hookContent = `#!/bin/sh
106
+ # Syntropic PRISM — anonymous cycle reporting
107
+ # Fire-and-forget: never blocks commits, respects telemetry opt-out
108
+ # Disable: syntropic telemetry disable (or delete this hook)
109
+ command -v syntropic >/dev/null 2>&1 && syntropic report >/dev/null 2>&1 &
110
+ `;
111
+ fs.writeFileSync(hookPath, hookContent, { mode: 0o755 });
112
+ console.log(` create .git/hooks/post-commit (PRISM cycle reporting)`);
113
+ return 'created';
114
+ }
115
+
89
116
  function hasSyntropicMarker(filePath) {
90
117
  if (!fs.existsSync(filePath)) return false;
91
118
  return fs.readFileSync(filePath, 'utf8').includes('<!-- syntropic -->');
@@ -250,13 +277,19 @@ async function run(args) {
250
277
  // Ensure ~/.syntropic/config.json exists (device_id, hmac_key, telemetry)
251
278
  ensureConfig();
252
279
 
280
+ // Install post-commit hook for PRISM cycle reporting
281
+ const hookResult = installPostCommitHook(targetDir);
282
+ const hookLine = hookResult === 'created' || hookResult === 'appended'
283
+ ? '\n .git/hooks/post-commit PRISM cycle reporting (anonymous)'
284
+ : '';
285
+
253
286
  console.log(`
254
287
  Done! Your project is set up with the Syntropic pipeline.
255
288
 
256
289
  What was created:
257
290
  ${toolFiles}
258
291
  .github/workflows/ Daily health check with auto-remediation
259
- scripts/health-check.js Local health check script
292
+ scripts/health-check.js Local health check script${hookLine}
260
293
 
261
294
  Tools configured: ${selectedTools.map(t => TOOLS[t].label).join(', ')}
262
295
 
@@ -0,0 +1,160 @@
1
+ /**
2
+ * syntropic login
3
+ *
4
+ * Device Code Flow authentication:
5
+ * 1. Request device code from server
6
+ * 2. Open browser to verification URL
7
+ * 3. Poll until user completes auth
8
+ * 4. Store tokens locally
9
+ */
10
+
11
+ const https = require('https');
12
+ const { execFileSync } = require('child_process');
13
+ const { getAuth, saveAuth } = require('./config-utils');
14
+
15
+ const API_BASE = 'https://www.syntropicworks.com';
16
+
17
+ function request(method, path, body) {
18
+ return new Promise((resolve, reject) => {
19
+ const url = new URL(path, API_BASE);
20
+ const options = {
21
+ hostname: url.hostname,
22
+ port: 443,
23
+ path: url.pathname + url.search,
24
+ method,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ };
27
+
28
+ const req = https.request(options, (res) => {
29
+ let data = '';
30
+ res.on('data', (chunk) => { data += chunk; });
31
+ res.on('end', () => {
32
+ try {
33
+ resolve({ status: res.statusCode, data: JSON.parse(data) });
34
+ } catch (e) {
35
+ resolve({ status: res.statusCode, data: {} });
36
+ }
37
+ });
38
+ });
39
+
40
+ req.on('error', reject);
41
+ if (body) req.write(JSON.stringify(body));
42
+ req.end();
43
+ });
44
+ }
45
+
46
+ function openBrowser(url) {
47
+ // H5 fix: Use execFileSync to avoid shell injection via URL
48
+ try {
49
+ // macOS
50
+ execFileSync('open', [url], { stdio: 'ignore' });
51
+ return true;
52
+ } catch (_) {
53
+ try {
54
+ // Linux
55
+ execFileSync('xdg-open', [url], { stdio: 'ignore' });
56
+ return true;
57
+ } catch (_) {
58
+ try {
59
+ // Windows — 'start' needs cmd.exe, use 'explorer' instead
60
+ execFileSync('explorer', [url], { stdio: 'ignore' });
61
+ return true;
62
+ } catch (_) {
63
+ return false;
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ function sleep(ms) {
70
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
+ }
72
+
73
+ async function run() {
74
+ console.log('\n syntropic login\n');
75
+
76
+ // Check if already authenticated
77
+ const existing = getAuth();
78
+ if (existing && existing.email) {
79
+ console.log(` Already signed in as ${existing.email}`);
80
+ console.log(' Run `syntropic logout` to sign out.\n');
81
+ return;
82
+ }
83
+
84
+ // Step 1: Request device code
85
+ console.log(' Requesting authentication...');
86
+ const { status, data } = await request('POST', '/api/v1/auth/device', {});
87
+
88
+ if (status !== 200) {
89
+ console.error(` Error: ${data.error || 'Failed to start authentication'}`);
90
+ process.exit(1);
91
+ }
92
+
93
+ const { device_code, user_code, verification_url, expires_in, interval } = data;
94
+
95
+ // Step 2: Open browser
96
+ console.log(`\n Your code: ${user_code}\n`);
97
+
98
+ const opened = openBrowser(verification_url);
99
+ if (opened) {
100
+ console.log(' Browser opened. Sign in and accept the terms to continue.');
101
+ } else {
102
+ console.log(' Open this URL in your browser to sign in:');
103
+ console.log(` ${verification_url}`);
104
+ }
105
+
106
+ // Step 3: Poll for completion
107
+ console.log('\n Waiting for authentication...');
108
+ const pollInterval = (interval || 5) * 1000;
109
+ const deadline = Date.now() + (expires_in || 900) * 1000;
110
+
111
+ while (Date.now() < deadline) {
112
+ await sleep(pollInterval);
113
+
114
+ try {
115
+ const poll = await request('POST', '/api/v1/auth/poll', { device_code });
116
+
117
+ if (poll.status === 200) {
118
+ // Success!
119
+ const { access_token, refresh_token, email, tier } = poll.data;
120
+
121
+ // H3 fix: Extract expiry from JWT payload instead of hardcoding
122
+ let expiresAt;
123
+ try {
124
+ const payload = JSON.parse(Buffer.from(access_token.split('.')[1], 'base64').toString());
125
+ expiresAt = new Date(payload.exp * 1000).toISOString();
126
+ } catch (_) {
127
+ expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // fallback 1 hour
128
+ }
129
+
130
+ saveAuth({
131
+ access_token,
132
+ refresh_token,
133
+ email,
134
+ tier: tier || 'cli_trial',
135
+ expires_at: expiresAt,
136
+ });
137
+
138
+ console.log(` ✓ Authenticated as ${email} (${tier || 'CLI Trial'})\n`);
139
+ console.log(' Run `syntropic analyse` to get started.\n');
140
+ return;
141
+ }
142
+
143
+ if (poll.status === 410) {
144
+ console.error('\n Authentication expired. Run `syntropic login` to try again.\n');
145
+ process.exit(1);
146
+ }
147
+
148
+ // 202 = still pending, continue polling
149
+ process.stdout.write('.');
150
+ } catch (err) {
151
+ // Network error — retry
152
+ process.stdout.write('x');
153
+ }
154
+ }
155
+
156
+ console.error('\n Authentication timed out. Run `syntropic login` to try again.\n');
157
+ process.exit(1);
158
+ }
159
+
160
+ module.exports = run;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * syntropic logout
3
+ *
4
+ * Clears stored authentication tokens.
5
+ */
6
+
7
+ const { getAuth, clearAuth } = require('./config-utils');
8
+
9
+ async function run() {
10
+ const auth = getAuth();
11
+
12
+ if (!auth || !auth.email) {
13
+ console.log('\n Not signed in.\n');
14
+ return;
15
+ }
16
+
17
+ const email = auth.email;
18
+ clearAuth();
19
+ console.log(`\n Signed out (${email}).\n`);
20
+ }
21
+
22
+ module.exports = run;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * syntropic whoami
3
+ *
4
+ * Shows current authentication status.
5
+ */
6
+
7
+ const { getAuth, isAuthExpired } = require('./config-utils');
8
+
9
+ async function run() {
10
+ const auth = getAuth();
11
+
12
+ if (!auth || !auth.email) {
13
+ console.log('\n Not signed in. Run `syntropic login` to authenticate.\n');
14
+ return;
15
+ }
16
+
17
+ const expired = isAuthExpired(auth);
18
+ const tierLabel = {
19
+ cli_trial: 'CLI Trial',
20
+ beta: 'Beta',
21
+ pro: 'Pro',
22
+ free: 'Free',
23
+ }[auth.tier] || auth.tier;
24
+
25
+ console.log(`\n ${auth.email} (${tierLabel})${expired ? ' — token expired, run `syntropic login` to refresh' : ''}\n`);
26
+ }
27
+
28
+ module.exports = run;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "syntropic",
3
- "version": "0.7.3",
4
- "description": "Philosophy-as-code development pipeline for Claude Code, Cursor, Windsurf, GitHub Copilot, and OpenAI Codex.",
3
+ "version": "0.8.1",
4
+ "description": "Philosophy-as-code development pipeline with market analysis for Claude Code, Cursor, Windsurf, GitHub Copilot, and OpenAI Codex.",
5
5
  "bin": {
6
6
  "syntropic": "./bin/syntropic.js"
7
7
  },
@@ -19,6 +19,8 @@
19
19
  "skill-md",
20
20
  "ai-development",
21
21
  "dev-pipeline",
22
+ "market-analysis",
23
+ "startup-validation",
22
24
  "syntropic"
23
25
  ],
24
26
  "author": "Syntropic Works <hello@syntropicworks.com>",