lopebase 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +87 -20
  2. package/dist/lopebase.js +312 -2
  3. package/package.json +17 -7
package/README.md CHANGED
@@ -1,28 +1,95 @@
1
- # lopebase
1
+ <p align="center">
2
+ <a href="https://lopebase.com"><img src="https://lopebase.com/icon-512.png" width="72" height="72" alt="LopeBase"></a>
3
+ </p>
2
4
 
3
- Set up a project in LopeBase from its own repo. Your coding agent does the steps
4
- and stops for you only when it has to. See ADR 0020.
5
+ <h1 align="center">lopebase</h1>
6
+
7
+ <p align="center">
8
+ Set up <a href="https://lopebase.com">LopeBase</a> from your project's own folder.<br>
9
+ Your coding agent does the steps. It stops for you only when it has to.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://lopebase.com">Website</a> ·
14
+ <a href="https://lopebase.com/agents">For agents</a> ·
15
+ <a href="https://lopebase.com/pricing">Pricing</a> ·
16
+ <a href="https://www.npmjs.com/package/@lopebase/adapter">@lopebase/adapter</a>
17
+ </p>
18
+
19
+ ---
20
+
21
+ LopeBase is one place to see and run all of your products: traffic, search,
22
+ signups, revenue, and errors side by side, with a weekly summary and an agent
23
+ that can help. This CLI connects a project to it in a few minutes.
24
+
25
+ ## Quick start
26
+
27
+ In the folder of any project you run:
5
28
 
6
29
  ```sh
7
- npx lopebase # scan, pick what to connect, hand off to your agent
8
- npx lopebase login # connect this computer: approve a code in the browser
9
- npx lopebase scan # what the scan found (--json for machines)
10
- npx lopebase prompt # print the agent prompt instead of launching an agent
11
- npx lopebase mcp # stdio MCP server that forwards to your workspace
30
+ npx lopebase
12
31
  ```
13
32
 
14
- `login` uses the device flow: it shows a short code, opens your workspace, and
15
- waits while you press Connect. `login --paste` takes a token from Settings,
16
- Agent access instead, for workspaces that do not offer device sign-in.
33
+ 1. **Scan.** It reads your `package.json` files, config file names, and the
34
+ variable *names* in example env files, then shows your stack, the services
35
+ you already use, and what is missing (for example analytics or error
36
+ tracking).
37
+ 2. **Connect this computer.** It opens your LopeBase workspace and shows a
38
+ short code. Check it matches and press Connect.
39
+ 3. **Pick what to connect.** Services already in your code are ticked; the
40
+ ones you do not use yet are offered, never forced.
41
+ 4. **Hand over to your agent.** It finds the coding agents you have installed
42
+ (Claude Code, Codex, Gemini CLI, opencode, Cursor Agent), gives the one you
43
+ choose the LopeBase tools, and starts it with what the scan found. The agent
44
+ does the setup and asks you when it needs a sign-in, a key, or a decision.
45
+
46
+ No agent installed? It prints the prompt for you to paste instead.
47
+
48
+ ## Commands
49
+
50
+ | Command | What it does |
51
+ | --- | --- |
52
+ | `npx lopebase` | Scan, connect, choose, and hand over to your agent. |
53
+ | `npx lopebase login` | Connect this computer to your workspace with a short code. `--paste` to paste an agent token instead. |
54
+ | `npx lopebase scan` | Show what the scan found. `--json` for scripts. |
55
+ | `npx lopebase prompt` | Print the setup prompt instead of starting an agent. |
56
+ | `npx lopebase mcp` | Run the LopeBase tools as a local MCP server (stdio) for any agent. |
57
+ | `npx lopebase adapter secret` | Print a new signing secret for [@lopebase/adapter](https://www.npmjs.com/package/@lopebase/adapter). Pipe it straight into your secret store. |
58
+ | `npx lopebase adapter check <url>` | Test a running adapter, local or deployed, before you connect it. |
59
+
60
+ ## What it reads, and what it keeps
61
+
62
+ - **Reads:** `package.json` files at the root and in `apps/`, `packages/`,
63
+ and similar folders; config file names such as `wrangler.toml` or
64
+ `vercel.json`; and the variable names in `.env.example` style files.
65
+ - **Never reads** a real `.env` file, and never sends a value anywhere. Only
66
+ names leave your machine, and only to your own workspace.
67
+ - **Keeps** one thing: an agent token in `~/.config/lopebase/auth.json`,
68
+ readable only by you. It lasts 30 days, acts as the workspace admin who
69
+ approved it, is limited to what that admin allowed, and can be revoked any
70
+ time under Settings, Agent access.
71
+ - `LOPEBASE_TOKEN` and `LOPEBASE_URL` override the saved values, for CI or
72
+ another machine.
73
+
74
+ ## Use it with any agent
75
+
76
+ `npx lopebase` sets this up for you. To add the tools by hand, register this
77
+ as a stdio MCP server in your agent:
78
+
79
+ ```sh
80
+ npx -y lopebase mcp
81
+ ```
82
+
83
+ Or point an agent that supports remote MCP straight at your workspace, for
84
+ example `https://yourteam.lopebase.com/api/mcp`. It will ask you to sign in.
85
+
86
+ ## Requirements
87
+
88
+ Node 18 or newer. Works on macOS, Linux, and Windows.
17
89
 
18
- From this monorepo: `bun run lopebase` (from the repo root). To publish:
19
- `bun run build` makes `dist/lopebase.js` for Node, and `npm publish` runs
20
- verify and build first.
90
+ ## Help
21
91
 
22
- The scan reads package.json files, config file names, and env var names from
23
- example env files. It never reads a real `.env` file and never sends a value.
24
- The token is saved to `~/.config/lopebase/auth.json` (mode 600), or read from
25
- `LOPEBASE_TOKEN` and `LOPEBASE_URL`.
92
+ Questions or problems: [contact@jackalope.digital](mailto:contact@jackalope.digital).
93
+ Privacy: [app.lopebase.com/legal/privacy](https://app.lopebase.com/legal/privacy).
26
94
 
27
- `src/detect.ts` is also imported by the backoffice's `detect_stack` tool, so the
28
- terminal and the server give the same answer.
95
+ LopeBase is made by [Jackalope Digital](https://jackalope.digital).
package/dist/lopebase.js CHANGED
@@ -812,9 +812,282 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
812
812
  } };
813
813
  };
