@open-product-primer/cli 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.
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.computeDateWindow = computeDateWindow;
37
+ exports.generateAmplitudeDefinition = generateAmplitudeDefinition;
38
+ exports.generateBigQuerySQL = generateBigQuerySQL;
39
+ exports.classifyStatus = classifyStatus;
40
+ exports.writeRunResult = writeRunResult;
41
+ exports.runAmplitudeMetric = runAmplitudeMetric;
42
+ exports.runBigQueryMetric = runBigQueryMetric;
43
+ exports.scanCriteriaForSourceType = scanCriteriaForSourceType;
44
+ const fs = __importStar(require("fs"));
45
+ const path = __importStar(require("path"));
46
+ const yaml = __importStar(require("js-yaml"));
47
+ // ─── Date window ──────────────────────────────────────────────────────────────
48
+ function computeDateWindow(launchDate, timeframe) {
49
+ if (!launchDate)
50
+ return null;
51
+ const match = timeframe.match(/^(\d+)\s+days/);
52
+ if (!match)
53
+ return null;
54
+ const days = parseInt(match[1], 10);
55
+ const start = new Date(launchDate + 'T00:00:00Z');
56
+ const end = new Date(launchDate + 'T00:00:00Z');
57
+ end.setUTCDate(end.getUTCDate() + days);
58
+ return {
59
+ start: start.toISOString().slice(0, 10),
60
+ end: end.toISOString().slice(0, 10),
61
+ };
62
+ }
63
+ // ─── Amplitude generator ──────────────────────────────────────────────────────
64
+ function generateAmplitudeDefinition(metric) {
65
+ const def = metric.source.definition;
66
+ const window = computeDateWindow(metric.launch_date ?? null, metric.timeframe);
67
+ const isDenominator = !!def.denominator_event;
68
+ const events = [{ event_type: def.event }];
69
+ if (isDenominator && def.denominator_event) {
70
+ events.push({ event_type: def.denominator_event });
71
+ }
72
+ const result = {
73
+ chart_type: isDenominator ? 'funnel' : 'event_count',
74
+ events,
75
+ aggregation: def.aggregation,
76
+ };
77
+ if (window) {
78
+ result['window'] = window;
79
+ }
80
+ else {
81
+ result['_comment'] = 'launch_date required to compute window';
82
+ }
83
+ const segmentFilters = [];
84
+ if (metric.segment)
85
+ segmentFilters.push(metric.segment);
86
+ result['segment_filters'] = segmentFilters;
87
+ return result;
88
+ }
89
+ // ─── BigQuery SQL generator ───────────────────────────────────────────────────
90
+ function generateBigQuerySQL(metric) {
91
+ const def = metric.source.definition;
92
+ const window = computeDateWindow(metric.launch_date ?? null, metric.timeframe);
93
+ const windowComment = window
94
+ ? `-- Window: ${window.start} to ${window.end}`
95
+ : '-- launch_date required to compute window';
96
+ const header = [
97
+ `-- Metric: ${metric.name}`,
98
+ `-- Baseline: ${metric.baseline} | Target: ${metric.target}`,
99
+ windowComment,
100
+ ].join('\n');
101
+ const dateFilter = window
102
+ ? ` AND DATE(timestamp) BETWEEN '${window.start}' AND '${window.end}'`
103
+ : ` -- AND DATE(timestamp) BETWEEN <start> AND <end> -- set launch_date to enable`;
104
+ const agg = buildSQLAggregation(def);
105
+ if (def.denominator_query) {
106
+ const numerator = `SELECT ${agg}\nFROM \`${def.table}\`\nWHERE (${def.filter})\n${dateFilter}`;
107
+ const indented = numerator.split('\n').join('\n ');
108
+ return `${header}\nSELECT (\n ${indented}\n) / (\n ${def.denominator_query}\n)`;
109
+ }
110
+ return `${header}\nSELECT ${agg}\nFROM \`${def.table}\`\nWHERE (${def.filter})\n${dateFilter}`;
111
+ }
112
+ function buildSQLAggregation(def) {
113
+ switch (def.aggregation) {
114
+ case 'count_distinct':
115
+ return `COUNT(DISTINCT ${def.metric_column})`;
116
+ case 'count':
117
+ return `COUNT(${def.metric_column})`;
118
+ case 'avg':
119
+ return `AVG(${def.metric_column})`;
120
+ default:
121
+ return `SUM(${def.metric_column})`;
122
+ }
123
+ }
124
+ // ─── Status classification ────────────────────────────────────────────────────
125
+ function classifyStatus(actual, target) {
126
+ if (actual === null)
127
+ return 'pending';
128
+ return actual >= target ? 'hit' : 'missed';
129
+ }
130
+ // ─── Run result writer ────────────────────────────────────────────────────────
131
+ function writeRunResult(betId, measurementsDir, results, runDate) {
132
+ const data = {
133
+ bet_id: betId,
134
+ run_date: runDate,
135
+ metrics: results.map((r) => ({
136
+ id: r.id,
137
+ name: r.name,
138
+ source: r.source,
139
+ actual: r.actual,
140
+ target: r.target,
141
+ status: r.status,
142
+ notes: r.notes,
143
+ })),
144
+ };
145
+ fs.writeFileSync(path.join(measurementsDir, `run-${runDate}.yaml`), yaml.dump(data, { indent: 2 }), 'utf-8');
146
+ }
147
+ // ─── Amplitude execution ──────────────────────────────────────────────────────
148
+ async function runAmplitudeMetric(definitionPath, apiKey) {
149
+ const def = JSON.parse(fs.readFileSync(definitionPath, 'utf-8'));
150
+ if (!def.window) {
151
+ return { actual: null, notes: 'No date window — set launch_date in criteria.yaml' };
152
+ }
153
+ const startDate = def.window.start.replace(/-/g, '');
154
+ const endDate = def.window.end.replace(/-/g, '');
155
+ const auth = 'Basic ' + Buffer.from(`${apiKey}:`).toString('base64');
156
+ let response;
157
+ if (def.chart_type === 'funnel') {
158
+ response = await fetch('https://amplitude.com/api/2/funnels', {
159
+ method: 'POST',
160
+ headers: { Authorization: auth, 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({
162
+ e: def.events,
163
+ m: def.aggregation === 'unique_users' ? 'UNIQUE' : 'TOTALS',
164
+ start: startDate,
165
+ end: endDate,
166
+ }),
167
+ });
168
+ }
169
+ else {
170
+ const params = new URLSearchParams({
171
+ e: JSON.stringify({ event_type: def.events[0]?.event_type }),
172
+ m: def.aggregation === 'unique_users' ? 'UNIQUE' : 'TOTALS',
173
+ start: startDate,
174
+ end: endDate,
175
+ i: '30',
176
+ });
177
+ response = await fetch(`https://amplitude.com/api/2/events/segmentation?${params}`, {
178
+ headers: { Authorization: auth },
179
+ });
180
+ }
181
+ if (!response.ok) {
182
+ const text = await response.text();
183
+ return { actual: null, notes: `Amplitude API ${response.status}: ${text.slice(0, 200)}` };
184
+ }
185
+ const data = (await response.json());
186
+ const actual = extractAmplitudeScalar(data, def.chart_type);
187
+ return { actual, notes: actual === null ? 'Could not extract scalar from Amplitude response' : null };
188
+ }
189
+ function extractAmplitudeScalar(data, chartType) {
190
+ if (chartType === 'funnel') {
191
+ const steps = data?.data?.steps;
192
+ if (steps && steps.length > 0) {
193
+ const rate = steps[steps.length - 1]?.overall_conversion_rate;
194
+ return typeof rate === 'number' ? rate : null;
195
+ }
196
+ return null;
197
+ }
198
+ const series = data?.data?.series;
199
+ if (series && series.length > 0 && series[0].length > 0) {
200
+ const val = series[0][0]?.value;
201
+ return typeof val === 'number' ? val : null;
202
+ }
203
+ return null;
204
+ }
205
+ // ─── BigQuery execution ───────────────────────────────────────────────────────
206
+ async function runBigQueryMetric(sqlPath) {
207
+ let BQMod;
208
+ try {
209
+ BQMod = await Promise.resolve().then(() => __importStar(require('@google-cloud/bigquery')));
210
+ }
211
+ catch {
212
+ return { actual: null, notes: '@google-cloud/bigquery not installed' };
213
+ }
214
+ const sql = fs.readFileSync(sqlPath, 'utf-8');
215
+ const bq = new BQMod.BigQuery();
216
+ const [job] = await bq.createQueryJob({ query: sql });
217
+ const [rows] = await job.getQueryResults();
218
+ if (!rows || rows.length === 0) {
219
+ return { actual: null, notes: 'BigQuery query returned zero rows' };
220
+ }
221
+ const firstValue = Object.values(rows[0])[0];
222
+ if (typeof firstValue === 'number')
223
+ return { actual: firstValue, notes: null };
224
+ if (firstValue !== null && firstValue !== undefined) {
225
+ const parsed = parseFloat(String(firstValue));
226
+ if (!isNaN(parsed))
227
+ return { actual: parsed, notes: null };
228
+ }
229
+ return { actual: null, notes: 'Could not extract scalar from BigQuery result' };
230
+ }
231
+ // ─── Criteria scanner (used by doctor) ───────────────────────────────────────
232
+ function scanCriteriaForSourceType(projectRoot, sourceType) {
233
+ const betsDir = path.join(projectRoot, 'primer', 'bets');
234
+ if (!fs.existsSync(betsDir))
235
+ return false;
236
+ for (const entry of fs.readdirSync(betsDir)) {
237
+ const criteriaPath = path.join(betsDir, entry, 'criteria.yaml');
238
+ if (!fs.existsSync(criteriaPath))
239
+ continue;
240
+ try {
241
+ const parsed = yaml.load(fs.readFileSync(criteriaPath, 'utf-8'));
242
+ if (parsed?.metrics?.some((m) => m.source?.type === sourceType))
243
+ return true;
244
+ }
245
+ catch {
246
+ // skip malformed criteria files
247
+ }
248
+ }
249
+ return false;
250
+ }
@@ -0,0 +1,4 @@
1
+ export declare function ensureDir(dir: string): void;
2
+ export declare function writeFileIfAbsent(filePath: string, content: string): boolean;
3
+ export declare function writeFile(filePath: string, content: string): void;
4
+ export declare function fileExists(filePath: string): boolean;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ensureDir = ensureDir;
37
+ exports.writeFileIfAbsent = writeFileIfAbsent;
38
+ exports.writeFile = writeFile;
39
+ exports.fileExists = fileExists;
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ function ensureDir(dir) {
43
+ if (!fs.existsSync(dir)) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ }
46
+ }
47
+ function writeFileIfAbsent(filePath, content) {
48
+ if (fs.existsSync(filePath))
49
+ return false;
50
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
51
+ fs.writeFileSync(filePath, content, 'utf-8');
52
+ return true;
53
+ }
54
+ function writeFile(filePath, content) {
55
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
56
+ fs.writeFileSync(filePath, content, 'utf-8');
57
+ }
58
+ function fileExists(filePath) {
59
+ return fs.existsSync(filePath);
60
+ }
@@ -0,0 +1,6 @@
1
+ export declare function configTemplate(projectName: string, openspecEnabled: boolean, graphifyEnabled: boolean): string;
2
+ export declare const sequenceTemplate = "wip_limits:\n now: 2\n\nnow: []\nnext: []\nlater: []\nbacklog: []\n";
3
+ export declare const pdrTemplate = "# PDR-XXX: <Decision title>\n\n## Status\nProposed | Accepted | Deprecated | Superseded by PDR-YYY\n\n## Context\n<What forced this decision?>\n\n## Decision\n<Clear statement of what is decided>\n\n## Alternatives considered\n- <Alternative A and why rejected>\n- <Alternative B and why rejected>\n\n## Consequences\n- Positive: <...>\n- Trade-offs: <...>\n- Follow-ups: <...>\n\n## Evidence\n- <Research link>\n- <Data link>\n\n## Related\n- Bets: <BET-IDs>\n- OpenSpec: <change paths>\n- Supersedes: <PDR-ID or none>\n";
4
+ export declare const betDecisionTemplate = "# Decision: BET-XXX <Bet title>\n\n## Status\n- Decision: Build now | Defer | Kill\n- Date: YYYY-MM-DD\n- Owner: <name>\n- Review date: YYYY-MM-DD\n\n## Why now\n- <prioritization rationale>\n\n## Alternatives considered\n- <alternative + reason>\n\n## Expected outcomes\n- <metric: baseline -> target in timeframe>\n\n## Kill criteria / rollback trigger\n- <condition and action>\n\n## Links\n- PDRs: <PDR-IDs>\n- OpenSpec change: <path once promoted>\n";
5
+ export declare const criteriaTemplate = "metrics:\n - id: metric_id\n name: \"Metric name\"\n baseline: 0.00\n target: 0.00\n timeframe: \"30 days post-launch\"\n launch_date: \"YYYY-MM-DD\"\n source:\n type: amplitude\n definition:\n event: event_name\n aggregation: unique_users\n denominator_event: null\n segment: null\n";
6
+ export declare const kpiReviewTemplate = "# KPI Review: BET-XXX\n\n**Review date:** YYYY-MM-DD\n**Reviewed by:** <name>\n\n| Metric | Baseline | Target | Actual | Status |\n|--------|----------|--------|--------|--------|\n| <metric name> | - | - | - | pending |\n\n## Decision quality\n<Did outcomes validate the decision? What would you do differently?>\n\n## Actions\n- [ ] Update bet-decision outcome section\n- [ ] Update affected PDRs\n- [ ] Re-sequence impacted bets\n";
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.kpiReviewTemplate = exports.criteriaTemplate = exports.betDecisionTemplate = exports.pdrTemplate = exports.sequenceTemplate = void 0;
4
+ exports.configTemplate = configTemplate;
5
+ function configTemplate(projectName, openspecEnabled, graphifyEnabled) {
6
+ return `version: 1
7
+ project:
8
+ name: "${projectName}"
9
+ agents: []
10
+ integrations:
11
+ openspec:
12
+ enabled: ${openspecEnabled}
13
+ changes_dir: openspec/changes
14
+ graphify:
15
+ enabled: ${graphifyEnabled}
16
+ graph_dir: graphify-out
17
+ measurement:
18
+ amplitude:
19
+ enabled: false
20
+ bigquery:
21
+ enabled: false
22
+ metabase_url: null
23
+ sequencing:
24
+ wip_limits:
25
+ now: 2
26
+ `;
27
+ }
28
+ exports.sequenceTemplate = `wip_limits:
29
+ now: 2
30
+
31
+ now: []
32
+ next: []
33
+ later: []
34
+ backlog: []
35
+ `;
36
+ exports.pdrTemplate = `# PDR-XXX: <Decision title>
37
+
38
+ ## Status
39
+ Proposed | Accepted | Deprecated | Superseded by PDR-YYY
40
+
41
+ ## Context
42
+ <What forced this decision?>
43
+
44
+ ## Decision
45
+ <Clear statement of what is decided>
46
+
47
+ ## Alternatives considered
48
+ - <Alternative A and why rejected>
49
+ - <Alternative B and why rejected>
50
+
51
+ ## Consequences
52
+ - Positive: <...>
53
+ - Trade-offs: <...>
54
+ - Follow-ups: <...>
55
+
56
+ ## Evidence
57
+ - <Research link>
58
+ - <Data link>
59
+
60
+ ## Related
61
+ - Bets: <BET-IDs>
62
+ - OpenSpec: <change paths>
63
+ - Supersedes: <PDR-ID or none>
64
+ `;
65
+ exports.betDecisionTemplate = `# Decision: BET-XXX <Bet title>
66
+
67
+ ## Status
68
+ - Decision: Build now | Defer | Kill
69
+ - Date: YYYY-MM-DD
70
+ - Owner: <name>
71
+ - Review date: YYYY-MM-DD
72
+
73
+ ## Why now
74
+ - <prioritization rationale>
75
+
76
+ ## Alternatives considered
77
+ - <alternative + reason>
78
+
79
+ ## Expected outcomes
80
+ - <metric: baseline -> target in timeframe>
81
+
82
+ ## Kill criteria / rollback trigger
83
+ - <condition and action>
84
+
85
+ ## Links
86
+ - PDRs: <PDR-IDs>
87
+ - OpenSpec change: <path once promoted>
88
+ `;
89
+ exports.criteriaTemplate = `metrics:
90
+ - id: metric_id
91
+ name: "Metric name"
92
+ baseline: 0.00
93
+ target: 0.00
94
+ timeframe: "30 days post-launch"
95
+ launch_date: "YYYY-MM-DD"
96
+ source:
97
+ type: amplitude
98
+ definition:
99
+ event: event_name
100
+ aggregation: unique_users
101
+ denominator_event: null
102
+ segment: null
103
+ `;
104
+ exports.kpiReviewTemplate = `# KPI Review: BET-XXX
105
+
106
+ **Review date:** YYYY-MM-DD
107
+ **Reviewed by:** <name>
108
+
109
+ | Metric | Baseline | Target | Actual | Status |
110
+ |--------|----------|--------|--------|--------|
111
+ | <metric name> | - | - | - | pending |
112
+
113
+ ## Decision quality
114
+ <Did outcomes validate the decision? What would you do differently?>
115
+
116
+ ## Actions
117
+ - [ ] Update bet-decision outcome section
118
+ - [ ] Update affected PDRs
119
+ - [ ] Re-sequence impacted bets
120
+ `;
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@open-product-primer/cli",
3
+ "version": "0.1.0",
4
+ "description": "Open Product Primer CLI — product decisions, sequencing, and KPI tracking for repositories",
5
+ "keywords": [
6
+ "product",
7
+ "decisions",
8
+ "sequencing",
9
+ "kpi",
10
+ "openspec",
11
+ "oprim"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Eshane",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/eshraw/open-product-primer.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/eshraw/open-product-primer/issues"
22
+ },
23
+ "homepage": "https://github.com/eshraw/open-product-primer#readme",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "bin": {
28
+ "open-product-primer": "bin/oprim.js",
29
+ "oprim": "bin/oprim.js"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "dist",
34
+ "LICENSE",
35
+ "README.md"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "dev": "tsc --watch",
40
+ "start": "node dist/cli.js",
41
+ "test": "vitest run",
42
+ "prepublishOnly": "npm run build"
43
+ },
44
+ "dependencies": {
45
+ "@google-cloud/bigquery": "^8.3.1",
46
+ "@inquirer/prompts": "^7.0.0",
47
+ "chalk": "^5.3.0",
48
+ "commander": "^12.0.0",
49
+ "js-yaml": "^4.1.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/js-yaml": "^4.0.9",
53
+ "@types/node": "^20.0.0",
54
+ "typescript": "^5.4.0",
55
+ "vitest": "^4.1.7"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ }
60
+ }