@optima-chat/dev-skills 0.7.33 → 0.7.36

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 (40) hide show
  1. package/AGENTS.md +3 -0
  2. package/bin/helpers/billing-http.ts +191 -0
  3. package/bin/helpers/confirm-prompt.ts +23 -0
  4. package/bin/helpers/entitlement/grant.ts +70 -0
  5. package/bin/helpers/entitlement/list.ts +77 -0
  6. package/bin/helpers/entitlement/revoke.ts +116 -0
  7. package/bin/helpers/entitlement.ts +32 -0
  8. package/bin/helpers/infisical-secrets.ts +41 -0
  9. package/bin/helpers/plugin/set-default.ts +65 -0
  10. package/bin/helpers/plugin/set-paid.ts +69 -0
  11. package/bin/helpers/plugin/show.ts +42 -0
  12. package/bin/helpers/plugin.ts +32 -0
  13. package/bin/helpers/product/add-channel.ts +78 -0
  14. package/bin/helpers/product/create.ts +96 -0
  15. package/bin/helpers/product/show.ts +43 -0
  16. package/bin/helpers/product/toggle-channel.ts +56 -0
  17. package/bin/helpers/product/update.ts +72 -0
  18. package/bin/helpers/product.ts +46 -0
  19. package/dist/bin/helpers/billing-http.js +153 -0
  20. package/dist/bin/helpers/confirm-prompt.js +54 -0
  21. package/dist/bin/helpers/entitlement/grant.js +73 -0
  22. package/dist/bin/helpers/entitlement/list.js +62 -0
  23. package/dist/bin/helpers/entitlement/revoke.js +105 -0
  24. package/dist/bin/helpers/entitlement.js +39 -0
  25. package/dist/bin/helpers/infisical-secrets.js +33 -0
  26. package/dist/bin/helpers/plugin/set-default.js +61 -0
  27. package/dist/bin/helpers/plugin/set-paid.js +65 -0
  28. package/dist/bin/helpers/plugin/show.js +45 -0
  29. package/dist/bin/helpers/plugin.js +39 -0
  30. package/dist/bin/helpers/product/add-channel.js +95 -0
  31. package/dist/bin/helpers/product/create.js +124 -0
  32. package/dist/bin/helpers/product/show.js +46 -0
  33. package/dist/bin/helpers/product/toggle-channel.js +61 -0
  34. package/dist/bin/helpers/product/update.js +92 -0
  35. package/dist/bin/helpers/product.js +53 -0
  36. package/docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md +1973 -0
  37. package/docs/superpowers/plans/2026-05-25-optima-plugin-cli-impl.md +700 -0
  38. package/docs/superpowers/specs/2026-05-24-marketplace-admin-cli-design.md +324 -0
  39. package/docs/superpowers/specs/2026-05-25-optima-plugin-cli-design.md +156 -0
  40. package/package.json +8 -5
