@prajwolkc/stk 0.2.1 → 0.4.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.
Files changed (2) hide show
  1. package/dist/mcp/server.js +838 -0
  2. package/package.json +2 -2
@@ -366,6 +366,844 @@ server.tool("stk_config", "Read the current stk configuration for this project.
366
366
  content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
367
367
  };
368
368
  });
369
+ // ──────────────────────────────────────────
370
+ // Tool: stk_db
371
+ // ──────────────────────────────────────────
372
+ server.tool("stk_db", "Query your Supabase database directly. Run SELECT queries, check row counts, inspect table data — all from chat. Only read operations are allowed for safety.", {
373
+ query: z.string().optional().describe("SQL query to run (SELECT only for safety)"),
374
+ table: z.string().optional().describe("Shorthand: just provide a table name to SELECT * with a limit"),
375
+ limit: z.number().optional().describe("Max rows to return (default 20)"),
376
+ }, async ({ query, table, limit: rawLimit }) => {
377
+ const limit = rawLimit ?? 20;
378
+ const url = process.env.SUPABASE_URL;
379
+ const key = process.env.SUPABASE_SERVICE_KEY;
380
+ if (!url || !key) {
381
+ return { content: [{ type: "text", text: JSON.stringify({ error: "SUPABASE_URL or SUPABASE_SERVICE_KEY not set" }) }] };
382
+ }
383
+ // If table shorthand is used, build a simple query
384
+ let sql = query ?? "";
385
+ if (table && !query) {
386
+ sql = `SELECT * FROM ${table} ORDER BY created_at DESC LIMIT ${limit}`;
387
+ }
388
+ if (!sql && !table) {
389
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Provide either a 'query' or 'table' parameter" }) }] };
390
+ }
391
+ // Safety: only allow read operations
392
+ const normalized = sql.trim().toUpperCase();
393
+ if (!normalized.startsWith("SELECT") && !normalized.startsWith("WITH")) {
394
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Only SELECT/WITH queries are allowed for safety. Use Supabase dashboard for mutations." }) }] };
395
+ }
396
+ try {
397
+ const res = await fetch(`${url}/rest/v1/rpc/`, {
398
+ method: "POST",
399
+ headers: {
400
+ apikey: key,
401
+ Authorization: `Bearer ${key}`,
402
+ "Content-Type": "application/json",
403
+ Prefer: "return=representation",
404
+ },
405
+ body: JSON.stringify({}),
406
+ });
407
+ // Use PostgREST query instead of RPC for better compatibility
408
+ // Parse table name from SQL for simple queries
409
+ const tableMatch = sql.match(/FROM\s+["']?(\w+)["']?/i);
410
+ const targetTable = table ?? tableMatch?.[1];
411
+ if (targetTable) {
412
+ const restRes = await fetch(`${url}/rest/v1/${targetTable}?select=*&limit=${limit}`, {
413
+ headers: {
414
+ apikey: key,
415
+ Authorization: `Bearer ${key}`,
416
+ "Content-Type": "application/json",
417
+ Prefer: "count=exact",
418
+ },
419
+ });
420
+ if (!restRes.ok) {
421
+ const errText = await restRes.text();
422
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Query failed: ${errText}` }) }] };
423
+ }
424
+ const contentRange = restRes.headers.get("content-range");
425
+ const totalCount = contentRange ? contentRange.split("/")[1] : "unknown";
426
+ const data = await restRes.json();
427
+ return {
428
+ content: [{
429
+ type: "text",
430
+ text: JSON.stringify({
431
+ table: targetTable,
432
+ totalRows: totalCount,
433
+ returned: Array.isArray(data) ? data.length : 0,
434
+ data,
435
+ }, null, 2),
436
+ }],
437
+ };
438
+ }
439
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Could not parse table name from query. Use the 'table' parameter instead." }) }] };
440
+ }
441
+ catch (err) {
442
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
443
+ }
444
+ });
445
+ // ──────────────────────────────────────────
446
+ // Tool: stk_analytics
447
+ // ──────────────────────────────────────────
448
+ server.tool("stk_analytics", "Get live app analytics: total users, posts, payments, revenue, and recent activity. Pulls data from Supabase and Stripe in one call.", {}, async () => {
449
+ const results = {};
450
+ // Supabase stats
451
+ const url = process.env.SUPABASE_URL;
452
+ const key = process.env.SUPABASE_SERVICE_KEY;
453
+ if (url && key) {
454
+ const headers = {
455
+ apikey: key,
456
+ Authorization: `Bearer ${key}`,
457
+ Prefer: "count=exact",
458
+ };
459
+ // Get table counts in parallel
460
+ const [postsRes, usersRes, paymentsRes] = await Promise.all([
461
+ fetch(`${url}/rest/v1/posts?select=id&limit=0`, { headers }).catch(() => null),
462
+ fetch(`${url}/rest/v1/users?select=id&limit=0`, { headers }).catch(() => null),
463
+ fetch(`${url}/rest/v1/payments?select=id&limit=0`, { headers }).catch(() => null),
464
+ ]);
465
+ const getCount = (res) => {
466
+ if (!res?.ok)
467
+ return null;
468
+ const range = res.headers.get("content-range");
469
+ return range ? parseInt(range.split("/")[1]) || 0 : null;
470
+ };
471
+ results.supabase = {
472
+ totalPosts: getCount(postsRes),
473
+ totalUsers: getCount(usersRes),
474
+ totalPayments: getCount(paymentsRes),
475
+ };
476
+ // Recent posts (last 24h)
477
+ const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
478
+ const recentRes = await fetch(`${url}/rest/v1/posts?select=id&timestamp=gte.${oneDayAgo}&limit=0`, { headers: { ...headers, Prefer: "count=exact" } }).catch(() => null);
479
+ results.supabase.postsLast24h = getCount(recentRes);
480
+ // Recent users with paid posts
481
+ const paidUsersRes = await fetch(`${url}/rest/v1/users?select=id&paid_posts_available=gt.0&limit=0`, { headers: { ...headers, Prefer: "count=exact" } }).catch(() => null);
482
+ results.supabase.usersWithPaidPosts = getCount(paidUsersRes);
483
+ }
484
+ // Stripe stats
485
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
486
+ if (stripeKey) {
487
+ try {
488
+ // Balance
489
+ const balRes = await fetch("https://api.stripe.com/v1/balance", {
490
+ headers: { Authorization: `Bearer ${stripeKey}` },
491
+ });
492
+ const balData = await balRes.json();
493
+ // Recent charges
494
+ const chargesRes = await fetch("https://api.stripe.com/v1/charges?limit=100", {
495
+ headers: { Authorization: `Bearer ${stripeKey}` },
496
+ });
497
+ const chargesData = await chargesRes.json();
498
+ const charges = chargesData.data ?? [];
499
+ const totalRevenue = charges.reduce((sum, c) => sum + (c.status === "succeeded" ? c.amount : 0), 0);
500
+ results.stripe = {
501
+ balance: balData.available?.map((b) => `${(b.amount / 100).toFixed(2)} ${b.currency.toUpperCase()}`) ?? [],
502
+ totalCharges: charges.length,
503
+ successfulCharges: charges.filter((c) => c.status === "succeeded").length,
504
+ totalRevenue: `${(totalRevenue / 100).toFixed(2)}`,
505
+ mode: stripeKey.startsWith("sk_live") ? "live" : "test",
506
+ };
507
+ }
508
+ catch (err) {
509
+ results.stripe = { error: err.message };
510
+ }
511
+ }
512
+ return {
513
+ content: [{ type: "text", text: JSON.stringify({ analytics: results }, null, 2) }],
514
+ };
515
+ });
516
+ // ──────────────────────────────────────────
517
+ // Tool: stk_alerts
518
+ // ──────────────────────────────────────────
519
+ server.tool("stk_alerts", "Scan for problems across your entire stack: failed deploys, down services, error logs, Stripe failures, and database issues. Returns actionable alerts.", {}, async () => {
520
+ await loadPluginCheckers();
521
+ const config = loadConfig();
522
+ const alerts = [];
523
+ // 1. Check all service health
524
+ const serviceList = enabledServices(config);
525
+ const checks = serviceList.map(async (name) => {
526
+ const checker = getChecker(name);
527
+ if (!checker)
528
+ return null;
529
+ return checker();
530
+ });
531
+ const results = (await Promise.all(checks)).filter(Boolean);
532
+ for (const r of results) {
533
+ if (r.status === "down") {
534
+ alerts.push({ level: "critical", source: r.name, message: `Service is DOWN: ${r.detail ?? "unreachable"}` });
535
+ }
536
+ else if (r.status === "degraded") {
537
+ alerts.push({ level: "warning", source: r.name, message: `Service degraded: ${r.detail ?? "slow response"}` });
538
+ }
539
+ else if (r.latency && r.latency > 3000) {
540
+ alerts.push({ level: "warning", source: r.name, message: `High latency: ${r.latency}ms` });
541
+ }
542
+ }
543
+ // 2. Check Vercel for failed deploys
544
+ if (process.env.VERCEL_TOKEN) {
545
+ try {
546
+ const res = await fetch("https://api.vercel.com/v6/deployments?limit=5", {
547
+ headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
548
+ });
549
+ const data = await res.json();
550
+ for (const dep of data.deployments ?? []) {
551
+ const state = dep.readyState ?? dep.state;
552
+ if (state === "ERROR" || state === "CANCELED") {
553
+ alerts.push({ level: "critical", source: "Vercel", message: `Deploy ${state}: ${dep.url ?? dep.uid}` });
554
+ }
555
+ }
556
+ }
557
+ catch { /* skip */ }
558
+ }
559
+ // 3. Check Stripe for recent failures
560
+ if (process.env.STRIPE_SECRET_KEY) {
561
+ try {
562
+ const res = await fetch("https://api.stripe.com/v1/charges?limit=20", {
563
+ headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
564
+ });
565
+ const data = await res.json();
566
+ const failed = (data.data ?? []).filter((c) => c.status === "failed");
567
+ if (failed.length > 0) {
568
+ alerts.push({ level: "warning", source: "Stripe", message: `${failed.length} failed charge(s) in recent transactions` });
569
+ }
570
+ }
571
+ catch { /* skip */ }
572
+ }
573
+ // 4. Check for error logs in Vercel
574
+ if (process.env.VERCEL_TOKEN) {
575
+ try {
576
+ const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=1", {
577
+ headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
578
+ });
579
+ const depData = await depRes.json();
580
+ const dep = depData.deployments?.[0];
581
+ if (dep) {
582
+ const logRes = await fetch(`https://api.vercel.com/v2/deployments/${dep.uid}/events`, {
583
+ headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
584
+ });
585
+ const events = await logRes.json();
586
+ if (Array.isArray(events)) {
587
+ const errors = events.filter((e) => e.type === "stderr");
588
+ if (errors.length > 5) {
589
+ alerts.push({ level: "warning", source: "Vercel Logs", message: `${errors.length} stderr entries in latest deploy` });
590
+ }
591
+ }
592
+ }
593
+ }
594
+ catch { /* skip */ }
595
+ }
596
+ if (alerts.length === 0) {
597
+ alerts.push({ level: "info", source: "stk", message: "All clear — no issues detected" });
598
+ }
599
+ return {
600
+ content: [{
601
+ type: "text",
602
+ text: JSON.stringify({
603
+ project: config.name,
604
+ alerts,
605
+ summary: {
606
+ critical: alerts.filter((a) => a.level === "critical").length,
607
+ warnings: alerts.filter((a) => a.level === "warning").length,
608
+ ok: alerts.every((a) => a.level === "info"),
609
+ },
610
+ }, null, 2),
611
+ }],
612
+ };
613
+ });
614
+ // ──────────────────────────────────────────
615
+ // Tool: stk_rollback
616
+ // ──────────────────────────────────────────
617
+ server.tool("stk_rollback", "Rollback to a previous Vercel deployment. Lists recent deploys and can promote an older one to production.", {
618
+ deployId: z.string().optional().describe("Deployment ID to rollback to. If omitted, lists recent deployments to choose from."),
619
+ confirm: z.boolean().optional().default(false).describe("Must be true to actually execute the rollback"),
620
+ }, async ({ deployId, confirm }) => {
621
+ const token = process.env.VERCEL_TOKEN;
622
+ if (!token) {
623
+ return { content: [{ type: "text", text: JSON.stringify({ error: "VERCEL_TOKEN not set" }) }] };
624
+ }
625
+ // List recent deployments
626
+ const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=10", {
627
+ headers: { Authorization: `Bearer ${token}` },
628
+ });
629
+ const depData = await depRes.json();
630
+ const deployments = (depData.deployments ?? []).map((d) => ({
631
+ id: d.uid,
632
+ url: d.url,
633
+ state: d.readyState ?? d.state,
634
+ created: new Date(d.created).toISOString(),
635
+ target: d.target ?? "preview",
636
+ }));
637
+ if (!deployId) {
638
+ return {
639
+ content: [{
640
+ type: "text",
641
+ text: JSON.stringify({
642
+ message: "Recent deployments — provide a deployId to rollback",
643
+ deployments,
644
+ }, null, 2),
645
+ }],
646
+ };
647
+ }
648
+ if (!confirm) {
649
+ const target = deployments.find((d) => d.id === deployId);
650
+ return {
651
+ content: [{
652
+ type: "text",
653
+ text: JSON.stringify({
654
+ message: "Rollback requires confirmation. Call again with confirm: true",
655
+ target: target ?? deployId,
656
+ }, null, 2),
657
+ }],
658
+ };
659
+ }
660
+ // Execute rollback by promoting the old deployment
661
+ try {
662
+ // Get the deployment's project
663
+ const detailRes = await fetch(`https://api.vercel.com/v13/deployments/${deployId}`, {
664
+ headers: { Authorization: `Bearer ${token}` },
665
+ });
666
+ const detail = await detailRes.json();
667
+ const projectId = detail.projectId;
668
+ if (!projectId) {
669
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Could not determine project from deployment" }) }] };
670
+ }
671
+ // Create a new deployment based on the old one (redeploy)
672
+ const rollbackRes = await fetch(`https://api.vercel.com/v13/deployments`, {
673
+ method: "POST",
674
+ headers: {
675
+ Authorization: `Bearer ${token}`,
676
+ "Content-Type": "application/json",
677
+ },
678
+ body: JSON.stringify({
679
+ name: detail.name,
680
+ deploymentId: deployId,
681
+ target: "production",
682
+ }),
683
+ });
684
+ if (!rollbackRes.ok) {
685
+ const errData = await rollbackRes.json();
686
+ return { content: [{ type: "text", text: JSON.stringify({ error: errData.error?.message ?? `HTTP ${rollbackRes.status}` }) }] };
687
+ }
688
+ const rollbackData = await rollbackRes.json();
689
+ return {
690
+ content: [{
691
+ type: "text",
692
+ text: JSON.stringify({
693
+ rolledBack: true,
694
+ newDeploymentId: rollbackData.id,
695
+ url: rollbackData.url,
696
+ note: "Rollback triggered. Use stk_health to verify.",
697
+ }, null, 2),
698
+ }],
699
+ };
700
+ }
701
+ catch (err) {
702
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
703
+ }
704
+ });
705
+ // ──────────────────────────────────────────
706
+ // Tool: stk_env_sync
707
+ // ──────────────────────────────────────────
708
+ server.tool("stk_env_sync", "Compare and sync environment variables between local .env and Vercel. Shows which vars are missing, extra, or mismatched.", {
709
+ action: z.enum(["diff", "pull", "push"]).optional().default("diff").describe("diff: compare local vs remote. pull: download remote to .env.pulled. push: upload local to Vercel."),
710
+ confirm: z.boolean().optional().default(false).describe("Required for push action"),
711
+ }, async ({ action, confirm }) => {
712
+ const token = process.env.VERCEL_TOKEN;
713
+ const projectId = process.env.VERCEL_PROJECT_ID;
714
+ // Read local .env
715
+ let localVars = {};
716
+ try {
717
+ const { readFileSync } = await import("fs");
718
+ const envContent = readFileSync(".env", "utf-8");
719
+ for (const line of envContent.split("\n")) {
720
+ const trimmed = line.trim();
721
+ if (!trimmed || trimmed.startsWith("#"))
722
+ continue;
723
+ const eqIdx = trimmed.indexOf("=");
724
+ if (eqIdx > 0) {
725
+ localVars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
726
+ }
727
+ }
728
+ }
729
+ catch {
730
+ localVars = {};
731
+ }
732
+ if (!token) {
733
+ return {
734
+ content: [{
735
+ type: "text",
736
+ text: JSON.stringify({
737
+ localVars: Object.keys(localVars),
738
+ remote: "VERCEL_TOKEN not set — cannot fetch remote env vars",
739
+ }, null, 2),
740
+ }],
741
+ };
742
+ }
743
+ // Fetch Vercel env vars
744
+ let remoteVars = {};
745
+ const envUrl = projectId
746
+ ? `https://api.vercel.com/v9/projects/${projectId}/env`
747
+ : null;
748
+ if (envUrl) {
749
+ try {
750
+ const res = await fetch(envUrl, {
751
+ headers: { Authorization: `Bearer ${token}` },
752
+ });
753
+ const data = await res.json();
754
+ for (const env of data.envs ?? []) {
755
+ remoteVars[env.key] = env.value ?? "(encrypted)";
756
+ }
757
+ }
758
+ catch { /* skip */ }
759
+ }
760
+ const localKeys = new Set(Object.keys(localVars));
761
+ const remoteKeys = new Set(Object.keys(remoteVars));
762
+ const onlyLocal = [...localKeys].filter((k) => !remoteKeys.has(k));
763
+ const onlyRemote = [...remoteKeys].filter((k) => !localKeys.has(k));
764
+ const shared = [...localKeys].filter((k) => remoteKeys.has(k));
765
+ if (action === "diff" || !action) {
766
+ return {
767
+ content: [{
768
+ type: "text",
769
+ text: JSON.stringify({
770
+ localCount: localKeys.size,
771
+ remoteCount: remoteKeys.size,
772
+ onlyInLocal: onlyLocal,
773
+ onlyInRemote: onlyRemote,
774
+ inBoth: shared.length,
775
+ note: projectId ? undefined : "Set VERCEL_PROJECT_ID for remote env comparison",
776
+ }, null, 2),
777
+ }],
778
+ };
779
+ }
780
+ if (action === "pull") {
781
+ const { writeFileSync } = await import("fs");
782
+ const lines = Object.entries(remoteVars).map(([k, v]) => `${k}=${v}`);
783
+ writeFileSync(".env.pulled", lines.join("\n") + "\n");
784
+ return {
785
+ content: [{ type: "text", text: JSON.stringify({ pulled: true, file: ".env.pulled", count: lines.length }) }],
786
+ };
787
+ }
788
+ if (action === "push") {
789
+ if (!confirm) {
790
+ return {
791
+ content: [{ type: "text", text: JSON.stringify({ message: "Push requires confirm: true. This will overwrite remote env vars.", varsToUpload: onlyLocal.length + shared.length }) }],
792
+ };
793
+ }
794
+ if (!projectId || !envUrl) {
795
+ return { content: [{ type: "text", text: JSON.stringify({ error: "VERCEL_PROJECT_ID required for push" }) }] };
796
+ }
797
+ let uploaded = 0;
798
+ for (const [key, value] of Object.entries(localVars)) {
799
+ await fetch(envUrl, {
800
+ method: "POST",
801
+ headers: {
802
+ Authorization: `Bearer ${token}`,
803
+ "Content-Type": "application/json",
804
+ },
805
+ body: JSON.stringify({
806
+ key,
807
+ value,
808
+ type: "encrypted",
809
+ target: ["production", "preview", "development"],
810
+ }),
811
+ });
812
+ uploaded++;
813
+ }
814
+ return {
815
+ content: [{ type: "text", text: JSON.stringify({ pushed: true, uploaded }) }],
816
+ };
817
+ }
818
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Unknown action" }) }] };
819
+ });
820
+ // ──────────────────────────────────────────
821
+ // Tool: stk_perf
822
+ // ──────────────────────────────────────────
823
+ server.tool("stk_perf", "Check performance across your stack: Supabase query latency, table sizes, Vercel deploy times, and API response times.", {
824
+ tables: z.array(z.string()).optional().describe("Specific Supabase tables to benchmark (defaults to all detected)"),
825
+ }, async ({ tables }) => {
826
+ const perf = {};
827
+ // Supabase performance
828
+ const url = process.env.SUPABASE_URL;
829
+ const key = process.env.SUPABASE_SERVICE_KEY;
830
+ if (url && key) {
831
+ const headers = {
832
+ apikey: key,
833
+ Authorization: `Bearer ${key}`,
834
+ Prefer: "count=exact",
835
+ };
836
+ // Auto-detect tables or use provided list
837
+ const tablesToCheck = tables ?? ["posts", "users", "payments"];
838
+ const tableStats = [];
839
+ for (const table of tablesToCheck) {
840
+ const start = Date.now();
841
+ try {
842
+ const res = await fetch(`${url}/rest/v1/${table}?select=id&limit=0`, {
843
+ headers,
844
+ });
845
+ const latency = Date.now() - start;
846
+ const range = res.headers.get("content-range");
847
+ const count = range ? parseInt(range.split("/")[1]) || 0 : null;
848
+ let status = "fast";
849
+ if (latency > 1000)
850
+ status = "slow";
851
+ else if (latency > 500)
852
+ status = "moderate";
853
+ tableStats.push({ table, rowCount: count, queryLatency: latency, status });
854
+ }
855
+ catch {
856
+ tableStats.push({ table, rowCount: null, queryLatency: Date.now() - start, status: "error" });
857
+ }
858
+ }
859
+ // API latency test
860
+ const apiStart = Date.now();
861
+ await fetch(`${url}/rest/v1/`, { headers }).catch(() => null);
862
+ const apiLatency = Date.now() - apiStart;
863
+ perf.supabase = {
864
+ apiLatency,
865
+ tables: tableStats,
866
+ recommendation: tableStats.some((t) => t.status === "slow")
867
+ ? "Some queries are slow. Consider adding database indexes."
868
+ : tableStats.some((t) => (t.rowCount ?? 0) > 10000)
869
+ ? "Large tables detected. Ensure you have indexes on frequently queried columns."
870
+ : "Performance looks good.",
871
+ };
872
+ }
873
+ // Vercel deploy performance
874
+ if (process.env.VERCEL_TOKEN) {
875
+ try {
876
+ const res = await fetch("https://api.vercel.com/v6/deployments?limit=5", {
877
+ headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
878
+ });
879
+ const data = await res.json();
880
+ const deploys = (data.deployments ?? []).map((d) => {
881
+ const buildDuration = d.buildingAt && d.ready
882
+ ? Math.round((d.ready - d.buildingAt) / 1000)
883
+ : null;
884
+ return {
885
+ id: d.uid,
886
+ state: d.readyState ?? d.state,
887
+ buildDuration: buildDuration ? `${buildDuration}s` : "unknown",
888
+ created: new Date(d.created).toISOString(),
889
+ };
890
+ });
891
+ perf.vercel = {
892
+ recentDeploys: deploys,
893
+ avgBuildTime: deploys.filter((d) => d.buildDuration !== "unknown").length > 0
894
+ ? "see individual deploys"
895
+ : "no build data available",
896
+ };
897
+ }
898
+ catch { /* skip */ }
899
+ }
900
+ // Stripe API latency
901
+ if (process.env.STRIPE_SECRET_KEY) {
902
+ const start = Date.now();
903
+ await fetch("https://api.stripe.com/v1/balance", {
904
+ headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
905
+ }).catch(() => null);
906
+ perf.stripe = { apiLatency: Date.now() - start };
907
+ }
908
+ return {
909
+ content: [{ type: "text", text: JSON.stringify({ performance: perf }, null, 2) }],
910
+ };
911
+ });
912
+ // ──────────────────────────────────────────
913
+ // Tool: stk_cost
914
+ // ──────────────────────────────────────────
915
+ server.tool("stk_cost", "Track costs across your stack: Stripe fees, Vercel usage, Supabase plan details. Get a unified view of what you're spending.", {}, async () => {
916
+ const costs = {};
917
+ // Stripe revenue & fees
918
+ if (process.env.STRIPE_SECRET_KEY) {
919
+ try {
920
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
921
+ // Balance
922
+ const balRes = await fetch("https://api.stripe.com/v1/balance", {
923
+ headers: { Authorization: `Bearer ${stripeKey}` },
924
+ });
925
+ const balData = await balRes.json();
926
+ // Recent balance transactions for fee tracking
927
+ const txRes = await fetch("https://api.stripe.com/v1/balance_transactions?limit=100", {
928
+ headers: { Authorization: `Bearer ${stripeKey}` },
929
+ });
930
+ const txData = await txRes.json();
931
+ const transactions = txData.data ?? [];
932
+ const totalFees = transactions.reduce((sum, t) => sum + (t.fee || 0), 0);
933
+ const totalGross = transactions.reduce((sum, t) => sum + (t.amount > 0 ? t.amount : 0), 0);
934
+ const totalNet = transactions.reduce((sum, t) => sum + (t.net || 0), 0);
935
+ costs.stripe = {
936
+ mode: stripeKey.startsWith("sk_live") ? "live" : "test",
937
+ balance: balData.available?.map((b) => ({
938
+ amount: (b.amount / 100).toFixed(2),
939
+ currency: b.currency.toUpperCase(),
940
+ })) ?? [],
941
+ recentTransactions: transactions.length,
942
+ totalGross: (totalGross / 100).toFixed(2),
943
+ totalFees: (totalFees / 100).toFixed(2),
944
+ totalNet: (totalNet / 100).toFixed(2),
945
+ feePercentage: totalGross > 0 ? ((totalFees / totalGross) * 100).toFixed(1) + "%" : "N/A",
946
+ };
947
+ }
948
+ catch (err) {
949
+ costs.stripe = { error: err.message };
950
+ }
951
+ }
952
+ // Vercel usage
953
+ if (process.env.VERCEL_TOKEN) {
954
+ try {
955
+ // Get team/user info for billing context
956
+ const userRes = await fetch("https://api.vercel.com/v2/user", {
957
+ headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
958
+ });
959
+ const userData = await userRes.json();
960
+ // Count deployments
961
+ const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=100", {
962
+ headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
963
+ });
964
+ const depData = await depRes.json();
965
+ const deployments = depData.deployments ?? [];
966
+ // Deployments this month
967
+ const monthStart = new Date();
968
+ monthStart.setDate(1);
969
+ monthStart.setHours(0, 0, 0, 0);
970
+ const thisMonth = deployments.filter((d) => new Date(d.created) >= monthStart);
971
+ costs.vercel = {
972
+ plan: userData.user?.billing?.plan ?? "unknown",
973
+ deploymentsThisMonth: thisMonth.length,
974
+ totalDeployments: deployments.length,
975
+ note: "Vercel free tier includes 100 deployments/day. Check vercel.com/dashboard for detailed billing.",
976
+ };
977
+ }
978
+ catch { /* skip */ }
979
+ }
980
+ // Supabase usage estimate
981
+ if (process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_KEY) {
982
+ const url = process.env.SUPABASE_URL;
983
+ const key = process.env.SUPABASE_SERVICE_KEY;
984
+ const headers = {
985
+ apikey: key,
986
+ Authorization: `Bearer ${key}`,
987
+ Prefer: "count=exact",
988
+ };
989
+ const tableCounts = {};
990
+ for (const table of ["posts", "users", "payments"]) {
991
+ try {
992
+ const res = await fetch(`${url}/rest/v1/${table}?select=id&limit=0`, { headers });
993
+ const range = res.headers.get("content-range");
994
+ tableCounts[table] = range ? parseInt(range.split("/")[1]) || 0 : null;
995
+ }
996
+ catch {
997
+ tableCounts[table] = null;
998
+ }
999
+ }
1000
+ const totalRows = Object.values(tableCounts).reduce((s, v) => s + (v ?? 0), 0);
1001
+ costs.supabase = {
1002
+ tables: tableCounts,
1003
+ totalRows,
1004
+ note: "Supabase free tier: 500MB database, 1GB storage, 50k monthly active users. Check supabase.com/dashboard for detailed usage.",
1005
+ };
1006
+ }
1007
+ return {
1008
+ content: [{ type: "text", text: JSON.stringify({ costs }, null, 2) }],
1009
+ };
1010
+ });
1011
+ // ──────────────────────────────────────────
1012
+ // Brain: Supabase Knowledge Base Client
1013
+ // ──────────────────────────────────────────
1014
+ function getBrainClient() {
1015
+ const url = process.env.SUPABASE_URL;
1016
+ const key = process.env.SUPABASE_SERVICE_KEY;
1017
+ if (!url || !key)
1018
+ return null;
1019
+ return {
1020
+ async query(table, params = {}) {
1021
+ const searchParams = new URLSearchParams(params);
1022
+ const res = await fetch(`${url}/rest/v1/${table}?${searchParams}`, {
1023
+ headers: {
1024
+ apikey: key,
1025
+ Authorization: `Bearer ${key}`,
1026
+ "Content-Type": "application/json",
1027
+ Prefer: "count=exact",
1028
+ },
1029
+ });
1030
+ const count = res.headers.get("content-range")?.split("/")[1] ?? null;
1031
+ const data = await res.json();
1032
+ return { data, count, ok: res.ok };
1033
+ },
1034
+ async insert(table, row) {
1035
+ const res = await fetch(`${url}/rest/v1/${table}`, {
1036
+ method: "POST",
1037
+ headers: {
1038
+ apikey: key,
1039
+ Authorization: `Bearer ${key}`,
1040
+ "Content-Type": "application/json",
1041
+ Prefer: "return=representation",
1042
+ },
1043
+ body: JSON.stringify(row),
1044
+ });
1045
+ const data = await res.json();
1046
+ return { data, ok: res.ok };
1047
+ },
1048
+ };
1049
+ }
1050
+ // ──────────────────────────────────────────
1051
+ // Tool: stk_brain_search
1052
+ // ──────────────────────────────────────────
1053
+ server.tool("stk_brain_search", "Search the knowledge base for SaaS patterns, best practices, and architecture examples from top open-source projects (LangChain, Ollama, Transformers, llama.cpp, vLLM, AutoGen, OpenAI Cookbook). Use this when you need to know how successful projects solve specific problems.", {
1054
+ query: z.string().describe("What to search for (e.g., 'authentication', 'real-time updates', 'payment integration')"),
1055
+ category: z.string().optional().describe("Filter: architecture, auth, payments, database, api, deployment, testing, performance, security, ml, realtime, general"),
1056
+ }, async ({ query, category }) => {
1057
+ const brain = getBrainClient();
1058
+ if (!brain)
1059
+ return { content: [{ type: "text", text: JSON.stringify({ error: "SUPABASE_URL or SUPABASE_SERVICE_KEY not set" }) }] };
1060
+ // Try ilike search on content and title
1061
+ const words = query.split(" ").filter(w => w.length > 2);
1062
+ const searchWord = words[0] ?? query;
1063
+ const params = {
1064
+ or: `(title.ilike.%${searchWord}%,content.ilike.%${searchWord}%)`,
1065
+ limit: "10",
1066
+ order: "source",
1067
+ };
1068
+ if (category)
1069
+ params["category"] = `eq.${category}`;
1070
+ const { data, ok } = await brain.query("knowledge", params);
1071
+ if (!ok)
1072
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Query failed", data }) }] };
1073
+ return {
1074
+ content: [{
1075
+ type: "text",
1076
+ text: JSON.stringify({
1077
+ query,
1078
+ results: (data ?? []).map((r) => ({ source: r.source, category: r.category, title: r.title, content: r.content, tags: r.tags })),
1079
+ total: data?.length ?? 0,
1080
+ }, null, 2),
1081
+ }],
1082
+ };
1083
+ });
1084
+ // ──────────────────────────────────────────
1085
+ // Tool: stk_brain_patterns
1086
+ // ──────────────────────────────────────────
1087
+ server.tool("stk_brain_patterns", "Get best practice patterns for a specific feature. Returns how top projects implement auth, payments, real-time, caching, APIs, etc.", {
1088
+ feature: z.string().describe("The feature or pattern (e.g., 'authentication', 'webhooks', 'caching', 'model serving', 'fine-tuning')"),
1089
+ }, async ({ feature }) => {
1090
+ const brain = getBrainClient();
1091
+ if (!brain)
1092
+ return { content: [{ type: "text", text: JSON.stringify({ error: "SUPABASE_URL or SUPABASE_SERVICE_KEY not set" }) }] };
1093
+ const { data, ok } = await brain.query("knowledge", {
1094
+ or: `(title.ilike.%${feature}%,content.ilike.%${feature}%)`,
1095
+ limit: "15",
1096
+ order: "source",
1097
+ });
1098
+ if (!ok)
1099
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Query failed" }) }] };
1100
+ // Group by source
1101
+ const grouped = {};
1102
+ for (const item of data ?? []) {
1103
+ if (!grouped[item.source])
1104
+ grouped[item.source] = [];
1105
+ grouped[item.source].push({ title: item.title, category: item.category, content: item.content });
1106
+ }
1107
+ return {
1108
+ content: [{
1109
+ type: "text",
1110
+ text: JSON.stringify({
1111
+ feature,
1112
+ patterns: grouped,
1113
+ totalSources: Object.keys(grouped).length,
1114
+ totalPatterns: data?.length ?? 0,
1115
+ }, null, 2),
1116
+ }],
1117
+ };
1118
+ });
1119
+ // ──────────────────────────────────────────
1120
+ // Tool: stk_brain_stack
1121
+ // ──────────────────────────────────────────
1122
+ server.tool("stk_brain_stack", "Get recommendations specific to YOUR stack (Supabase + Vercel + Stripe + Node.js). Filters knowledge for patterns matching your technology choices.", {
1123
+ question: z.string().describe("What you want to build or solve (e.g., 'add user auth', 'implement webhooks', 'optimize queries')"),
1124
+ }, async ({ question }) => {
1125
+ const brain = getBrainClient();
1126
+ if (!brain)
1127
+ return { content: [{ type: "text", text: JSON.stringify({ error: "SUPABASE_URL or SUPABASE_SERVICE_KEY not set" }) }] };
1128
+ const words = question.split(" ").filter(w => w.length > 3);
1129
+ const searchWord = words[0] ?? question;
1130
+ const { data, ok } = await brain.query("knowledge", {
1131
+ or: `(content.ilike.%${searchWord}%,title.ilike.%${searchWord}%)`,
1132
+ limit: "10",
1133
+ });
1134
+ if (!ok)
1135
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Query failed" }) }] };
1136
+ return {
1137
+ content: [{
1138
+ type: "text",
1139
+ text: JSON.stringify({
1140
+ question,
1141
+ stack: ["Supabase", "Vercel", "Stripe", "Node.js/Express"],
1142
+ recommendations: (data ?? []).map((r) => ({ source: r.source, title: r.title, content: r.content, relevance: r.category })),
1143
+ total: data?.length ?? 0,
1144
+ }, null, 2),
1145
+ }],
1146
+ };
1147
+ });
1148
+ // ──────────────────────────────────────────
1149
+ // Tool: stk_brain_learn
1150
+ // ──────────────────────────────────────────
1151
+ server.tool("stk_brain_learn", "Save new knowledge to the brain. Use this to remember patterns, solutions, or learnings for future reference across all projects.", {
1152
+ title: z.string().describe("Short title"),
1153
+ content: z.string().describe("The knowledge — pattern, solution, or best practice"),
1154
+ source: z.string().optional().describe("Where this came from (e.g., 'project:worldchat', 'github:vercel/next.js')"),
1155
+ category: z.string().optional().describe("Category: architecture, auth, payments, database, api, deployment, testing, performance, security, ml, general"),
1156
+ tags: z.array(z.string()).optional().describe("Tags for searchability"),
1157
+ }, async ({ title, content, source, category, tags }) => {
1158
+ const brain = getBrainClient();
1159
+ if (!brain)
1160
+ return { content: [{ type: "text", text: JSON.stringify({ error: "SUPABASE_URL or SUPABASE_SERVICE_KEY not set" }) }] };
1161
+ const { data, ok } = await brain.insert("knowledge", {
1162
+ title,
1163
+ content,
1164
+ source: source ?? "manual",
1165
+ category: category ?? "general",
1166
+ tags: tags ?? [],
1167
+ created_at: new Date().toISOString(),
1168
+ });
1169
+ if (!ok)
1170
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Insert failed", data }) }] };
1171
+ return {
1172
+ content: [{
1173
+ type: "text",
1174
+ text: JSON.stringify({ learned: true, title, message: "Knowledge saved. I can recall this in future conversations." }, null, 2),
1175
+ }],
1176
+ };
1177
+ });
1178
+ // ──────────────────────────────────────────
1179
+ // Tool: stk_brain_stats
1180
+ // ──────────────────────────────────────────
1181
+ server.tool("stk_brain_stats", "Check what the brain knows — total knowledge entries, categories, sources, and coverage.", {}, async () => {
1182
+ const brain = getBrainClient();
1183
+ if (!brain)
1184
+ return { content: [{ type: "text", text: JSON.stringify({ error: "SUPABASE_URL or SUPABASE_SERVICE_KEY not set" }) }] };
1185
+ const { data, count } = await brain.query("knowledge", { select: "category,source", limit: "1000" });
1186
+ const categories = {};
1187
+ const sources = {};
1188
+ for (const row of data ?? []) {
1189
+ categories[row.category] = (categories[row.category] || 0) + 1;
1190
+ sources[row.source] = (sources[row.source] || 0) + 1;
1191
+ }
1192
+ return {
1193
+ content: [{
1194
+ type: "text",
1195
+ text: JSON.stringify({
1196
+ totalKnowledge: count ?? data?.length ?? 0,
1197
+ categories,
1198
+ sources,
1199
+ topSources: Object.entries(sources)
1200
+ .sort(([, a], [, b]) => b - a)
1201
+ .slice(0, 10)
1202
+ .map(([name, count]) => ({ name, count })),
1203
+ }, null, 2),
1204
+ }],
1205
+ };
1206
+ });
369
1207
  // Helper
370
1208
  function detectGitHubRepo() {
371
1209
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@prajwolkc/stk",
3
- "version": "0.2.1",
4
- "description": "One CLI to deploy, monitor, and debug your entire stack. Health checks, deploy watching, env sync, logs, and GitHub issues — all from one command.",
3
+ "version": "0.4.0",
4
+ "description": "One CLI to deploy, monitor, debug, and learn about your entire stack. Infrastructure monitoring, knowledge base brain, deploy watching, and GitHub issues — all from one command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "prajwolkc",