814
814
 
815
+ // ../admin-adapter/src/security.ts
816
+ var SIGNATURE_HEADER = "lopebase-signature";
817
+ var enc = /* @__PURE__ */ new TextEncoder;
818
+ function hex(bytes) {
819
+ return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
820
+ }
821
+ async function sha256Hex(data) {
822
+ return hex(await crypto.subtle.digest("SHA-256", data));
823
+ }
824
+ async function hmacHex(secret, message) {
825
+ let key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, !1, ["sign"]);
826
+ return hex(await crypto.subtle.sign("HMAC", key, enc.encode(message)));
827
+ }
828
+ function newSigningSecret() {
829
+ let bytes = crypto.getRandomValues(new Uint8Array(32)), bin = "";
830
+ for (let b of bytes)
831
+ bin += String.fromCharCode(b);
832
+ return "lbs_" + btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
833
+ }
834
+ function payload(t, method, pathAndQuery, bodyHash) {
835
+ return `${t}.${method.toUpperCase()}.${pathAndQuery}.${bodyHash}`;
836
+ }
837
+ async function signRequest(input) {
838
+ let t = input.timestamp ?? Math.floor(Date.now() / 1000), body = typeof input.body === "string" ? enc.encode(input.body) : input.body ?? new Uint8Array, sig = await hmacHex(input.secret, payload(t, input.method, input.pathAndQuery, await sha256Hex(body)));
839
+ return `t=${t},v1=${sig}`;
840
+ }
841
+ var SENSITIVE_PARTS = /* @__PURE__ */ new Set([
842
+ "password",
843
+ "passwd",
844
+ "passcode",
845
+ "pwd",
846
+ "hash",
847
+ "hashed",
848
+ "salt",
849
+ "secret",
850
+ "secrets",
851
+ "token",
852
+ "tokens",
853
+ "credential",
854
+ "credentials",
855
+ "ssn",
856
+ "card",
857
+ "cvv",
858
+ "cvc",
859
+ "iban",
860
+ "otp",
861
+ "mfa",
862
+ "totp",
863
+ "session",
864
+ "cookie"
865
+ ]), SENSITIVE_JOINED = /apikey|privatekey|secretkey|accesskey|accountnumber|routingnumber|stripecustomer|paymentmethod|recoverycode/;
866
+ function isSensitiveFieldName(name) {
867
+ let parts = name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
868
+ if (parts.some((p) => SENSITIVE_PARTS.has(p)))
869
+ return !0;
870
+ return SENSITIVE_JOINED.test(parts.join(""));
871
+ }
872
+
873
+ // ../admin-adapter/src/adapter.ts
874
+ var DEFAULT_BASE_PATH = "/api/lopebase";
875
+ // ../admin-adapter/src/conformance.ts
876
+ import { strict as assert } from "node:assert";
877
+ async function body(res) {
878
+ return await res.json();
879
+ }
880
+ function conformanceChecks(opts) {
881
+ if (!opts.adapter && !opts.baseUrl)
882
+ throw Error("conformanceChecks needs an adapter or a baseUrl");
883
+ let remote = !opts.adapter, base = opts.baseUrl ? opts.baseUrl.replace(/\/$/, "") : `${opts.origin ?? "https://property.test"}${(opts.basePath ?? opts.adapter?.config.basePath ?? DEFAULT_BASE_PATH).replace(/\/$/, "")}`, send = (req) => opts.adapter ? opts.adapter.handle(req) : fetch(req);
884
+ async function signedHeaders(url, method, bodyText, timestamp) {
885
+ if (opts.secret) {
886
+ let u = new URL(url);
887
+ return {
888
+ [SIGNATURE_HEADER]: await signRequest({
889
+ secret: opts.secret,
890
+ method,
891
+ pathAndQuery: u.pathname + u.search,
892
+ body: bodyText,
893
+ timestamp
894
+ })
895
+ };
896
+ }
897
+ return opts.token ? { authorization: `Bearer ${opts.token}` } : {};
898
+ }
899
+ let authed = async (path, init) => {
900
+ let url = base + path, method = init?.method ?? "GET", bodyText = typeof init?.body === "string" ? init.body : void 0;
901
+ return send(new Request(url, {
902
+ ...init,
903
+ headers: {
904
+ "content-type": "application/json",
905
+ ...await signedHeaders(url, method, bodyText),
906
+ ...init?.headers
907
+ }
908
+ }));
909
+ }, resources = () => authed("/resources").then((r) => body(r));
910
+ return [
911
+ {
912
+ name: "GET /health returns { ok } without auth",
913
+ async run() {
914
+ let res = await send(new Request(`${base}/health`));
915
+ assert.equal(res.status, 200);
916
+ let b = await body(res);
917
+ assert.equal(typeof b.ok, "boolean");
918
+ }
919
+ },
920
+ {
921
+ name: "requests without a valid token get 401",
922
+ async run() {
923
+ let res = await send(new Request(`${base}/resources`));
924
+ assert.equal(res.status, 401);
925
+ }
926
+ },
927
+ {
928
+ name: "GET /resources returns a catalog with field metadata",
929
+ async run() {
930
+ let res = await authed("/resources");
931
+ assert.equal(res.status, 200);
932
+ let b = await body(res);
933
+ assert.ok(Array.isArray(b.resources));
934
+ for (let r of b.resources)
935
+ assert.ok(r.name && r.label && r.readCapability), assert.ok(Array.isArray(r.fields));
936
+ }
937
+ },
938
+ {
939
+ name: "GET /actions returns a catalog with param specs",
940
+ async run() {
941
+ let res = await authed("/actions");
942
+ assert.equal(res.status, 200);
943
+ let b = await body(res);
944
+ assert.ok(Array.isArray(b.actions));
945
+ for (let a of b.actions)
946
+ assert.ok(a.name && a.label && a.capability), assert.ok(Array.isArray(a.params));
947
+ }
948
+ },
949
+ {
950
+ name: "GET /resources/:name paginates ({ rows, total? })",
951
+ async run() {
952
+ let cat = await resources(), name = opts.sampleResource ?? cat.resources[0]?.name;
953
+ if (!name)
954
+ return;
955
+ let res = await authed(`/resources/${name}?limit=5`);
956
+ assert.equal(res.status, 200);
957
+ let page = await body(res);
958
+ assert.ok(Array.isArray(page.rows));
959
+ }
960
+ },
961
+ {
962
+ name: "GET /resources/:name/:id returns a row or 404-null",
963
+ async run() {
964
+ let cat = await resources(), meta = opts.sampleResource ? cat.resources.find((r) => r.name === opts.sampleResource) : cat.resources[0];
965
+ if (!meta)
966
+ return;
967
+ let first = (await body(await authed(`/resources/${meta.name}?limit=1`))).rows[0];
968
+ if (!first)
969
+ return;
970
+ let idField = meta.fields[0]?.name ?? "id", res = await authed(`/resources/${meta.name}/${encodeURIComponent(String(first[idField]))}`);
971
+ assert.ok(res.status === 200 || res.status === 404);
972
+ }
973
+ },
974
+ {
975
+ name: "GET /audit returns { rows: AuditEvent[] }",
976
+ async run() {
977
+ let res = await authed("/audit");
978
+ assert.equal(res.status, 200);
979
+ let b = await body(res);
980
+ assert.ok(Array.isArray(b.rows));
981
+ }
982
+ },
983
+ {
984
+ name: "an unknown route returns 404",
985
+ async run() {
986
+ let res = await authed("/does-not-exist");
987
+ assert.equal(res.status, 404);
988
+ }
989
+ },
990
+ {
991
+ name: "no field in the catalog looks like a secret unless marked safe",
992
+ async run() {
993
+ let cat = await resources();
994
+ for (let r of cat.resources)
995
+ for (let f of r.fields)
996
+ assert.ok(!isSensitiveFieldName(f.name) || f.sensitive === "allow", `${r.name}.${f.name} looks sensitive`);
997
+ }
998
+ },
999
+ {
1000
+ name: "rows contain only declared fields, and a page is at most 100",
1001
+ async run() {
1002
+ let cat = await resources();
1003
+ for (let r of cat.resources) {
1004
+ let res = await authed(`/resources/${r.name}?limit=1000`);
1005
+ assert.equal(res.status, 200);
1006
+ let page = await body(res);
1007
+ assert.ok(page.rows.length <= 100, `${r.name} returned ${page.rows.length} rows`);
1008
+ let declared = /* @__PURE__ */ new Set(["id", ...r.fields.map((f) => f.name)]);
1009
+ for (let row of page.rows)
1010
+ for (let k of Object.keys(row))
1011
+ assert.ok(declared.has(k), `${r.name} returned undeclared field ${k}`);
1012
+ }
1013
+ }
1014
+ },
1015
+ ...opts.secret ? [
1016
+ {
1017
+ name: "a stale or tampered signature gets 401",
1018
+ async run() {
1019
+ let url = `${base}/resources`, stale = await signedHeaders(url, "GET", void 0, Math.floor(Date.now() / 1000) - 3600);
1020
+ assert.equal((await send(new Request(url, { headers: stale }))).status, 401);
1021
+ let good = await signedHeaders(url, "GET", void 0), tampered = `${url}?limit=1`;
1022
+ assert.equal((await send(new Request(tampered, { headers: good }))).status, 401);
1023
+ }
1024
+ }
1025
+ ] : [],
1026
+ {
1027
+ name: "a mutation writes an audit row (PATCH round-trips into /audit)",
1028
+ async run() {
1029
+ let writable = (await resources()).resources.find((r) => r.writeCapability && r.fields.some((f) => f.editable));
1030
+ if (!writable || remote)
1031
+ return;
1032
+ let row = (await body(await authed(`/resources/${writable.name}?limit=1`))).rows[0];
1033
+ if (!row)
1034
+ return;
1035
+ let idField = writable.fields[0]?.name ?? "id", editField = writable.fields.find((f) => f.editable)?.name;
1036
+ if (!editField)
1037
+ return;
1038
+ let before = await body(await authed("/audit")), res = await authed(`/resources/${writable.name}/${encodeURIComponent(String(row[idField]))}`, {
1039
+ method: "PATCH",
1040
+ body: JSON.stringify({ patch: { [editField]: row[editField] } })
1041
+ });
1042
+ assert.equal(res.status, 200);
1043
+ let after = await body(await authed("/audit"));
1044
+ assert.ok(after.rows.length > before.rows.length, "expected a new audit row");
1045
+ }
1046
+ }
1047
+ ];
1048
+ }
815
1049
  // bin/lopebase.ts