@@ -0,0 +1,324 @@
1
+ # Marketplace Admin CLI — Design Spec
2
+
3
+ **Status**: draft for review
4
+ **Date**: 2026-05-24
5
+ **Author**: Jerry (via Claude collaborative spec)
6
+ **Tracking**: Optima-Chat/optima-dev-skills (to be filed)
7
+
8
+ ## 1. Purpose
9
+
10
+ Give the Optima team a CLI surface to manage the paid-plugin marketplace (Wave 1.5) **before any admin UI exists**:
11
+
12
+ 1. Create a `Product` bundling 1+ plugin slugs and set its refund policy.
13
+ 2. Attach a payment `Channel` to a `Product` so it becomes self-purchaseable.
14
+ 3. Toggle a channel on/off.
15
+ 4. Inspect a `Product`'s current state (channels + plugins).
16
+ 5. Grant a `Product` entitlement to a user (admin-grant, no payment).
17
+ 6. Revoke an admin-granted entitlement.
18
+ 7. List a user's entitlements (used by `revoke` internally and exposed standalone).
19
+
20
+ CLI ships inside the existing `@optima-chat/dev-skills` npm package as two new bin entries: `optima-product` and `optima-entitlement`, each with subcommands.
21
+
22
+ ## 2. Background
23
+
24
+ Wave 1.5 (deployed to stage 2026-05-21, prod pending — see [optima-billing#43](https://github.com/Optima-Chat/optima-billing/issues/43)) shipped a full set of billing admin HTTP endpoints under `/api/billing/admin/*` plus the `Product` / `ProductPlugin` / `ProductChannel` / `Entitlement` data model. The service layer (`product.service.ts`, `entitlement.service.ts`) already encodes:
25
+
26
+ - N:1 Plugin↔Product mapping with `pluginSlugs` ≥ 1 invariant
27
+ - Concurrent grant race handling (partial unique index `entitlements_one_active`)
28
+ - Outbox event creation (`entitlement.granted` / `entitlement.revoked`) with set-difference semantics on revoke (a plugin only appears in `lostPluginSlugs` if no other ACTIVE entitlement covers it)
29
+ - Stripe refund cascade (PAYMENT source) vs no-op for ADMIN_GRANT / PARTNER sources (`EntitlementSource` enum is `PAYMENT | ADMIN_GRANT | PARTNER`)
30
+ - Bundled subscription cascade per spec §6.2/§6.3
31
+
32
+ **Implication for this CLI**: the heavy logic is already centralized in billing. The CLI must NOT reimplement any of it. It is a thin HTTP client that collects arguments, calls the admin endpoint, and surfaces the response/error verbatim.
33
+
34
+ ## 3. Non-goals
35
+
36
+ - **Stripe Dashboard automation**: operator creates the Stripe `Product`+`Price` in the Stripe Dashboard by hand, then passes the resulting `price_xxx` ID to `optima-product add-channel`. No Stripe SDK dependency in this CLI.
37
+ - **prod usage**: prod billing main is still on pre-Wave-1.5 schema ([optima-billing#48](https://github.com/Optima-Chat/optima-billing/pull/48) revert). The CLI accepts `--env prod` but will get 404/error responses until Wave 1.5 lands on prod. That's a billing rollout concern, not a CLI concern.
38
+ - **Revoking PAYMENT or PARTNER-source entitlements**: only `ADMIN_GRANT`-source entitlements may be revoked via this CLI. `PAYMENT` revokes belong to the customer-facing Stripe refund flow (Stripe API + accounting); `PARTNER` revokes (3rd-party-issued grants) need their own out-of-band reversal — neither belongs in an admin convenience CLI.
39
+ - **Listing all products**: no `optima-product list` subcommand. The required endpoint `GET /api/billing/admin/products` does not exist — see [optima-billing#58](https://github.com/Optima-Chat/optima-billing/issues/58). When that endpoint ships, add the subcommand in a follow-up.
40
+ - **Admin UI**: this CLI is the bridge until a web admin UI exists. It is not a replacement.
41
+
42
+ ## 4. Architecture
43
+
44
+ ```
45
+ ┌──── Infisical (BILLING_URL + client_secret)
46
+
47
+ operator's shell ──────┼──── user-auth /api/v1/oauth/token (grant_type=client_credentials)
48
+ $ optima-product │ → service JWT (type=service, clientId=dev-skills-ubd3qz6n)
49
+ $ optima-entitlement │
50
+
51
+ ┌─────────────────────────────────────┐ SQL ┌─────────────┐
52
+ │ optima-billing (stage) │ ────────► │ billing DB │
53
+ │ POST /api/billing/admin/products │ │ │
54
+ │ POST /api/billing/admin/ │ └─────────────┘
55
+ │ grant-entitlement │
56
+ │ POST /api/billing/admin/ │ same tx
57
+ │ refund-entitlement ▼
58
+ │ GET /api/billing/admin/entitlements outbox_events
59
+ │ GET /api/internal/products/:key │
60
+ └─────────────────────────────────────┘ │
61
+ │ async dispatch
62
+
63
+ optima-skills (existing)
64
+ entitlement.granted → UserPlugin upsert
65
+ entitlement.revoked → UserPlugin deleteMany
66
+ ```
67
+
68
+ Three external dependencies per invocation: Infisical (config + secret lookup), user-auth (M2M token mint), optima-billing (admin endpoint).
69
+
70
+ Components:
71
+
72
+ - **CLI bin** (`bin/cli.js`): top-level dispatcher for `product` / `entitlement` subcommands. Mirrors how existing bins route flags.
73
+ - **Subcommand handlers** (`bin/helpers/product/*.ts`, `bin/helpers/entitlement/*.ts`): one file per subcommand, each does arg parsing → token acquisition → HTTP call → JSON pretty-print or `BillingError` surface.
74
+ - **Shared HTTP module** (`bin/helpers/billing-http.ts`, new): wraps `fetch` with: auth header injection, base URL resolution per env, response envelope unwrap, error formatting. Mirrors `db-utils.ts`'s role for DB.
75
+ - **Auth module** (`bin/helpers/m2m-auth.ts`, new): `getServiceToken(env)` → Infisical fetch of OAuth credentials → `POST /api/v1/oauth/token` with `grant_type=client_credentials` → cache token in memory for the process lifetime.
76
+
77
+ Dependencies on existing infra:
78
+
79
+ - `db-utils.getInfisicalConfig` / `getInfisicalToken` — reused as-is to authenticate to Infisical.
80
+ - No new npm dependencies. `fetch` is Node 18+ native; `package.json` `engines.node` is currently `">=14.0.0"`. Implementation plan T1 bumps it to `">=18.0.0"` (current install confirmed on Node 22 per `/home/jerry/.nvm/versions/node/v22.13.0/lib/node_modules/@optima-chat/dev-skills`; `>=18` is a safety floor, not a tightening of the runtime).
81
+
82
+ ## 5. Command surface
83
+
84
+ All commands accept `--env stage|prod` (default `stage`) and `-h`/`--help`.
85
+
86
+ ### 5.1 `optima-product`
87
+
88
+ ```
89
+ optima-product create
90
+ --key <productKey> required, unique slug; matches Wave 1.5 productKey rules
91
+ --plugins <slug1,slug2,...> required, comma-separated, ≥1 plugin slug; CLI passes through verbatim (duplicates rejected server-side as INVALID_PLUGIN_SLUGS)
92
+ --type <ProductType> required. Schema enum (`prisma/schema.prisma`) supports `SUBSCRIPTION_PLAN | ONE_SHOT_SKILL`. **v1 CLI policy: accept only ONE_SHOT_SKILL** because the grant path (`entitlement.service.ts` grant() check) hard-rejects other types with "Only ONE_SHOT_SKILL grants supported", and there's no roadmap to introduce SUBSCRIPTION_PLAN products via CLI before an admin UI lands. If that changes, widen this flag — server `createProduct` itself accepts both.
93
+ [--name "..."] optional; convenience flag — folded into product.metadata.name by billing service (Product schema has no name column)
94
+ [--description "..."] optional; convenience flag — folded into product.metadata.description
95
+ [--refund-window-days N] optional
96
+ [--refund-prorate-max-days N] optional
97
+ [--bundled-plan-id <planId>] optional; for products that also grant a subscription
98
+ [--bundled-duration-days N] optional; required iff --bundled-plan-id given
99
+ [--revoke-bundled-on-refund true|false] optional, defaults to billing's default (true)
100
+ [--metadata <json-string>] optional, JSON object stored in product.metadata; merged with --name/--description if both supplied (--name/--description take precedence per service:79-82)
101
+ [--env stage|prod]
102
+
103
+ POST /api/billing/admin/products
104
+ Body: { productKey, type, pluginSlugs: [...], name?, description?, refundWindowDays?, refundProrateMaxDays?,
105
+ bundledPlanId?, bundledDurationDays?, revokeBundledOnRefund?, metadata? }
106
+ ```
107
+
108
+ Output (success): the created `Product` row pretty-printed as JSON, plus a one-line summary.
109
+
110
+ ```
111
+ optima-product update
112
+ --key <productKey> required
113
+ [--refund-window-days N] optional
114
+ [--refund-prorate-max-days N] optional
115
+ [--bundled-plan-id <planId>] optional (joint constraint with --bundled-duration-days)
116
+ [--bundled-duration-days N] optional
117
+ [--revoke-bundled-on-refund true|false] optional
118
+ [--metadata <json-string>] optional (full-replace; PATCH endpoint does not merge nested keys)
119
+ [--env stage|prod]
120
+
121
+ PATCH /api/billing/admin/products/:key
122
+ ```
123
+
124
+ Note: `productKey`, `type`, and `pluginSlugs` are immutable post-create (not in `UpdateProductInput`). To change plugin membership, create a new Product with a new key.
125
+
126
+ ```
127
+ optima-product add-channel
128
+ --key <productKey> required
129
+ --provider STRIPE required; today only STRIPE supported by this CLI
130
+ (ALIPAY/WECHAT_PAY/AIRWALLEX exist in schema but operator flow differs — no auto-create endpoint per provider)
131
+ --stripe-price-id <price_xxx> required; pre-created in Stripe Dashboard. Wire-mapped to body field `externalProductId` (provider-neutral name in billing schema)
132
+ --price-cents N required; MUST be > 0 (server rejects 0/negative with INVALID_PRICE). Should match the Stripe Price's unit_amount — operator's responsibility; billing does NOT call Stripe to verify
133
+ --currency USD required; should match the Stripe Price's currency (also not server-verified against Stripe)
134
+ [--enabled true|false] optional; defaults to true (channel goes immediately live). Use `--enabled false` to create a disabled channel (saves a `toggle-channel` round-trip when staging a future launch)
135
+ [--metadata <json-string>] optional
136
+ [--env stage|prod]
137
+
138
+ POST /api/billing/admin/products/:key/channels
139
+ Body: { provider, externalProductId, priceCents, currency, enabled?, metadata? }
140
+ ```
141
+
142
+ ```
143
+ optima-product toggle-channel
144
+ --key <productKey> required
145
+ --provider STRIPE required
146
+ --enabled true|false required
147
+ [--env stage|prod]
148
+
149
+ PATCH /api/billing/admin/products/:key/channels/:provider
150
+ Body: { enabled }
151
+ ```
152
+
153
+ ```
154
+ optima-product show
155
+ --key <productKey> required
156
+ [--env stage|prod]
157
+
158
+ GET /api/internal/products/:key
159
+ ```
160
+
161
+ Note on auth: despite the `/api/internal/` path prefix (vs `/api/billing/admin/`), this endpoint still requires a valid service-client M2M token (`extractAnyServiceAuth`, admin-products.ts:138). Same auth flow as the admin routes — no special handling.
162
+
163
+ Output: bare `Product` row (productKey, type, refund fields, metadata, timestamps). **Does NOT include `productPlugins` or `channels` arrays** — `getProductByKey` in `product.service.ts:234` is a plain `findUnique` with no `include`. For the full picture, operator must also call `optima-entitlement list` (for grants) or query the DB directly for channels/plugins. A follow-up will request billing to add `?include=plugins,channels` or expose a richer admin endpoint — see §7.
164
+
165
+ ### 5.2 `optima-entitlement`
166
+
167
+ ```
168
+ optima-entitlement grant
169
+ --email <user-email> required; CLI resolves to userId via user-auth (see §6.2)
170
+ --product-key <productKey> required
171
+ --justification "..." required; billing returns 400 without it; stored on entitlement.justification (free-text audit)
172
+ [--yes] skip the prod confirmation prompt (see §6.6); no-op on --env stage
173
+ [--env stage|prod]
174
+
175
+ POST /api/billing/admin/grant-entitlement
176
+ ```
177
+
178
+ Body: `{ userId, productKey, justification }`. The endpoint hardcodes `source=ADMIN_GRANT`, `priceCents=0`, `currency=USD`, and `grantedBy=<clientId from auth>`. CLI only forwards the three user-supplied fields.
179
+
180
+ ```
181
+ optima-entitlement revoke
182
+ --email <user-email> required
183
+ --product-key <productKey> required
184
+ --reason "..." required; billing returns 400 without it; stored on entitlement.refundReason
185
+ [--yes] skip the prod confirmation prompt (see §6.6); no-op on --env stage
186
+ [--env stage|prod]
187
+
188
+ Internal flow:
189
+ 1. **Fetch** GET /api/billing/admin/entitlements?userId=<resolved> → response shape `{ entitlements: [...] }`. Endpoint returns all statuses (ACTIVE, REFUNDED) — CLI filters client-side.
190
+ 2. **Filter** for status=ACTIVE AND productKey=<arg>.
191
+ 3. **Validate count** — expect exactly 0 or 1 match (DB partial unique `entitlements_one_active` enforces ≤1 ACTIVE per (user, productKey)).
192
+ 0 → exit 1 with "no active entitlement for (user, product)"
193
+ 1 → take entitlementId
194
+ 4. **Validate source** — refuse if source ≠ ADMIN_GRANT.
195
+ Exit 1 with source-specific message:
196
+ source=PAYMENT → "refusing to revoke a PAYMENT-source entitlement via CLI; this would leave the customer charged but unentitled. Use the Stripe refund flow which calls Stripe refund API + records refundedAmountCents + emits webhook. Manual psql is the escape hatch if absolutely necessary."
197
+ source=PARTNER → "refusing to revoke a PARTNER-source entitlement via CLI; PARTNER grants are issued out-of-band and must be reversed via the partner contract / process that issued them. Manual psql is the escape hatch if absolutely necessary."
198
+ 5. **Refund** POST /api/billing/admin/refund-entitlement with `{ entitlementId, refundReason }`.
199
+
200
+ Race window note: between step 1 and step 5 two scenarios are possible:
201
+ - **PAYMENT lands concurrently**: blocked by partial unique index `entitlements_one_active` while the ADMIN_GRANT row is still ACTIVE; can only land after our refund flips status to REFUNDED. No corruption.
202
+ - **Two operators racing concurrent revokes**: both list the same ACTIVE entitlementId in step 1. First refund wins (status → REFUNDED). Second refund hits billing with a stale id; billing's `entSvc.revoke` either no-ops (idempotent on REFUNDED) or returns an error — either way the CLI surfaces it as a clear failure and exits 1.
203
+
204
+ A future server-side `POST /admin/refund-by-product` (atomic lookup + refund under `FOR UPDATE`) could close both windows, but neither is a real risk for an infrequent admin CLI.
205
+ ```
206
+
207
+ ```
208
+ optima-entitlement list
209
+ --email <user-email> required
210
+ [--env stage|prod]
211
+
212
+ GET /api/billing/admin/entitlements?userId=<resolved>
213
+ ```
214
+
215
+ Response shape: `{ entitlements: Entitlement[] }`. Output: table of entitlements, newest first, columns: `id | productKey | status | source | purchasedAt | refundedAt`.
216
+
217
+ ## 6. Implementation notes
218
+
219
+ ### 6.1 Environment resolution
220
+
221
+ - **`BILLING_URL`**: read from Infisical at `secretPath=/shared-secrets/domain-urls`. Indirect evidence the secret exists on **stage**: billing's `BILLING_PUBLIC_URL` env is configured as `${staging.shared-secrets.domain-urls.BILLING_URL}` (visible in `optima-show-env billing stage` output) — Infisical resolves the substitution at injection time, so the underlying secret must exist. Direct verification (Universal-Auth fetch of the path itself) and prod-side existence are deferred to T1 — see §10. No existing dev-skills helper reads from `/shared-secrets/domain-urls`, so implementation extends `db-utils` (or adds a sibling helper). Note the env-name mismatch: billing's runtime env var is `BILLING_PUBLIC_URL`, but the Infisical secret name is just `BILLING_URL` — CLI uses the Infisical secret name.
222
+ - **OAuth credentials**: dev-skills client is `clientId=dev-skills-ubd3qz6n` (existing OAuth client, already on billing's `ADMIN_SERVICE_ALLOWLIST` via the `dev-skills-<suffix>` prefix rule). The `client_secret` location in Infisical is **the one blocking open question** (see §10) — T1 of the implementation plan must locate or add it.
223
+
224
+ ### 6.2 Email → userId resolution
225
+
226
+ Reuse `db-utils.resolveUserId(email, env, infisicalConfig, token)` (4-arg signature, see `bin/helpers/db-utils.ts:169`). Caller must already have `infisicalConfig` + Infisical access token in hand from §6.1's lookup — these are passed through to `resolveUserId`. No new helper code needed.
227
+
228
+ ### 6.3 M2M token acquisition
229
+
230
+ ```ts
231
+ POST {USER_AUTH_URL}/api/v1/oauth/token
232
+ Content-Type: application/x-www-form-urlencoded
233
+ Body: grant_type=client_credentials
234
+ &client_id=dev-skills-ubd3qz6n
235
+ &client_secret=<secret>
236
+ ```
237
+
238
+ Response: `{ access_token, token_type, expires_in }`. The JWT must contain `type: "service"` claim — billing's `extractAnyServiceAuth` (auth.ts ~340) hard-rejects tokens without it with 403 FORBIDDEN. T1 verifies via a smoke `curl` that user-auth's `client_credentials` grant actually sets this claim (the billing comment at auth.ts ~322 implies it does, but the verify-response shape doesn't carry it back, so the JWT itself must be decoded to confirm).
239
+
240
+ **Token lifecycle**: the M2M auth module exposes `getServiceToken(env)` which memoizes within the process. Each CLI invocation calls it once at startup, and every HTTP call thereafter reuses the same token — important for multi-call flows like `revoke` (list + refund = 2 calls) or compound smoke tests. **No cross-invocation cache** (every fresh CLI process re-mints) — adequate since token TTLs (typically ≥1 hour) far exceed any single CLI invocation.
241
+
242
+ ### 6.4 Error handling
243
+
244
+ Billing's dominant envelope is the flat shape `{ error: "CODE_STRING", message: "..." }` (emitted by the global error handler at `src/app.ts:99-118` for all BillingError throws + validation + internal errors — verified during plan R1). A nested shape `{ error: { code, message } }` appears on a few inline returns (e.g. admin-products.ts toggle-channel 400/404). CLI handles both; printed format collapses to:
245
+
246
+ ```
247
+ ❌ Error [<HTTP status>] <code>: <message>
248
+ ```
249
+
250
+ and **always exits 1** on any error (auth failure, validation failure, server error, network failure — all collapse to exit 1; matches existing `grant-subscription` etc. precedent). No retries on 4xx; one retry on 5xx (billing already has its own idempotency story for grant via `idempotencyKey`).
251
+
252
+ **Non-envelope fallback**: if a response is non-2xx but the body isn't valid JSON or lacks the `{error: {code, message}}` shape (e.g. raw 502 from an upstream load balancer, plain-text 500 from a crashed handler before the error middleware runs), CLI prints:
253
+
254
+ ```
255
+ ❌ Error [<HTTP status>] <status text>
256
+ Response body (first 500 bytes): <truncated body>
257
+ ```
258
+
259
+ So operators always see SOMETHING actionable on failure.
260
+
261
+ ### 6.5 Output format
262
+
263
+ Default: human-readable summary lines + the response object pretty-printed.
264
+ Future: `--json` flag for machine-readable output. **Not in initial scope** — add when first consumer requires it.
265
+
266
+ ### 6.6 Safety rails
267
+
268
+ - Default `--env stage`. To run against prod, operator must type `--env prod` explicitly.
269
+ - `optima-entitlement revoke` MUST refuse non-`ADMIN_GRANT` sources (`PAYMENT`, `PARTNER`) with a source-specific message (see §5.2). **No `--force` escape** — operator drops to psql if they truly need to bypass (logged + auditable that way). Rationale: PAYMENT revoke via CLI without Stripe-side refund would leave the customer charged but unentitled; PARTNER revoke without partner-process reversal violates the issuance contract — both strictly worse than the manual escape hatch.
270
+ - **prod safety prompt**: when `--env prod`, `grant` and `revoke` print the resolved action (`userId + productKey + source + new status`) and require typing `yes` to confirm. `--yes` flag bypasses the prompt (for scripted ops with prior verification). Stage stays no-prompt for ergonomic iteration. Rationale: typo'd `--email` that happens to match a different real user would silently affect the wrong account; one-time human-in-the-loop is cheap insurance for prod-only operations.
271
+ - `optima-product create` does NOT auto-add a channel. A product without a channel is admin-grant-only; making it self-purchaseable is a deliberate second step.
272
+
273
+ ## 7. Out-of-scope follow-ups
274
+
275
+ | Item | Why deferred |
276
+ |---|---|
277
+ | `optima-product list` subcommand | Endpoint missing — see [optima-billing#58](https://github.com/Optima-Chat/optima-billing/issues/58). Add CLI side once endpoint ships. |
278
+ | `optima-product show` returning plugins + channels | Endpoint `getProductByKey` is bare `findUnique` with no `include`. File billing issue to add `?include=plugins,channels` or expose `/api/billing/admin/products/:key` (admin-richer view). Until then, `show` returns the bare Product row only. |
279
+ | `optima-entitlement show <id>` | Billing has no fetch-by-id endpoint; list-by-userId is the only path. File billing issue to add `GET /api/billing/admin/entitlements/:id`. Until then, use `optima-entitlement list` and grep by id. |
280
+ | Auto-create Stripe Product+Price | Requires Stripe SDK + per-env Stripe key wiring. Manual Dashboard creation is fine for the small initial paid-plugin catalog. |
281
+ | Non-STRIPE channel providers (ALIPAY, WECHAT_PAY, AIRWALLEX) | Each has its own out-of-band registration flow (or none at all). Add per-provider subcommand only when a real product needs it. |
282
+ | `--json` flag for machine-readable output | Add when first scripted consumer requires it. |
283
+ | Rotation of `dev-skills-ubd3qz6n` OAuth secret | Tracked by Wave 1.5 plan T10 step 8 ([optima-billing#43](https://github.com/Optima-Chat/optima-billing/issues/43)). Independent. |
284
+ | `optima-product delete` subcommand | No `DELETE /api/billing/admin/products/:key` endpoint exists (verified). Wave 1.5 spec §2.7 is append-only — products are never deleted, only channels disabled via `toggle-channel`. Listed here for completeness; not anticipated. |
285
+ | `--verify-sync` flag on grant/revoke (poll skills UserPlugin after) | Nice-to-have closing the cross-service async loop without operator dropping to `optima-query-db`. Low cost (one query). Add when first operator hits the gap. |
286
+ | `optima-product show --with-db-verify` (parallel ProductPlugin + ProductChannel DB queries to fill the gap from bare `getProductByKey`) | Reasonable workaround for the §7 enrichment gap, but adds SSH tunnel + SQL overhead per show. Wait until billing endpoint enriches OR the gap actually bites in practice. |
287
+ | Bulk grant / revoke (e.g. CSV of emails) | YAGNI until a real campaign needs it. |
288
+ | prod billing deploy of Wave 1.5 | Out of this repo's control. CLI is forward-compatible. Prod main reverted via [optima-billing#48](https://github.com/Optima-Chat/optima-billing/pull/48); integration→main PR gated on Wave 1.5 tracker [optima-billing#43](https://github.com/Optima-Chat/optima-billing/issues/43). |
289
+
290
+ ## 8. Testing approach
291
+
292
+ - **No unit tests** for the HTTP wrapper / arg parsing. Pattern matches existing `grant-subscription` etc.
293
+ - **Stage smoke** is the primary verification:
294
+ 1. `optima-product create --key smoke-cli-1 --plugins skillify --type ONE_SHOT_SKILL --env stage` → 201
295
+ 2. `optima-product show --key smoke-cli-1 --env stage` → returns Product with 1 plugin, 0 channels
296
+ 3. `optima-entitlement grant --email pro.xu.optima@gmail.com --product-key smoke-cli-1 --env stage` → 201
297
+ 4. `optima-entitlement list --email pro.xu.optima@gmail.com --env stage` → includes the new entitlement, status=ACTIVE, source=ADMIN_GRANT
298
+ 5. Verify outbox + skills sync:
299
+ a. `optima-query-db billing "SELECT id, event_type, status, payload FROM outbox_events WHERE event_type='entitlement.granted' ORDER BY created_at DESC LIMIT 1" stage` → DELIVERED row. (Verify column case at smoke time — billing tables in this area are snake_case `outbox_events.event_type` per Wave 1.5 migrations; column quoting may need adjustment if Prisma generated camelCase columns. Outbox `payload` column may carry a billing-internal envelope rather than the verbatim wire shape; implementer pins the exact field path during T-final smoke.)
300
+ b. `optima-query-db skills "SELECT up.\"userId\", p.slug FROM \"UserPlugin\" up JOIN \"Plugin\" p ON p.id = up.\"pluginId\" WHERE up.\"userId\"='<resolved>' AND p.slug='skillify'" stage` → exactly 1 row. Skills schema: `UserPlugin` is `(userId, pluginId, installedAt)` with **no status column** (`optima-skills/prisma/schema.prisma:153`); grants `upsert` a row, revokes `deleteMany` it. Post-revoke step 7 asserts the row is absent, not that any status flipped.
301
+ 6. `optima-entitlement revoke --email pro.xu.optima@gmail.com --product-key smoke-cli-1 --env stage` → 200
302
+ 7. `optima-entitlement list --email pro.xu.optima@gmail.com --env stage` → status=REFUNDED, refundedAt set. Also re-run step 5b query → 0 rows (UserPlugin row deleted by skills revoke handler).
303
+ 8. (Optional) `optima-product add-channel --key smoke-cli-1 --provider STRIPE --stripe-price-id <fresh test Price> --price-cents 100 --currency USD --env stage` → 201, then `optima-product show` reflects the channel
304
+ - **Clean up**: leave stage Product rows in place (Wave 1.5 spec §2.7 append-only convention) but disable the test channel via `toggle-channel --enabled false`.
305
+
306
+ ## 9. Risks & mitigations
307
+
308
+ | Risk | Mitigation |
309
+ |---|---|
310
+ | Stage Infisical `ADMIN_SERVICE_ALLOWLIST` overridden to not include `dev-skills` | Verified 2026-05-24: not overridden; falls back to default `"sales-page,dev-skills"`. If broken later, the 403 message names the offending clientId for fast diagnosis. |
311
+ | `dev-skills-ubd3qz6n` client secret location in Infisical not yet known | Implementation plan first task is to locate or set up the secret path; spec proceeds. |
312
+ | Operator confuses ADMIN_GRANT for free trial / runs revoke on a PAYMENT or PARTNER entitlement | Revoke refuses non-ADMIN_GRANT source with a source-specific message (§5.2 step 4); grant is explicit (no implicit "free trial" mode). |
313
+ | Typo'd `--email` on prod silently affects wrong real user | §6.6 prod-only confirmation prompt prints resolved userId before action; `--yes` bypass exists for scripted ops with prior verification. |
314
+ | `dev-skills-ubd3qz6n` OAuth client is shared with other test infra (test-token minting, smoke scripts) — concurrent CLI ops + ambient test traffic share the same client's token-mint quota and rate limits | Single-action CLI runs are infrequent enough that contention is unlikely in practice. If hit, error surfaces as user-auth 429/5xx with a clear retry path. Real fix (if it ever bites): mint a dedicated `dev-skills-admin-cli` OAuth client at impl time. Listed here so the trade-off is explicit. |
315
+ | Billing service layer changes break wire contract | CLI calls public admin endpoints; any breaking change to those would be a billing release concern surfaced at call time as a clear HTTP error. |
316
+ | prod usage attempt before Wave 1.5 lands prod | Billing returns 404/500 with clear error; CLI passes it through. Documented in §3 non-goals. |
317
+
318
+ ## 10. Open questions
319
+
320
+ 1. **[BLOCKING for implementation T1]** Where in Infisical does `dev-skills-ubd3qz6n`'s `client_secret` live? Candidates: `/services/dev-skills/CLIENT_SECRET`, `/shared-secrets/oauth/dev-skills/CLIENT_SECRET`, or it may need to be added fresh. T1 of the implementation plan must locate or create this path on both stage AND prod Infisical envs before any HTTP code is written.
321
+
322
+ 2. **[BLOCKING for implementation T1]** Does Infisical's `/shared-secrets/domain-urls/BILLING_URL` exist on **prod**? Verified to exist on stage (billing's own `BILLING_PUBLIC_URL` references it). If prod is missing, T1 also adds it (CLI uses `--env prod` would otherwise fail at URL lookup before reaching billing).
323
+
324
+ 3. **[VERIFY in T1, not blocking design]** Does user-auth's `client_credentials` grant actually mint a JWT with `type: "service"` claim? Inferred from billing's `extractAnyServiceAuth` comments but never observed directly. A 30-second `curl` smoke will confirm.
@@ -0,0 +1,156 @@
1
+ # optima-plugin CLI — Design Spec
2
+
3
+ **Status**: draft for review
4
+ **Date**: 2026-05-25
5
+ **Author**: Jerry (via Claude collaborative spec)
6
+ **Repo**: `@optima-chat/dev-skills`
7
+
8
+ ## 1. Purpose
9
+
10
+ Add the **skills-side admin command that the marketplace-admin-cli ([#11](https://github.com/Optima-Chat/optima-dev-skills/pull/11)) missed**.
11
+
12
+ The shipped `optima-product` / `optima-entitlement` CLIs manage only the **billing half** of a paid plugin (Product, Channel, Entitlement). But the flag that actually makes a plugin paid/free to users — `Plugin.isPaid` — lives in **optima-skills**, and it is the real access gate:
13
+
14
+ ```ts
15
+ // optima-skills src/routes/plugins.ts:132 (GET /:slug/download-url) + user-plugins.ts:96 (install)
16
+ // NOTE: this gate is on the install/download paths, NOT the plain detail read
17
+ // GET /:slug (line 72) that `show` uses — so `show` is unaffected by isPaid.
18
+ if (plugin.isPaid) {
19
+ const ok = await hasEntitlement({ userId, pluginId, isPaid: true }); // REAL billing HTTP call
20
+ if (!ok) throw 402 PAYMENT_REQUIRED // → plugin.salesUrl (fallback: sales.optima.onl)
21
+ }
22
+ ```
23
+
24
+ (`hasEntitlement` is the real implementation — `entitlement-checker.ts` calls billing's `checkEntitlement` with caching + fail-mode. The stale `// stub for Phase 0` comment at `plugins.ts:128` is outdated; Wave 1.5 wired the real call.)
25
+
26
+ Because no plugin currently has `isPaid=true` (the Wave 1/1.5 testing sessions exercised billing only, never flipped the skills flag), **every plugin including scout is effectively free regardless of its billing Product**. There is no CLI to flip this. The skills admin endpoint exists and its auth middleware comment explicitly says it is "callable by the dev-skills CLI" — but the command was never built.
27
+
28
+ `optima-plugin` closes that gap.
29
+
30
+ ## 2. Background — verified facts (2026-05-25)
31
+
32
+ - **Endpoint**: `PATCH /api/admin/plugins/:slug` (optima-skills `src/routes/admin-plugins.ts:19-31`). **`.strict()`** zod body — accepts ONLY `{ trustLevel?, status?, category?, tags?, readme?, defaultForUser?, isPaid? }`. **`salesUrl` is NOT accepted** (verified: `.strict()` → any extra key incl. `salesUrl` → ZodError 400). `salesUrl` is writable **only at publish time** from `pluginJson.metadata.salesUrl` (`plugin-publish-persister.ts:71`). Returns the full updated Plugin row.
33
+ - **Auth**: `requireAdminService` = `tryM2mAuth` + `requireAdminServiceClient` (`src/middleware/admin-service.ts`). Same model as billing: verified service JWT (`type: "service"`) + clientId on `ADMIN_SERVICE_ALLOWLIST` (default `'sales-page,dev-skills'`, prefix match). **Verified live**: minted a dev-skills M2M token via the existing `billing-http.getServiceToken('stage')` and a no-op `PATCH /api/admin/plugins/scout {}` returned 200 with scout's row. dev-skills token works against skills admin unchanged.
34
+ - **Skills base URL**: Infisical `/shared-secrets/domain-urls/SKILLS_REGISTRY_URL`. Stage = `https://skills.stage.optima.onl` (verified). Prod value to confirm in impl T1 — **note skills IS deployed to prod** (skills.optima.onl, marketplace-v2 Option A), so `--env prod` may actually be functional for plugin commands, unlike `optima-product` (billing prod is pre-Wave-1.5).
35
+ - **Read path**: public `GET /api/plugins/:slug` exposes `{slug, name, description, version, isPaid, salesUrl, category, tags, components, author, updatedAt}` — enough to verify isPaid/salesUrl, but **NOT** `defaultForUser` / `status` / `trustLevel` (no admin GET-single endpoint exists).
36
+ - **Error envelope**: skills uses the **nested** `{error: {code, message}}` shape (e.g. admin-service.ts 401/403). billing's shared `formatBillingError` already handles both nested and flat, so it is reusable as-is for skills responses.
37
+
38
+ ## 3. Non-goals
39
+
40
+ - **No new auth/token work**: reuse `billing-http.getServiceToken(env)` verbatim (same dev-skills M2M client serves both billing and skills).
41
+ - **No auto-coupling of isPaid ↔ defaultForUser**: operator sets each independently. (A typical free plugin is `isPaid=false, defaultForUser=true`, but the CLI does not enforce or auto-apply that — explicit is safer for an admin tool.)
42
+ - **No general `patch` escape hatch**: only the three scoped verbs below. The skills PATCH also accepts trustLevel/status/category/tags/readme, but those are out of scope (use a future command or psql if ever needed).
43
+ - **No write of `defaultForUser` read-back in `show`**: `show` uses the public GET (no defaultForUser field). Reading defaultForUser back is deferred to a skills follow-up (admin GET-single).
44
+ - **No marketplace re-publish / version management**: that is the existing publish flow's job.
45
+
46
+ ## 4. Architecture
47
+
48
+ New bin `optima-plugin` → dispatcher → subcommand handlers, mirroring `optima-product`'s structure.
49
+
50
+ ```
51
+ optima-plugin <subcommand> → bin/helpers/plugin.ts (dispatcher)
52
+ → bin/helpers/plugin/show.ts
53
+ → bin/helpers/plugin/set-paid.ts
54
+ → bin/helpers/plugin/set-default.ts
55
+ ```
56
+
57
+ **Shared HTTP refactor** (low-risk, on freshly-shipped code): generalize `billing-http.ts`'s call core so both billing and skills reuse the fetch + 5xx-retry + envelope-format + token logic.
58
+
59
+ - Extract the existing `callBilling` body into a private `callService(baseUrl, env, method, path, body?)` that takes a resolved base URL. **The `getServiceToken(env)` mint moves INTO `callService`** (currently inside `callBilling`); the 5xx single-retry + non-JSON-2xx guard move verbatim too.
60
+ - `callBilling(env, method, path, body?)` → `callService(getBillingUrl(env), env, ...)` (unchanged behavior + signature — billing path stays byte-identical).
61
+ - Add `getSkillsUrl(env)` (Infisical `SKILLS_REGISTRY_URL`, memoized like `getBillingUrl`) + `callSkills(env, method, path, body?)` → `callService(getSkillsUrl(env), env, ...)`.
62
+ - `getServiceToken`, `formatBillingError` (rename → `formatServiceError`, keep behavior), caches: shared. **Genericize the one hardcoded string** `"Billing returned non-JSON 2xx body"` → `"Service returned non-JSON 2xx body"` (it now also covers skills).
63
+
64
+ Reuse unchanged: `confirmIfProd` (prod prompt), `validateEnv` (stage|prod gate), `fetchInfisicalSecret`.
65
+
66
+ ## 5. Command surface
67
+
68
+ All commands: `--env stage|prod` (default `stage`), `-h`/`--help`, validated via `validateEnv` at entry.
69
+
70
+ ### 5.1 `optima-plugin show`
71
+
72
+ ```
73
+ optima-plugin show --slug <slug> [--env stage|prod]
74
+
75
+ GET /api/plugins/:slug (public; no auth needed but token injection is harmless)
76
+ ```
77
+
78
+ Output: pretty-printed `{slug, name, version, isPaid, salesUrl, category, tags, ...}`. **Does NOT include defaultForUser / status / trustLevel** — public endpoint omits them (documented gap; skills follow-up to add admin GET-single). One-line note printed when shown.
79
+
80
+ **Limitation**: `GET /api/plugins/:slug` returns 404 for non-ACTIVE plugins (`plugins.ts:78` rejects `status !== 'ACTIVE'`). `show` therefore only works for ACTIVE plugins; a BETA/DEPRECATED plugin surfaces as `404 NOT_FOUND`. Acceptable — scout/skillify are ACTIVE. (The same admin GET-single follow-up in §7 would lift this.)
81
+
82
+ ### 5.2 `optima-plugin set-paid`
83
+
84
+ ```
85
+ optima-plugin set-paid --slug <slug> --paid true|false [--env stage|prod] [--yes]
86
+
87
+ PATCH /api/admin/plugins/:slug
88
+ Body: { isPaid: <bool> }
89
+ ```
90
+
91
+ - `--paid` required (`true`|`false`); maps to `isPaid`.
92
+ - **No `--sales-url` flag** — the skills PATCH `.strict()` schema rejects `salesUrl` (§2). salesUrl is publish-time-only (`metadata.salesUrl`). When `--paid true` and the plugin's salesUrl is null, the 402 falls back to `sales.optima.onl`. Setting a custom sales page requires either a re-publish with `metadata.salesUrl`, or a skills follow-up to add `salesUrl` to patchSchema (§7). **Reminder printed** on `--paid true`: "ensure a billing Product + channel exists (optima-product) or users will 402 with no purchase path; salesUrl is publish-time-only."
93
+ - prod confirm prompt (resolved action: slug + isPaid + env), `--yes` bypass.
94
+ - Output: pretty-print the returned updated Plugin row (skills PATCH returns full row incl isPaid, salesUrl, defaultForUser).
95
+
96
+ ### 5.3 `optima-plugin set-default`
97
+
98
+ ```
99
+ optima-plugin set-default --slug <slug> --default true|false
100
+ [--env stage|prod] [--yes]
101
+
102
+ PATCH /api/admin/plugins/:slug
103
+ Body: { defaultForUser: <bool> }
104
+ ```
105
+
106
+ - `--default` required (`true`|`false`); maps to `defaultForUser`.
107
+ - prod confirm prompt, `--yes` bypass.
108
+ - Output: pretty-print returned row.
109
+ - **Note**: the PATCH is a plain `prisma.plugin.update` with no skill-sync broadcast (matches the rollback handler's documented Phase 0 behavior). Flipping `defaultForUser` changes what NEW user syncs receive; it does not retroactively push/remove the plugin for existing users until their next session/sync boundary.
110
+
111
+ ## 6. Implementation notes
112
+
113
+ - **Token**: `getServiceToken(env)` reused. Skills admin uses the same dev-skills client (stage `dev-skills-ubd3qz6n`, prod `dev-skills-hinxa0rs`) already on skills' `ADMIN_SERVICE_ALLOWLIST` (default includes `dev-skills`).
114
+ - **Error handling**: `callSkills` reuses the shared `formatServiceError` → `❌ Error [<status>] <code>: <message>`, non-envelope fallback for non-JSON. Exit 1 on any error (matches house convention).
115
+ - **404 on unknown slug**: skills returns 404 `{error:{code:'NOT_FOUND',...}}` (`Errors.notFound('Plugin')`). Surfaces via formatter.
116
+ - **`show` token**: public endpoint needs no auth; `callSkills` injects the Bearer anyway (harmless). Keeps one code path.
117
+ - **prod**: unlike `optima-product`, `--env prod` for `optima-plugin` may be functional (skills prod is live). T1 confirms prod `SKILLS_REGISTRY_URL` + prod dev-skills client on prod skills allowlist.
118
+
119
+ ## 7. Out-of-scope follow-ups
120
+
121
+ | Item | Why deferred |
122
+ |---|---|
123
+ | skills `GET /api/admin/plugins/:slug` (admin read with defaultForUser/status/trustLevel, any status) | No admin GET-single exists; public GET is ACTIVE-only + omits admin fields. File optima-skills issue. Until then `show` reads the public listing (isPaid/salesUrl, ACTIVE-only). |
124
+ | skills patchSchema: add `salesUrl` (so `set-paid --sales-url` becomes possible) | Currently salesUrl is publish-time-only; `.strict()` PATCH rejects it. If operators need to set custom sales pages without re-publishing, file an optima-skills issue to add `salesUrl: z.string().nullable().optional()` to patchSchema, then add `--sales-url` to set-paid. |
125
+ | `optima-plugin` verbs for trustLevel/status/category/tags/readme | PATCH supports them but no current need. Add when required. |
126
+ | Auto-set `defaultForUser=true` when making a plugin free | Deliberate non-goal — operator controls independently (§3). |
127
+
128
+ ## 8. Testing approach
129
+
130
+ Smoke-only (matches all existing dev-skills helpers; no unit tests).
131
+
132
+ Stage smoke (use `scout` — the real plugin we want paid, and `skillify` — should stay free):
133
+
134
+ 1. `optima-plugin show --slug scout --env stage` → isPaid=false, salesUrl=null (current state)
135
+ 2. `optima-plugin set-paid --slug scout --paid true --env stage` → 200, returned row isPaid=true (salesUrl stays whatever publish set — likely null; 402 falls back to sales.optima.onl)
136
+ 3. `optima-plugin show --slug scout --env stage` → isPaid=true reflected
137
+ 4. `optima-plugin set-default --slug scout --default false --env stage` → 200, returned row defaultForUser=false (verify via the returned PATCH body since show can't read it)
138
+ 5. `optima-plugin set-paid --slug skillify --paid false --env stage` → 200, isPaid=false (confirms idempotent / skillify stays free)
139
+ 6. Negative: `optima-plugin set-paid --slug nonexistent-xyz --paid true --env stage` → exit 1, 404 NOT_FOUND surfaced cleanly
140
+
141
+ **State note**: this smoke deliberately flips real stage plugin state (scout → paid). That is the intended end state per the operator's goal, not throwaway test data — leave scout paid after smoke (or coordinate with the separate "make scout paid" task). skillify set-paid false is a no-op (already false).
142
+
143
+ ## 9. Risks
144
+
145
+ | Risk | Mitigation |
146
+ |---|---|
147
+ | Shared HTTP refactor breaks the just-shipped billing path | `callBilling` keeps its exact signature + behavior (delegates to `callService` with billing URL). Smoke billing once post-refactor (`optima-entitlement list`) to confirm no regression. |
148
+ | skills prod allowlist missing dev-skills | T1 verifies; default includes `dev-skills` so likely fine. 403 message names the client if not. |
149
+ | `show` can't display defaultForUser | Documented; `set-default` output (returned PATCH row) is the read-back path until the admin GET-single follow-up lands. |
150
+ | Operator flips isPaid=true but no billing Product/channel exists → users hit 402 with no way to buy | Out of CLI's enforcement scope, but `set-paid --paid true` prints a reminder: "ensure a billing Product + channel exists (optima-product) or users will 402 with no purchase path." |
151
+
152
+ ## 10. Open questions
153
+
154
+ 1. **[T1]** prod `SKILLS_REGISTRY_URL` value + prod dev-skills client (`dev-skills-hinxa0rs`) on prod skills `ADMIN_SERVICE_ALLOWLIST`?
155
+
156
+ (Resolved during spec review: public `GET /api/plugins/:slug` rejects non-ACTIVE plugins with 404 — `plugins.ts:78`. `show` is ACTIVE-only; documented in §5.1. `salesUrl` is not PATCH-settable — `set-paid` drops `--sales-url`; documented in §2/§5.2/§7.)
package/package.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "name": "@optima-chat/dev-skills",
3
- "version": "0.7.33",
3
+ "version": "0.7.36",
4
4
  "description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "optima-dev-skills": "bin/cli.js",
8
- "optima-query-db": "dist/bin/helpers/query-db.js",
8
+ "optima-entitlement": "dist/bin/helpers/entitlement.js",
9
9
  "optima-generate-test-token": "dist/bin/helpers/generate-test-token.js",
10
- "optima-show-env": "dist/bin/helpers/show-env.js",
10
+ "optima-grant-balance": "dist/bin/helpers/grant-balance.js",
11
11
  "optima-grant-subscription": "dist/bin/helpers/grant-subscription.js",
12
- "optima-grant-balance": "dist/bin/helpers/grant-balance.js"
12
+ "optima-plugin": "dist/bin/helpers/plugin.js",
13
+ "optima-product": "dist/bin/helpers/product.js",
14
+ "optima-query-db": "dist/bin/helpers/query-db.js",
15
+ "optima-show-env": "dist/bin/helpers/show-env.js"
13
16
  },
14
17
  "scripts": {
15
18
  "postinstall": "node scripts/install.js",
@@ -36,7 +39,7 @@
36
39
  },
37
40
  "homepage": "https://github.com/Optima-Chat/optima-dev-skills#readme",
38
41
  "engines": {
39
- "node": ">=14.0.0"
42
+ "node": ">=18.0.0"
40
43
  },
41
44
  "files": [
42
45
  ".claude",