stacksherpa 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alex Stein
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # stacksherpa
2
+
3
+ Intelligent API recommendation engine for Claude Code. Silently picks the best APIs for your stack based on your project profile, constraints, and past experiences.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ claude plugin add github:alexstein/stacksherpa
9
+ ```
10
+
11
+ That's it. The skill loads automatically and the MCP server starts on first use.
12
+
13
+ ## What happens
14
+
15
+ When you ask Claude to implement something that needs an external API (email, payments, auth, etc.), stacksherpa:
16
+
17
+ 1. Checks the shared provider catalog (50+ providers across 13 categories)
18
+ 2. Scores providers against your project profile (stack, scale, compliance, budget)
19
+ 3. Applies taste learned from your past decisions across all projects
20
+ 4. Returns the best match with confidence level
21
+
22
+ Claude uses the recommendation silently — you just get the right API without having to research it.
23
+
24
+ ## How it works
25
+
26
+ **Scoring factors:**
27
+ - Compliance match (SOC2, HIPAA, GDPR, PCI-DSS)
28
+ - Scale fit (hobby → enterprise)
29
+ - Strength alignment with your priorities (DX, reliability, cost, performance)
30
+ - Ecosystem affinity (prefers providers in ecosystems you already use)
31
+ - Past experience (positive/negative outcomes across projects)
32
+ - Pattern inference (learns your preferences over time)
33
+
34
+ **Categories:** email, payments, auth, sms, storage, database, analytics, search, monitoring, ai, push, financial-data, trading, prediction-markets
35
+
36
+ ## Tools
37
+
38
+ The MCP server exposes these tools:
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `recommend` | Get instant recommendation for a category |
43
+ | `get_profile` | View merged project profile + taste |
44
+ | `update_project_profile` | Surgical profile updates (set/append/remove) |
45
+ | `record_decision` | Record an API selection outcome |
46
+ | `get_provider` | Detailed provider info (pricing, issues, benchmarks) |
47
+ | `list_categories` | All categories with provider counts |
48
+ | `get_search_strategy` | Tailored search queries for research |
49
+ | `report_outcome` | Report integration success/failure |
50
+
51
+ ## Data
52
+
53
+ - **Shared catalog**: Hosted on Turso (read-only from clients). Providers, pricing, issues, benchmarks.
54
+ - **Local data**: `~/.stacksherpa/` stores your profiles, decisions, and project registry. Never leaves your machine.
55
+
56
+ ## Configuration
57
+
58
+ ### Project profile
59
+
60
+ Create `.stacksherpa/profile.json` in your project:
61
+
62
+ ```json
63
+ {
64
+ "project": {
65
+ "name": "my-app",
66
+ "stack": { "language": "TypeScript", "framework": "Next.js" },
67
+ "scale": "startup"
68
+ },
69
+ "constraints": {
70
+ "compliance": ["SOC2"]
71
+ },
72
+ "preferences": {
73
+ "prioritize": ["dx", "reliability"]
74
+ }
75
+ }
76
+ ```
77
+
78
+ Or let Claude update it for you via the `update_project_profile` tool.
79
+
80
+ ### Global defaults
81
+
82
+ Set defaults for all projects in `~/.stacksherpa/defaults.json`. Local project profiles override globals.
83
+
84
+ ## Advanced
85
+
86
+ ### Self-hosting the catalog
87
+
88
+ Set environment variables to point at your own Turso database:
89
+
90
+ ```bash
91
+ TURSO_DATABASE_URL=libsql://your-db.turso.io
92
+ TURSO_AUTH_TOKEN=your-read-token
93
+ ```
94
+
95
+ ### Seeding the catalog
96
+
97
+ ```bash
98
+ TURSO_WRITE_TOKEN=your-write-token npm run seed:turso
99
+ ```
100
+
101
+ ### Running scrapers
102
+
103
+ Scrapers update pricing, issues, and benchmarks. They require write access to Turso and API keys for data sources:
104
+
105
+ ```bash
106
+ FIRECRAWL_API_KEY=... TURSO_WRITE_TOKEN=... npm run cron:daily
107
+ ```
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Database Client (Turso / libSQL)
3
+ *
4
+ * Reads provider catalog from a shared Turso database.
5
+ * All queries are async. Write methods are only available
6
+ * when TURSO_WRITE_TOKEN is set (for scrapers/admin).
7
+ */
8
+ import { createClient } from '@libsql/client';
9
+ import { SCHEMA } from './schema.js';
10
+ // Turso connection — defaults to shared read-only catalog
11
+ const TURSO_URL = process.env.TURSO_DATABASE_URL ?? 'libsql://api-broker-astein91.aws-us-west-2.turso.io';
12
+ const TURSO_READ_TOKEN = process.env.TURSO_AUTH_TOKEN ?? 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicm8iLCJpYXQiOjE3NzA5MTg3OTUsImlkIjoiYjA4YmZkNTUtZjhhNi00ODU5LThjNzMtYjg4NzIzMmI4YmYyIiwicmlkIjoiMWJjN2NhMTgtMDU1OC00NjI2LWJjY2YtOGVmMWIwZjQ1ZDAxIn0.aZ4gR9f0I9Qq8T_tkQp-uXwL-4LzUPGaGCDATirOFBBu9kHmuFs5cfXXJ162RzfX2Nrsn-HEAx_H8-uSSfiKBA';
13
+ const TURSO_WRITE_TOKEN = process.env.TURSO_WRITE_TOKEN;
14
+ let client = null;
15
+ export function getClient() {
16
+ if (!client) {
17
+ client = createClient({
18
+ url: TURSO_URL,
19
+ authToken: TURSO_READ_TOKEN || undefined,
20
+ });
21
+ }
22
+ return client;
23
+ }
24
+ /**
25
+ * Initialize the database schema (for local/embedded use or first-time Turso setup).
26
+ * In production, schema is managed via Turso CLI.
27
+ */
28
+ export async function ensureSchema() {
29
+ const db = getClient();
30
+ // Split SCHEMA into individual statements and execute each
31
+ const statements = SCHEMA
32
+ .split(';')
33
+ .map(s => s.trim())
34
+ .filter(s => s.length > 0);
35
+ for (const stmt of statements) {
36
+ await db.execute(stmt);
37
+ }
38
+ }
39
+ // ============================================
40
+ // Query Methods (for MCP server)
41
+ // ============================================
42
+ /**
43
+ * Get all providers in a category
44
+ */
45
+ export async function getProvidersByCategory(category) {
46
+ const db = getClient();
47
+ const result = await db.execute({
48
+ sql: `SELECT * FROM providers WHERE category = ? AND status = 'active'`,
49
+ args: [category],
50
+ });
51
+ return result.rows.map(row => rowToProvider(row));
52
+ }
53
+ /**
54
+ * Get a single provider by ID with all related data
55
+ */
56
+ export async function getProviderById(id) {
57
+ const db = getClient();
58
+ const result = await db.execute({
59
+ sql: `SELECT * FROM providers WHERE id = ?`,
60
+ args: [id],
61
+ });
62
+ if (result.rows.length === 0)
63
+ return null;
64
+ const row = result.rows[0];
65
+ const provider = rowToProvider(row);
66
+ // Get latest pricing
67
+ const pricingResult = await db.execute({
68
+ sql: `SELECT * FROM latest_pricing WHERE provider_id = ?`,
69
+ args: [id],
70
+ });
71
+ if (pricingResult.rows.length > 0) {
72
+ provider.pricing = rowToPricing(pricingResult.rows[0]);
73
+ }
74
+ // Get known issues
75
+ const issuesResult = await db.execute({
76
+ sql: `SELECT * FROM active_issues WHERE provider_id = ?`,
77
+ args: [id],
78
+ });
79
+ if (issuesResult.rows.length > 0) {
80
+ provider.knownIssues = issuesResult.rows.map(r => rowToKnownIssue(r));
81
+ }
82
+ // Get AI benchmarks if applicable
83
+ if (row.category === 'ai') {
84
+ const benchResult = await db.execute({
85
+ sql: `SELECT * FROM latest_ai_benchmarks WHERE provider_id = ?`,
86
+ args: [id],
87
+ });
88
+ if (benchResult.rows.length > 0) {
89
+ provider.aiBenchmarks = rowToAiBenchmarks(benchResult.rows[0]);
90
+ }
91
+ }
92
+ return provider;
93
+ }
94
+ /**
95
+ * Search providers with filters
96
+ */
97
+ export async function searchProviders(filters) {
98
+ const db = getClient();
99
+ let sql = `
100
+ SELECT DISTINCT p.* FROM providers p
101
+ LEFT JOIN latest_pricing pr ON p.id = pr.provider_id
102
+ WHERE p.status = 'active'
103
+ `;
104
+ const args = [];
105
+ if (filters.category) {
106
+ sql += ` AND p.category = ?`;
107
+ args.push(filters.category);
108
+ }
109
+ if (filters.compliance && filters.compliance.length > 0) {
110
+ for (const c of filters.compliance) {
111
+ sql += ` AND EXISTS (SELECT 1 FROM json_each(p.compliance) WHERE json_each.value = ?)`;
112
+ args.push(c);
113
+ }
114
+ }
115
+ if (filters.scale) {
116
+ sql += ` AND EXISTS (SELECT 1 FROM json_each(p.best_for) WHERE json_each.value = ?)`;
117
+ args.push(filters.scale);
118
+ }
119
+ if (filters.hasFreeTier) {
120
+ sql += ` AND pr.free_tier_included IS NOT NULL`;
121
+ }
122
+ if (filters.maxPricePerUnit !== undefined) {
123
+ sql += ` AND (pr.unit_price IS NULL OR pr.unit_price <= ?)`;
124
+ args.push(filters.maxPricePerUnit);
125
+ }
126
+ const result = await db.execute({ sql, args });
127
+ return result.rows.map(row => rowToProvider(row));
128
+ }
129
+ /**
130
+ * Get active issues for a provider
131
+ */
132
+ export async function getActiveIssues(providerId) {
133
+ const db = getClient();
134
+ const result = await db.execute({
135
+ sql: `SELECT * FROM active_issues WHERE provider_id = ?`,
136
+ args: [providerId],
137
+ });
138
+ return result.rows.map(r => rowToKnownIssue(r));
139
+ }
140
+ /**
141
+ * Get all categories with provider counts
142
+ */
143
+ export async function getCategories() {
144
+ const db = getClient();
145
+ const result = await db.execute(`
146
+ SELECT category, COUNT(*) as count
147
+ FROM providers
148
+ WHERE status = 'active'
149
+ GROUP BY category
150
+ ORDER BY count DESC
151
+ `);
152
+ return result.rows.map(r => ({
153
+ category: r.category,
154
+ count: Number(r.count),
155
+ }));
156
+ }
157
+ /**
158
+ * Get a map of provider name (lowercase) -> ecosystem for all providers that have one.
159
+ * Used for ecosystem affinity scoring without loading all providers.
160
+ */
161
+ export async function getProviderEcosystems() {
162
+ const db = getClient();
163
+ const result = await db.execute(`
164
+ SELECT LOWER(name) as name, ecosystem
165
+ FROM providers
166
+ WHERE ecosystem IS NOT NULL AND ecosystem != '' AND status = 'active'
167
+ `);
168
+ return new Map(result.rows.map(r => [r.name, r.ecosystem]));
169
+ }
170
+ /**
171
+ * Get providers needing updates (stale data)
172
+ */
173
+ export async function getStaleProviders(daysSinceUpdate = 30) {
174
+ const db = getClient();
175
+ const cutoff = new Date(Date.now() - daysSinceUpdate * 24 * 60 * 60 * 1000).toISOString();
176
+ const result = await db.execute({
177
+ sql: `
178
+ SELECT * FROM providers
179
+ WHERE last_verified < ? OR last_verified IS NULL
180
+ ORDER BY last_verified ASC NULLS FIRST
181
+ `,
182
+ args: [cutoff],
183
+ });
184
+ return result.rows;
185
+ }
186
+ // ============================================
187
+ // Write Methods (for scrapers/admin — requires TURSO_WRITE_TOKEN)
188
+ // ============================================
189
+ function getWriteClient() {
190
+ if (!TURSO_WRITE_TOKEN) {
191
+ throw new Error('TURSO_WRITE_TOKEN is required for write operations');
192
+ }
193
+ return createClient({
194
+ url: TURSO_URL,
195
+ authToken: TURSO_WRITE_TOKEN,
196
+ });
197
+ }
198
+ /**
199
+ * Upsert a provider (requires write token)
200
+ */
201
+ export async function upsertProvider(provider) {
202
+ const db = getWriteClient();
203
+ await db.execute({
204
+ sql: `
205
+ INSERT INTO providers (
206
+ id, name, description, category, subcategories, status,
207
+ website, docs_url, package, package_alt_names,
208
+ compliance, data_residency, self_hostable, on_prem_option,
209
+ strengths, weaknesses, best_for,
210
+ avoid_if, requires, best_when, alternatives,
211
+ ecosystem, updated_at, last_verified
212
+ ) VALUES (
213
+ ?, ?, ?, ?, ?, ?,
214
+ ?, ?, ?, ?,
215
+ ?, ?, ?, ?,
216
+ ?, ?, ?,
217
+ ?, ?, ?, ?,
218
+ ?, CURRENT_TIMESTAMP, ?
219
+ )
220
+ ON CONFLICT(id) DO UPDATE SET
221
+ name = excluded.name,
222
+ description = excluded.description,
223
+ category = excluded.category,
224
+ subcategories = excluded.subcategories,
225
+ status = excluded.status,
226
+ website = excluded.website,
227
+ docs_url = excluded.docs_url,
228
+ package = excluded.package,
229
+ package_alt_names = excluded.package_alt_names,
230
+ compliance = excluded.compliance,
231
+ data_residency = excluded.data_residency,
232
+ self_hostable = excluded.self_hostable,
233
+ on_prem_option = excluded.on_prem_option,
234
+ strengths = excluded.strengths,
235
+ weaknesses = excluded.weaknesses,
236
+ best_for = excluded.best_for,
237
+ avoid_if = excluded.avoid_if,
238
+ requires = excluded.requires,
239
+ best_when = excluded.best_when,
240
+ alternatives = excluded.alternatives,
241
+ ecosystem = excluded.ecosystem,
242
+ updated_at = CURRENT_TIMESTAMP,
243
+ last_verified = excluded.last_verified
244
+ `,
245
+ args: [
246
+ provider.id ?? provider.name.toLowerCase().replace(/\s+/g, '-'),
247
+ provider.name,
248
+ provider.description ?? null,
249
+ provider.category ?? 'unknown',
250
+ JSON.stringify(provider.subcategories ?? []),
251
+ provider.status ?? 'active',
252
+ provider.website ?? null,
253
+ provider.docsUrl ?? null,
254
+ provider.package ?? null,
255
+ JSON.stringify(provider.packageAltNames ?? {}),
256
+ JSON.stringify(provider.compliance ?? []),
257
+ JSON.stringify(provider.dataResidency ?? []),
258
+ provider.selfHostable ? 1 : 0,
259
+ provider.onPremOption ? 1 : 0,
260
+ JSON.stringify(provider.strengths ?? []),
261
+ JSON.stringify(provider.weaknesses ?? []),
262
+ JSON.stringify(provider.bestFor ?? provider.scale ?? []),
263
+ JSON.stringify(provider.avoidIf ?? []),
264
+ JSON.stringify(provider.requires ?? []),
265
+ JSON.stringify(provider.bestWhen ?? []),
266
+ JSON.stringify(provider.alternatives ?? []),
267
+ provider.ecosystem ?? null,
268
+ provider.lastVerified ?? null,
269
+ ],
270
+ });
271
+ }
272
+ /**
273
+ * Insert pricing data (append-only for history, requires write token)
274
+ */
275
+ export async function insertPricing(providerId, pricing) {
276
+ const db = getWriteClient();
277
+ await db.execute({
278
+ sql: `
279
+ INSERT INTO pricing (
280
+ provider_id, pricing_type, currency,
281
+ free_tier_included, free_tier_limitations,
282
+ unit, unit_price, volume_discounts,
283
+ plans, source_url, scraped_at, confidence
284
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
285
+ `,
286
+ args: [
287
+ providerId,
288
+ pricing.type,
289
+ pricing.currency,
290
+ pricing.freeTier?.included ?? null,
291
+ JSON.stringify(pricing.freeTier?.limitations ?? []),
292
+ pricing.unitPricing?.unit ?? null,
293
+ pricing.unitPricing?.price ?? null,
294
+ JSON.stringify(pricing.unitPricing?.volumeDiscounts ?? []),
295
+ JSON.stringify(pricing.plans ?? []),
296
+ pricing.source ?? null,
297
+ pricing.lastVerified,
298
+ 'medium',
299
+ ],
300
+ });
301
+ }
302
+ /**
303
+ * Upsert a known issue (requires write token)
304
+ */
305
+ export async function upsertKnownIssue(providerId, issue) {
306
+ const db = getWriteClient();
307
+ await db.execute({
308
+ sql: `
309
+ INSERT INTO known_issues (
310
+ id, provider_id, symptom, scope, workaround, severity,
311
+ affected_versions, github_issue_url,
312
+ reported_at, resolved_at, confidence, updated_at
313
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
314
+ ON CONFLICT(id) DO UPDATE SET
315
+ symptom = excluded.symptom,
316
+ scope = excluded.scope,
317
+ workaround = excluded.workaround,
318
+ severity = excluded.severity,
319
+ affected_versions = excluded.affected_versions,
320
+ resolved_at = excluded.resolved_at,
321
+ confidence = excluded.confidence,
322
+ updated_at = CURRENT_TIMESTAMP
323
+ `,
324
+ args: [
325
+ issue.id,
326
+ providerId,
327
+ issue.symptom,
328
+ issue.scope ?? null,
329
+ issue.workaround ?? null,
330
+ issue.severity,
331
+ issue.affectedVersions ?? null,
332
+ issue.githubIssue ?? null,
333
+ issue.reportedAt ?? null,
334
+ issue.resolvedAt ?? null,
335
+ issue.confidence,
336
+ ],
337
+ });
338
+ }
339
+ // ============================================
340
+ // Row to Type Converters
341
+ // ============================================
342
+ function parseJson(json) {
343
+ if (!json)
344
+ return undefined;
345
+ try {
346
+ return JSON.parse(json);
347
+ }
348
+ catch {
349
+ return undefined;
350
+ }
351
+ }
352
+ function str(val) {
353
+ if (val === null || val === undefined)
354
+ return null;
355
+ return String(val);
356
+ }
357
+ function num(val) {
358
+ if (val === null || val === undefined)
359
+ return null;
360
+ return Number(val);
361
+ }
362
+ function rowToProvider(row) {
363
+ return {
364
+ id: row.id,
365
+ name: row.name,
366
+ description: row.description ?? undefined,
367
+ category: row.category,
368
+ subcategories: parseJson(row.subcategories),
369
+ status: row.status,
370
+ website: row.website ?? undefined,
371
+ docsUrl: row.docs_url ?? undefined,
372
+ package: row.package ?? undefined,
373
+ packageAltNames: parseJson(row.package_alt_names),
374
+ compliance: parseJson(row.compliance),
375
+ dataResidency: parseJson(row.data_residency),
376
+ selfHostable: row.self_hostable === 1,
377
+ onPremOption: row.on_prem_option === 1,
378
+ strengths: parseJson(row.strengths),
379
+ weaknesses: parseJson(row.weaknesses),
380
+ bestFor: parseJson(row.best_for),
381
+ avoidIf: parseJson(row.avoid_if),
382
+ requires: parseJson(row.requires),
383
+ bestWhen: parseJson(row.best_when),
384
+ alternatives: parseJson(row.alternatives),
385
+ ecosystem: row.ecosystem ?? undefined,
386
+ lastVerified: row.last_verified ?? undefined,
387
+ };
388
+ }
389
+ function rowToPricing(row) {
390
+ return {
391
+ type: row.pricing_type ?? 'usage',
392
+ currency: row.currency,
393
+ freeTier: row.free_tier_included ? {
394
+ included: row.free_tier_included,
395
+ limitations: parseJson(row.free_tier_limitations),
396
+ } : undefined,
397
+ unitPricing: row.unit ? {
398
+ unit: row.unit,
399
+ price: row.unit_price ?? 0,
400
+ volumeDiscounts: parseJson(row.volume_discounts),
401
+ } : undefined,
402
+ plans: parseJson(row.plans),
403
+ lastVerified: row.scraped_at,
404
+ source: row.source_url ?? undefined,
405
+ };
406
+ }
407
+ function rowToKnownIssue(row) {
408
+ return {
409
+ id: row.id,
410
+ symptom: row.symptom,
411
+ scope: row.scope ?? '',
412
+ workaround: row.workaround ?? undefined,
413
+ severity: row.severity,
414
+ affectedVersions: row.affected_versions ?? undefined,
415
+ githubIssue: row.github_issue_url ?? undefined,
416
+ reportedAt: row.reported_at ?? '',
417
+ resolvedAt: row.resolved_at ?? undefined,
418
+ confidence: row.confidence,
419
+ };
420
+ }
421
+ function rowToAiBenchmarks(row) {
422
+ return {
423
+ lmArena: row.lmarena_elo ? {
424
+ elo: Number(row.lmarena_elo),
425
+ rank: num(row.lmarena_rank) ?? undefined,
426
+ category: str(row.lmarena_category) ?? undefined,
427
+ measuredAt: str(row.measured_at) ?? '',
428
+ } : undefined,
429
+ artificialAnalysis: row.aa_quality_index ? {
430
+ qualityIndex: Number(row.aa_quality_index),
431
+ speedIndex: num(row.aa_speed_index) ?? undefined,
432
+ pricePerMToken: num(row.aa_price_per_m_token) ?? undefined,
433
+ tokensPerSecond: num(row.aa_tokens_per_second) ?? undefined,
434
+ ttft: num(row.aa_ttft_ms) ?? undefined,
435
+ measuredAt: str(row.measured_at) ?? '',
436
+ } : undefined,
437
+ contextWindow: row.context_max_tokens ? {
438
+ maxTokens: Number(row.context_max_tokens),
439
+ effectiveTokens: num(row.context_effective_tokens) ?? undefined,
440
+ } : undefined,
441
+ capabilities: parseJson(str(row.capabilities)),
442
+ benchmarks: parseJson(str(row.benchmarks)),
443
+ };
444
+ }