816
1050
  var import_picocolors3 = __toESM(require_picocolors(), 1);
817
1051
 
1052
+ // src/adapter-check.ts
1053
+ async function checkAdapter(input) {
1054
+ let log = input.log ?? (() => {}), failed = [], passed = 0, base = input.baseUrl.replace(/\/$/, ""), url = new URL(`${base}/resources`), status;
1055
+ try {
1056
+ let headers = input.secret ? {
1057
+ [SIGNATURE_HEADER]: await signRequest({
1058
+ secret: input.secret,
1059
+ method: "GET",
1060
+ pathAndQuery: url.pathname + url.search
1061
+ })
1062
+ } : { authorization: `Bearer ${input.token ?? ""}` };
1063
+ status = (await fetch(url, { headers })).status;
1064
+ } catch (e) {
1065
+ let error = `Could not reach ${base} (${e instanceof Error ? e.message : String(e)}).`;
1066
+ return log(`FAIL ${error}`), { passed, failed: [{ name: "reachable", error }] };
1067
+ }
1068
+ if (status === 401) {
1069
+ let error = input.secret ? "The adapter refused the signature. The secret here does not match the app's LOPEBASE_SIGNING_SECRET, or the two clocks are more than 5 minutes apart." : "The adapter refused the token.";
1070
+ return log(`FAIL ${error}`), { passed, failed: [{ name: "authenticated", error }] };
1071
+ }
1072
+ if (status === 404) {
1073
+ let error = `Nothing at ${base}/resources. Check the base URL, usually https://your-app/api/lopebase.`;
1074
+ return log(`FAIL ${error}`), { passed, failed: [{ name: "found", error }] };
1075
+ }
1076
+ for (let c of conformanceChecks({
1077
+ baseUrl: input.baseUrl,
1078
+ secret: input.secret,
1079
+ token: input.token
1080
+ }))
1081
+ try {
1082
+ await c.run(), passed++, log(`pass ${c.name}`);
1083
+ } catch (e) {
1084
+ let error = e instanceof Error ? e.message.split(`
1085
+ `)[0] : String(e);
1086
+ failed.push({ name: c.name, error }), log(`FAIL ${c.name}: ${error}`);
1087
+ }
1088
+ return { passed, failed };
1089
+ }
1090
+
818
1091
  // src/agents.ts
819
1092
  import { spawnSync } from "node:child_process";
820
1093
  var AGENTS = [
@@ -1376,7 +1649,22 @@ function scanProject(root) {
1376
1649
  }
1377
1650
 
1378
1651
  // bin/lopebase.ts
1379
- var [command = "init", ...rest] = process.argv.slice(2), flag = (name) => rest.includes(`--${name}`), option = (name) => {
1652
+ var [command = "init", ...rest] = process.argv.slice(2), HELP = `
1653
+ ${import_picocolors3.default.bgYellow(import_picocolors3.default.black(" LopeBase "))} Set up LopeBase from this project's folder.
1654
+
1655
+ ${import_picocolors3.default.bold("Usage")}
1656
+ npx lopebase scan, connect, and hand over to your agent
1657
+ npx lopebase login connect this computer (--paste for a token)
1658
+ npx lopebase scan [--json] show what the scan found
1659
+ npx lopebase prompt print the setup prompt instead
1660
+ npx lopebase mcp run the LopeBase tools as a stdio MCP server
1661
+ npx lopebase adapter secret print a new @lopebase/adapter signing secret
1662
+ npx lopebase adapter check <url> test a running adapter (reads LOPEBASE_SIGNING_SECRET)
1663
+
1664
+ ${import_picocolors3.default.bold("Learn more")}
1665
+ ${import_picocolors3.default.cyan("https://lopebase.com")}
1666
+ Help: contact@jackalope.digital
1667
+ `, flag = (name) => rest.includes(`--${name}`), option = (name) => {
1380
1668
  let i = rest.indexOf(`--${name}`);
1381
1669
  return i >= 0 ? rest[i + 1] : rest.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
1382
1670
  }, cwd = process.cwd();
@@ -1560,6 +1848,28 @@ async function main() {
1560
1848
  let facts = scanProject(cwd), detection = detectStack(facts);
1561
1849
  return console.log(onboardingPrompt({ facts, detection, chosen: [] }));
1562
1850
  }
1851
+ case "adapter": {
1852
+ let sub = rest[0];
1853
+ if (sub === "secret")
1854
+ return process.stdout.write(`${newSigningSecret()}
1855
+ `);
1856
+ if (sub === "check" && rest[1]) {
1857
+ let envName = option("secret-env") ?? "LOPEBASE_SIGNING_SECRET", secret = process.env[envName]?.trim(), token = process.env.LOPEBASE_ADAPTER_TOKEN?.trim();
1858
+ if (!secret && !token)
1859
+ console.error(`Set ${envName} to the adapter's signing secret (or LOPEBASE_ADAPTER_TOKEN for an older bearer adapter).`), process.exit(1);
1860
+ console.log(`Checking ${rest[1]}`);
1861
+ let res = await checkAdapter({
1862
+ baseUrl: rest[1],
1863
+ secret,
1864
+ token: secret ? void 0 : token,
1865
+ log: (l) => console.log(l.startsWith("FAIL") ? import_picocolors3.default.red(l) : import_picocolors3.default.green(l))
1866
+ });
1867
+ console.log(res.failed.length ? import_picocolors3.default.red(`${res.failed.length} failed, ${res.passed} passed`) : import_picocolors3.default.green(`All ${res.passed} checks passed. Ready to connect.`)), process.exit(res.failed.length ? 1 : 0);
1868
+ }
1869
+ return console.log(`Usage:
1870
+ lopebase adapter secret print a new signing secret
1871
+ lopebase adapter check <base-url> test a running adapter (reads LOPEBASE_SIGNING_SECRET)`), process.exit(sub ? 1 : 0);
1872
+ }
1563
1873
  case "mcp": {
1564
1874
  let auth = loadAuth();
1565
1875
  if (!auth)
@@ -1567,7 +1877,7 @@ async function main() {
1567
1877
  return runMcpProxy(auth);
1568
1878
  }
1569
1879
  default:
1570
- console.log("Usage: lopebase [init|login|scan|prompt|mcp]"), process.exit(command === "--help" || command === "-h" ? 0 : 1);
1880
+ console.log(HELP), process.exit(command === "--help" || command === "-h" || command === "help" ? 0 : 1);
1571
1881
  }
1572
1882
  }
1573
1883
  main();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lopebase",
3
- "version": "0.1.0",
4
- "description": "Set up a project in LopeBase from your terminal, with your own coding agent doing the steps.",
3
+ "version": "0.2.0",
4
+ "description": "Set up LopeBase from your project's folder: scan your stack, connect your workspace, and let your coding agent do the rest.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "lopebase": "dist/lopebase.js"
@@ -25,25 +25,35 @@
25
25
  "dev": "bun bin/lopebase.ts"
26
26
  },
27
27
  "devDependencies": {
28
+ "@clack/prompts": "^0.11.0",
29
+ "@lopebase/adapter": "workspace:*",
28
30
  "@types/node": "^22.0.0",
31
+ "picocolors": "^1.1.1",
29
32
  "typescript": "~5.7.0",
30
- "vitest": "^4.0.0",
31
- "@clack/prompts": "^0.11.0",
32
- "picocolors": "^1.1.1"
33
+ "vitest": "^4.0.0"
33
34
  },
34
35
  "license": "UNLICENSED",
35
36
  "homepage": "https://lopebase.com",
36
37
  "keywords": [
37
38
  "lopebase",
38
- "mcp",
39
39
  "cli",
40
+ "mcp",
40
41
  "agent",
41
- "onboarding"
42
+ "claude-code",
43
+ "codex",
44
+ "onboarding",
45
+ "analytics",
46
+ "search-console",
47
+ "dashboard"
42
48
  ],
43
49
  "engines": {
44
50
  "node": ">=18"
45
51
  },
46
52
  "publishConfig": {
47
53
  "access": "public"
54
+ },
55
+ "author": "LopeBase by Jackalope Digital <contact@jackalope.digital> (https://lopebase.com)",
56
+ "bugs": {
57
+ "email": "contact@jackalope.digital"
48
58
  }
49
59
  }