@homeflare/alchemy 0.52.0 → 0.53.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 (49) hide show
  1. package/dist/grafana/folder-permission.d.ts +76 -0
  2. package/dist/grafana/folder-permission.d.ts.map +1 -0
  3. package/dist/grafana/index.d.ts +3 -1
  4. package/dist/grafana/index.d.ts.map +1 -1
  5. package/dist/grafana/index.js +133 -46
  6. package/dist/grafana/index.js.map +7 -5
  7. package/dist/grafana/providers.d.ts +1 -1
  8. package/dist/grafana/providers.d.ts.map +1 -1
  9. package/dist/grafana/provisioned.d.ts +1 -1
  10. package/dist/grafana/provisioned.d.ts.map +1 -1
  11. package/dist/grafana/team.d.ts +64 -0
  12. package/dist/grafana/team.d.ts.map +1 -0
  13. package/dist/litellm/fake-marketplace-litellm.d.ts +33 -0
  14. package/dist/litellm/fake-marketplace-litellm.d.ts.map +1 -0
  15. package/dist/litellm/index.d.ts +1 -0
  16. package/dist/litellm/index.d.ts.map +1 -1
  17. package/dist/litellm/index.js +530 -329
  18. package/dist/litellm/index.js.map +7 -4
  19. package/dist/litellm/marketplace-form.d.ts +21 -0
  20. package/dist/litellm/marketplace-form.d.ts.map +1 -0
  21. package/dist/litellm/marketplace-operations.d.ts +27 -0
  22. package/dist/litellm/marketplace-operations.d.ts.map +1 -0
  23. package/dist/litellm/marketplace-plugin.d.ts +57 -0
  24. package/dist/litellm/marketplace-plugin.d.ts.map +1 -0
  25. package/dist/litellm/marketplace-types.d.ts +55 -0
  26. package/dist/litellm/marketplace-types.d.ts.map +1 -0
  27. package/dist/litellm/providers.d.ts +1 -1
  28. package/dist/litellm/providers.d.ts.map +1 -1
  29. package/docs/litellm-marketplace.md +59 -0
  30. package/docs/litellm.md +1 -0
  31. package/package.json +1 -1
  32. package/src/grafana/folder-permission.test.ts +107 -0
  33. package/src/grafana/folder-permission.ts +128 -0
  34. package/src/grafana/index.ts +10 -1
  35. package/src/grafana/providers.ts +4 -0
  36. package/src/grafana/provisioned.ts +1 -1
  37. package/src/grafana/team.test.ts +108 -0
  38. package/src/grafana/team.ts +105 -0
  39. package/src/litellm/fake-marketplace-litellm.test.ts +51 -0
  40. package/src/litellm/fake-marketplace-litellm.ts +134 -0
  41. package/src/litellm/index.ts +8 -0
  42. package/src/litellm/marketplace-form.ts +160 -0
  43. package/src/litellm/marketplace-operations.ts +76 -0
  44. package/src/litellm/marketplace-plugin-replace.test.ts +68 -0
  45. package/src/litellm/marketplace-plugin.test.ts +181 -0
  46. package/src/litellm/marketplace-plugin.ts +156 -0
  47. package/src/litellm/marketplace-types.ts +53 -0
  48. package/src/litellm/providers.ts +2 -0
  49. package/src/litellm/registry-exports.test.ts +6 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `Grafana.Team` against a fake Grafana through the real `@distilled.cloud/grafana` protocol:
3
+ * exact-name observe (adopt), create body, update only on drift, provisioned refusal, idempotent delete.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
7
+ import * as Effect from 'effect/Effect';
8
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
9
+ import { grafanaOperations } from './resource.ts';
10
+ import { spec } from './team.ts';
11
+
12
+ const team = (over: Record<string, unknown> = {}) => ({
13
+ id: 7,
14
+ uid: 'aex7',
15
+ name: 'village-grafana',
16
+ email: '',
17
+ isProvisioned: false,
18
+ memberCount: 0,
19
+ orgId: 1,
20
+ ...over,
21
+ });
22
+ const search = (teams: unknown[]) =>
23
+ Response.json({ teams, totalCount: teams.length, page: 1, perPage: 100 });
24
+ const run = <A, E>(
25
+ fake: ReturnType<typeof fakeGrafana>,
26
+ effect: Effect.Effect<A, E, GrafanaOpContext>,
27
+ ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
28
+ const props = { name: 'village-grafana' };
29
+
30
+ describe('Grafana.Team', () => {
31
+ test('fetchLive keeps only an exact name match, GET without a body', async () => {
32
+ const fake = fakeGrafana((m, url) =>
33
+ m === 'GET' && url.pathname === '/api/teams/search'
34
+ ? search([team({ name: 'village-grafana-old', id: 9 }), team()])
35
+ : fakeFailure(404, 'x'),
36
+ );
37
+ const live = await run(fake, spec.fetchLive(props));
38
+ expect(live?.id).toBe(7);
39
+ expect(fake.bodies[0]).toBeUndefined();
40
+ });
41
+
42
+ test('absent team folds to undefined; 403 propagates', async () => {
43
+ expect(
44
+ await run(
45
+ fakeGrafana(() => search([])),
46
+ spec.fetchLive(props),
47
+ ),
48
+ ).toBeUndefined();
49
+ const failure = await run(
50
+ fakeGrafana(() => fakeFailure(403, 'forbidden')),
51
+ Effect.flip(spec.fetchLive(props)),
52
+ );
53
+ expect((failure as { _tag: string })._tag).toBe('Forbidden');
54
+ });
55
+
56
+ test('reconcile adopts a matching live team with no write', async () => {
57
+ const fake = fakeGrafana((m) =>
58
+ m === 'GET' ? search([team()]) : fakeFailure(500, 'no write expected'),
59
+ );
60
+ const out = await run(fake, grafanaOperations(spec).reconcile(props));
61
+ expect(out).toMatchObject({ id: 7, name: 'village-grafana' });
62
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
63
+ });
64
+
65
+ test('reconcile creates when absent, sending only declared fields', async () => {
66
+ let created = false;
67
+ const fake = fakeGrafana((m) => {
68
+ if (m === 'POST') {
69
+ created = true;
70
+ return Response.json({ teamId: 7, uid: 'aex7', message: 'Team created' });
71
+ }
72
+ return search(created ? [team()] : []);
73
+ });
74
+ await run(fake, grafanaOperations(spec).reconcile(props));
75
+ expect(fake.bodies.filter((b) => b !== undefined)).toEqual([{ name: 'village-grafana' }]);
76
+ });
77
+
78
+ test('email drift updates by team id', async () => {
79
+ let updated = false;
80
+ const fake = fakeGrafana((m) => {
81
+ if (m === 'PUT') {
82
+ updated = true;
83
+ return Response.json({ message: 'Team updated' });
84
+ }
85
+ return search([team({ email: updated ? 'ops@example.com' : '' })]);
86
+ });
87
+ await run(fake, grafanaOperations(spec).reconcile({ ...props, email: 'ops@example.com' }));
88
+ expect(fake.seen.some((s) => s.method === 'PUT' && s.path === '/api/teams/7')).toBe(true);
89
+ });
90
+
91
+ test('a provisioned team refuses update and delete without writing', async () => {
92
+ const fake = fakeGrafana(() => search([team({ isProvisioned: true, email: 'a@b' })]));
93
+ const failure = await run(
94
+ fake,
95
+ Effect.flip(grafanaOperations(spec).reconcile({ ...props, email: 'ops@example.com' })),
96
+ );
97
+ expect((failure as { _tag: string })._tag).toBe('GrafanaProvisionedObjectError');
98
+ const del = await run(fake, Effect.flip(grafanaOperations(spec).destroy(props)));
99
+ expect((del as { _tag: string })._tag).toBe('GrafanaProvisionedObjectError');
100
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
101
+ });
102
+
103
+ test('delete is idempotent when the team is already gone', async () => {
104
+ const fake = fakeGrafana(() => search([]));
105
+ await run(fake, grafanaOperations(spec).destroy(props));
106
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
107
+ });
108
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `Grafana.Team` — a Grafana team, keyed by NAME.
3
+ *
4
+ * Walked against `@distilled.cloud/grafana` 0.2.1 (`@homeflare/distilled-grafana`): `searchTeams`,
5
+ * `createTeam`, `updateTeam`, `deleteTeam`. Upstream Alchemy ships no Grafana family (S1); distilled
6
+ * ships the SDK, so every call goes through it (S23).
7
+ *
8
+ * ★ NAME IS THE KEY, NOT `uid`. `CreateTeamRequest` takes only `name`/`email` — there is no way to
9
+ * choose a team uid, and Grafana enforces unique team names per org. `fetchLive` therefore runs
10
+ * `GET /teams/search?name=<name>` (an exact-name filter) and keeps only an exact match; adoption of a
11
+ * team created by hand or by a script is a plain observe (S9/S10).
12
+ * ★ `email` is optional and compared only when declared ("undeclared means don't care", the kit's
13
+ * convention for optional props).
14
+ * ⛔ A provisioned team (`isProvisioned`, e.g. synced from SCIM/LDAP) is refused for update and delete.
15
+ */
16
+ import { Resource } from 'alchemy';
17
+ import * as Provider from 'alchemy/Provider';
18
+ import * as grafana from '@distilled.cloud/grafana';
19
+ import * as Effect from 'effect/Effect';
20
+ import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
21
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
22
+
23
+ export interface TeamProps {
24
+ /** Team name — unique per org, and this resource's identity. */
25
+ name: string;
26
+ /** Optional team email; compared only when declared. */
27
+ email?: string;
28
+ }
29
+
30
+ export interface TeamAttributes {
31
+ id: number;
32
+ uid: string;
33
+ name: string;
34
+ email: string;
35
+ isProvisioned: boolean;
36
+ }
37
+
38
+ export interface GrafanaTeam extends Resource<'Grafana.Team', TeamProps, TeamAttributes, never> {}
39
+
40
+ export const GrafanaTeam = Resource<GrafanaTeam>('Grafana.Team');
41
+
42
+ /** Exact-name lookup. `/teams/search?name=` is already exact in Grafana; the filter keeps that true. */
43
+ export const findTeamByName = (name: string) =>
44
+ grafana
45
+ .searchTeams({ name, perpage: 100 })
46
+ .pipe(Effect.map((result) => (result.teams ?? []).find((team) => team.name === name)));
47
+
48
+ export const spec: GrafanaSpec<
49
+ TeamProps,
50
+ grafana.TeamDTO,
51
+ TeamAttributes,
52
+ | grafana.SearchTeamsError
53
+ | grafana.CreateTeamError
54
+ | grafana.UpdateTeamError
55
+ | grafana.DeleteTeamError
56
+ | GrafanaProvisionedObjectError
57
+ > = {
58
+ attributes: (live) => ({
59
+ email: live.email ?? '',
60
+ id: live.id,
61
+ isProvisioned: live.isProvisioned,
62
+ name: live.name,
63
+ uid: live.uid,
64
+ }),
65
+ create: (props) =>
66
+ grafana.createTeam({
67
+ name: props.name,
68
+ ...(props.email === undefined ? {} : { email: props.email }),
69
+ }),
70
+ destroy: (props, live) =>
71
+ Effect.gen(function* () {
72
+ if (live.isProvisioned) {
73
+ return yield* refuseIfProvisioned(
74
+ 'Grafana.Team',
75
+ props.name,
76
+ 'an external team sync (isProvisioned)',
77
+ );
78
+ }
79
+ yield* grafana.deleteTeam({ team_id: String(live.id) });
80
+ }),
81
+ fetchLive: (props) => findTeamByName(props.name),
82
+ matches: (attributes, props) =>
83
+ attributes.name === props.name &&
84
+ (props.email === undefined || attributes.email === props.email),
85
+ update: (props, live) =>
86
+ Effect.gen(function* () {
87
+ if (live.isProvisioned) {
88
+ return yield* refuseIfProvisioned(
89
+ 'Grafana.Team',
90
+ props.name,
91
+ 'an external team sync (isProvisioned)',
92
+ );
93
+ }
94
+ return yield* grafana.updateTeam({
95
+ team_id: String(live.id),
96
+ name: props.name,
97
+ ...(props.email === undefined ? {} : { email: props.email }),
98
+ });
99
+ }),
100
+ };
101
+
102
+ export const handlers = grafanaHandlers(spec);
103
+
104
+ export const GrafanaTeamProvider = () =>
105
+ Provider.effect(GrafanaTeam, Effect.succeed(GrafanaTeam.Provider.of(handlers)));
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Fake-level assertions for `fake-marketplace-litellm.ts` against the distilled 1.103.0 route docs.
3
+ */
4
+ import { expect, test } from 'bun:test';
5
+ import { credentials } from '@distilled.cloud/litellm/Credentials';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
8
+ import * as Layer from 'effect/Layer';
9
+ import { registerPlugin, updatePlugin } from './marketplace-operations.ts';
10
+ import { FAKE_BASE } from './fake-litellm.ts';
11
+ import { pluginRow, startFakeMarketplaceLitellm } from './fake-marketplace-litellm.ts';
12
+ import { FAKE_KEY } from './fake-registry-base.ts';
13
+
14
+ const run = <A, E>(fetchFn: typeof globalThis.fetch, effect: Effect.Effect<A, E, unknown>) =>
15
+ Effect.runPromise(
16
+ effect.pipe(
17
+ Effect.provide(FetchHttpClient.layer),
18
+ Effect.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)),
19
+ Effect.provide(credentials({ apiKey: FAKE_KEY, baseUrl: FAKE_BASE })),
20
+ ) as Effect.Effect<A, E, never>,
21
+ );
22
+
23
+ test('register is create-only: a second POST of the same name returns 409 and changes nothing', async () => {
24
+ const fake = startFakeMarketplaceLitellm();
25
+ await run(fake.fetch, registerPlugin({ name: 'fake-workflows', source: { source: 'github' } }));
26
+ const before = fake.plugins()[0];
27
+ await expect(
28
+ run(fake.fetch, registerPlugin({ name: 'fake-workflows', source: { source: 'github' } })),
29
+ ).rejects.toThrow();
30
+ expect(fake.plugins()).toHaveLength(1);
31
+ expect(fake.plugins()[0]).toEqual(before);
32
+ });
33
+
34
+ test('PUT is a full replace: an omitted description is reset to null', async () => {
35
+ const fake = startFakeMarketplaceLitellm({
36
+ seed: [
37
+ pluginRow({
38
+ description: 'FAKE workflows',
39
+ name: 'fake-workflows',
40
+ source: { source: 'github', repo: 'FAKE-org/FAKE-repo' },
41
+ }),
42
+ ],
43
+ });
44
+ await run(
45
+ fake.fetch,
46
+ updatePlugin('fake-workflows', {
47
+ source: { source: 'github', repo: 'FAKE-org/FAKE-repo' },
48
+ }),
49
+ );
50
+ expect(fake.plugins()[0]).toMatchObject({ description: null, name: 'fake-workflows' });
51
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A fake of LiteLLM's `/claude-code/*` routes for tests — TEST ONLY. Skeleton and the rules every
3
+ * fake follows: fake-registry-base.ts.
4
+ *
5
+ * ★ MEASURED AGAINST THE GENERATED 1.103.0 ROUTE DOCS:
6
+ * - `POST /claude-code/plugins` is create-only and never overwrites; if `name` already exists it
7
+ * returns 409 Conflict (`claude_code_marketplace.ts:546`).
8
+ * - `PUT /claude-code/plugins/{name}` is a full replace, not a merge: any optional field left out
9
+ * is reset to its default (`claude_code_marketplace.ts:563`).
10
+ * - `PluginListItem` optional fields: author, category, description, domain, homepage, keywords,
11
+ * namespace, version (`claude_code_marketplace.ts:211-226`).
12
+ * - Enable/disable flip `enabled`; `GET /claude-code/marketplace.json` lists only enabled plugins.
13
+ * - Writes need an admin key (`forbidWrites` models the 403).
14
+ * - `registerDisabled` models a proxy whose register leaves the plugin disabled, which the resource
15
+ * must not trust. `ignoreSource` models a PUT that drops the source field.
16
+ * ⚠️ EVERY OTHER ANSWER IS UNMEASURED; shapes come from the generated 1.103.0 schema.
17
+ */
18
+ import { FAKE_KEY, type FakeRecord, json, recordingFetch } from './fake-registry-base.ts';
19
+ import type { Row } from './registry-support.ts';
20
+
21
+ export interface FakeMarketplaceLitellm extends FakeRecord {
22
+ readonly plugins: () => readonly Row[];
23
+ /** The names `GET /claude-code/marketplace.json` lists right now. */
24
+ readonly listed: () => Promise<readonly string[]>;
25
+ }
26
+
27
+ export interface FakeMarketplaceOptions {
28
+ readonly seed?: readonly Row[];
29
+ readonly forbidWrites?: boolean;
30
+ readonly registerDisabled?: boolean;
31
+ readonly ignoreSource?: boolean;
32
+ }
33
+
34
+ export const pluginRow = (fields: Row): Row => ({
35
+ created_at: '2026-10-12T00:00:00Z',
36
+ description: null,
37
+ enabled: true,
38
+ id: `FAKE-plugin-${String(fields['name'])}`,
39
+ source: { repo: 'FAKE-org/FAKE-repo', source: 'github' },
40
+ updated_at: '2026-10-12T00:00:00Z',
41
+ version: null,
42
+ ...fields,
43
+ });
44
+
45
+ const OPTIONAL_PUT_FIELDS = [
46
+ 'author',
47
+ 'category',
48
+ 'description',
49
+ 'domain',
50
+ 'homepage',
51
+ 'keywords',
52
+ 'namespace',
53
+ 'version',
54
+ ] as const;
55
+
56
+ export const startFakeMarketplaceLitellm = (
57
+ options: FakeMarketplaceOptions = {},
58
+ ): FakeMarketplaceLitellm => {
59
+ let rows: Row[] = (options.seed ?? []).map((row) => ({ ...row }));
60
+
61
+ const record = recordingFetch(FAKE_KEY, ({ body, request, url }) => {
62
+ const byName = /^\/claude-code\/plugins\/([^/]+)(\/enable|\/disable)?$/.exec(url.pathname);
63
+ if (url.pathname === '/claude-code/plugins' && request.method === 'GET') {
64
+ return json(200, { count: rows.length, plugins: rows });
65
+ }
66
+ if (url.pathname === '/claude-code/marketplace.json' && request.method === 'GET') {
67
+ return json(200, {
68
+ name: 'FAKE-litellm',
69
+ plugins: rows
70
+ .filter((row) => row['enabled'] === true)
71
+ .map((row) => ({ name: row['name'] })),
72
+ });
73
+ }
74
+ if (request.method !== 'GET' && options.forbidWrites === true) {
75
+ return json(403, { detail: { error: 'not_allowed_access' } });
76
+ }
77
+ if (url.pathname === '/claude-code/plugins' && request.method === 'POST') {
78
+ const name = body['name'];
79
+ if (rows.some((row) => row['name'] === name)) {
80
+ return json(409, { detail: `Plugin '${name}' already exists` });
81
+ }
82
+ const next = pluginRow({
83
+ ...body,
84
+ enabled: options.registerDisabled !== true,
85
+ });
86
+ rows = [...rows, next];
87
+ return json(200, { action: 'created', plugin: next, status: 'success' });
88
+ }
89
+ if (byName !== null) {
90
+ const name = decodeURIComponent(byName[1] ?? '');
91
+ const at = rows.findIndex((row) => row['name'] === name);
92
+ if (at === -1) return json(404, { detail: `Plugin '${name}' not found` });
93
+ const row = rows[at];
94
+ if (row === undefined) return json(404, { detail: `Plugin '${name}' not found` });
95
+ if (request.method === 'PUT') {
96
+ const reset = Object.fromEntries(
97
+ OPTIONAL_PUT_FIELDS.map((field) => [field, body[field] ?? null]),
98
+ );
99
+ const source = options.ignoreSource === true ? row['source'] : body['source'];
100
+ const next = pluginRow({
101
+ ...row,
102
+ ...reset,
103
+ source,
104
+ });
105
+ rows = rows.map((each, i) => (i === at ? next : each));
106
+ return json(200, { action: 'updated', plugin: next, status: 'success' });
107
+ }
108
+ if (request.method === 'POST' && byName[2] !== undefined) {
109
+ rows = rows.map((each, i) =>
110
+ i === at ? { ...each, enabled: byName[2] === '/enable' } : each,
111
+ );
112
+ return json(200, { status: 'success' });
113
+ }
114
+ if (request.method === 'DELETE') {
115
+ rows = rows.filter((_, i) => i !== at);
116
+ return json(200, { status: 'success' });
117
+ }
118
+ }
119
+ return json(404, { detail: 'not found' });
120
+ });
121
+
122
+ return {
123
+ ...record,
124
+ listed: async () => {
125
+ const response = await record.fetch('http://fake.local/claude-code/marketplace.json', {
126
+ headers: { authorization: `Bearer ${FAKE_KEY}` },
127
+ method: 'GET',
128
+ });
129
+ const payload = (await response.json()) as { plugins?: readonly { name?: unknown }[] };
130
+ return (payload.plugins ?? []).map((each) => String(each.name));
131
+ },
132
+ plugins: () => rows.map((row) => ({ ...row })),
133
+ };
134
+ };
@@ -79,6 +79,14 @@ export {
79
79
  type AccessGroupError,
80
80
  type AccessGroupProps,
81
81
  } from './access-group.ts';
82
+ export {
83
+ LiteLLMClaudeCodeMarketplacePlugin,
84
+ LiteLLMClaudeCodeMarketplacePluginProvider,
85
+ isLiteLLMClaudeCodeMarketplacePlugin,
86
+ type MarketplacePluginAttributes,
87
+ type MarketplacePluginError,
88
+ type MarketplacePluginProps,
89
+ } from './marketplace-plugin.ts';
82
90
  export {
83
91
  LiteLLMPolicyAttachment,
84
92
  LiteLLMPolicyAttachmentProvider,
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Wire bodies, comparison and refusals for `LiteLLM.ClaudeCodeMarketplacePlugin`.
3
+ *
4
+ * ★ THE UPDATE IS A FULL PUT: `source` is required and `version` is "cleared if omitted"
5
+ * (`claude_code_marketplace.ts:563`), so an update carries every optional field the plugin
6
+ * already has; a field the declaration leaves out is re-sent as it is live, never wiped.
7
+ * ★ REGISTER IS CREATE-ONLY: `POST /claude-code/plugins` returns 409 if the name already exists
8
+ * (`claude_code_marketplace.ts:546`), so `reconcile` registers only after a list shows the name
9
+ * absent.
10
+ */
11
+ import type * as cc from '@distilled.cloud/litellm/claude_code_marketplace';
12
+ import {
13
+ asRow,
14
+ canonical,
15
+ firstBadEntry,
16
+ firstDuplicate,
17
+ isBlank,
18
+ sameSet,
19
+ stringOrNull,
20
+ stringsOf,
21
+ } from './registry-support.ts';
22
+ import type { MarketplacePluginAttributes, MarketplacePluginProps } from './marketplace-types.ts';
23
+
24
+ const TEXT = ['description', 'version', 'domain', 'namespace', 'category', 'homepage'] as const;
25
+
26
+ const SOURCE_KINDS = new Set(['github', 'url', 'git-subdir', 'archive']);
27
+
28
+ /** The first reason a declaration is refused, or `undefined`. Runs before any request. */
29
+ export const firstProblem = (props: MarketplacePluginProps): string | undefined => {
30
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(props.pluginName))) {
31
+ return '`pluginName` must be kebab-case (lowercase letters, digits and single hyphens)';
32
+ }
33
+ const source = props.source as Readonly<Record<string, unknown>> | undefined;
34
+ if (source === undefined || isBlank(source['source'])) {
35
+ return '`source` needs a `source` kind (github, url, git-subdir or archive)';
36
+ }
37
+ if (!SOURCE_KINDS.has(String(source['source']))) {
38
+ return `\`source.source\` must be github, url, git-subdir or archive`;
39
+ }
40
+ for (const [key, value] of Object.entries(source)) {
41
+ if (isBlank(key) || isBlank(value)) return `\`source.${key}\` is blank`;
42
+ }
43
+ for (const field of TEXT) {
44
+ if (props[field] !== undefined && isBlank(props[field])) {
45
+ return `\`${field}\` is blank; leave it out to keep the live text`;
46
+ }
47
+ }
48
+ if (props.author !== undefined && isBlank(props.author.name)) return '`author.name` is blank';
49
+ const bad = firstBadEntry(props.keywords ?? []);
50
+ if (bad !== undefined) return `\`keywords\` has a blank or padded entry (${JSON.stringify(bad)})`;
51
+ const twice = firstDuplicate(props.keywords ?? []);
52
+ if (twice !== undefined) return `\`keywords\` lists ${JSON.stringify(twice)} twice`;
53
+ return undefined;
54
+ };
55
+
56
+ const sourceOf = (value: unknown): Readonly<Record<string, string>> =>
57
+ Object.fromEntries(
58
+ Object.entries(asRow(value) ?? {}).filter(
59
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
60
+ ),
61
+ );
62
+
63
+ /** One list item. `undefined` is "not a plugin row": the caller refuses the whole answer. */
64
+ export const toAttributes = (value: unknown): MarketplacePluginAttributes | undefined => {
65
+ const row = asRow(value);
66
+ if (row === undefined) return undefined;
67
+ const pluginId = row['id'];
68
+ const pluginName = row['name'];
69
+ if (typeof pluginId !== 'string' || typeof pluginName !== 'string') return undefined;
70
+ const author = asRow(row['author']);
71
+ return {
72
+ author:
73
+ author !== undefined && typeof author['name'] === 'string'
74
+ ? { email: stringOrNull(author['email']), name: author['name'] }
75
+ : null,
76
+ category: stringOrNull(row['category']),
77
+ description: stringOrNull(row['description']),
78
+ domain: stringOrNull(row['domain']),
79
+ enabled: row['enabled'] === true,
80
+ homepage: stringOrNull(row['homepage']),
81
+ keywords: canonical(stringsOf(row['keywords'])),
82
+ namespace: stringOrNull(row['namespace']),
83
+ pluginId,
84
+ pluginName,
85
+ source: sourceOf(row['source']),
86
+ version: stringOrNull(row['version']),
87
+ };
88
+ };
89
+
90
+ const sameSource = (
91
+ left: Readonly<Record<string, string>>,
92
+ right: Readonly<Record<string, string>>,
93
+ ): boolean => {
94
+ const a = Object.entries(left).sort(([x], [y]) => x.localeCompare(y));
95
+ const b = Object.entries(right).sort(([x], [y]) => x.localeCompare(y));
96
+ return a.length === b.length && a.every(([k, v], i) => k === b[i]?.[0] && v === b[i]?.[1]);
97
+ };
98
+
99
+ /** Wire names where live and declared disagree. Optional fields count only when declared. */
100
+ export const differing = (
101
+ live: MarketplacePluginAttributes,
102
+ props: MarketplacePluginProps,
103
+ ): readonly string[] => {
104
+ const fields: string[] = [];
105
+ if (!sameSource(live.source, props.source)) fields.push('source');
106
+ for (const field of TEXT) {
107
+ if (props[field] !== undefined && live[field] !== props[field]) fields.push(field);
108
+ }
109
+ if (props.keywords !== undefined && !sameSet(live.keywords, props.keywords)) {
110
+ fields.push('keywords');
111
+ }
112
+ if (props.author !== undefined) {
113
+ const same =
114
+ live.author?.name === props.author.name &&
115
+ (props.author.email === undefined || live.author.email === props.author.email);
116
+ if (!same) fields.push('author');
117
+ }
118
+ if (live.enabled !== (props.enabled ?? true)) fields.push('enabled');
119
+ return fields;
120
+ };
121
+
122
+ /** The metadata both writes share. `live` is the row an update must not wipe. */
123
+ const metadata = (props: MarketplacePluginProps, live?: MarketplacePluginAttributes) => {
124
+ const author: { name: string; email?: string } | undefined = props.author
125
+ ? { name: props.author.name }
126
+ : live?.author
127
+ ? { name: live.author.name }
128
+ : undefined;
129
+ if (author !== undefined) {
130
+ const keep = props.author?.email ?? live?.author?.email;
131
+ if (keep !== undefined && keep !== null) {
132
+ author.email = keep;
133
+ }
134
+ }
135
+ const keywords = props.keywords ?? (live?.keywords.length ? live.keywords : undefined);
136
+ return {
137
+ ...(author === undefined ? {} : { author }),
138
+ ...Object.fromEntries(
139
+ TEXT.flatMap((field) => {
140
+ const value = props[field] ?? live?.[field] ?? undefined;
141
+ return value === undefined ? [] : [[field, value]];
142
+ }),
143
+ ),
144
+ ...(keywords === undefined ? {} : { keywords: [...keywords] }),
145
+ source: { ...props.source },
146
+ };
147
+ };
148
+
149
+ export const registerBody = (
150
+ props: MarketplacePluginProps,
151
+ ): cc.RegisterPluginClaudeCodePluginsPostRequest => ({
152
+ ...metadata(props),
153
+ name: props.pluginName,
154
+ });
155
+
156
+ export const updateBody = (
157
+ props: MarketplacePluginProps,
158
+ live: MarketplacePluginAttributes,
159
+ ): Omit<cc.UpdatePluginClaudeCodePluginsPluginNamePutRequest, 'plugin_name'> =>
160
+ metadata(props, live);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The LiteLLM `/claude-code/plugins*` calls `LiteLLM.ClaudeCodeMarketplacePlugin` uses, through
3
+ * `@distilled.cloud/litellm/claude_code_marketplace` (1.103.0):
4
+ *
5
+ * list `listPluginsClaudeCodePluginsGet` GET /claude-code/plugins
6
+ * register `registerPluginClaudeCodePluginsPost` POST /claude-code/plugins
7
+ * update `updatePluginClaudeCodePluginsPluginNamePut` PUT /claude-code/plugins/{name}
8
+ * enable `enablePluginClaudeCodePluginsPluginNameEnablePost` POST …/{name}/enable
9
+ * disable `disablePluginClaudeCodePluginsPluginNameDisablePost` POST …/{name}/disable
10
+ * delete `deletePluginClaudeCodePluginsPluginNameDelete` DELETE /claude-code/plugins/{name}
11
+ *
12
+ * ⛔ WRITES NEED A PROXY ADMIN KEY. ⛔ NO MESSAGE SNIFFING: the by-name GET is not used, the list
13
+ * decides absence (registry-support.ts says why). ★ DELETE IS IDEMPOTENT BY A REAL READ: a failed
14
+ * DELETE is swallowed only when the list no longer has the name; otherwise the ORIGINAL error rises.
15
+ */
16
+ import * as cc from '@distilled.cloud/litellm/claude_code_marketplace';
17
+ import * as Effect from 'effect/Effect';
18
+ import { type LitellmOpContext, throughFetch } from './operations.ts';
19
+ import { LitellmRegistryUnreadableError } from './registry-errors.ts';
20
+ import { asRow, bodyOf } from './registry-support.ts';
21
+ import type { MarketplacePluginAttributes } from './marketplace-types.ts';
22
+ import { toAttributes } from './marketplace-form.ts';
23
+
24
+ const RESOURCE = 'LiteLLM.ClaudeCodeMarketplacePlugin';
25
+
26
+ /** Every plugin, enabled or not (`enabled_only` unset). */
27
+ export const listPlugins = (): Effect.Effect<
28
+ readonly MarketplacePluginAttributes[],
29
+ cc.ListPluginsClaudeCodePluginsGetError | LitellmRegistryUnreadableError,
30
+ LitellmOpContext
31
+ > =>
32
+ throughFetch(cc.listPluginsClaudeCodePluginsGet({})).pipe(
33
+ Effect.flatMap((response) => {
34
+ const items = asRow(bodyOf(response))?.['plugins'];
35
+ const parsed = Array.isArray(items) ? items.map(toAttributes) : undefined;
36
+ return parsed !== undefined && parsed.every((row) => row !== undefined)
37
+ ? Effect.succeed(parsed as readonly MarketplacePluginAttributes[])
38
+ : Effect.fail(
39
+ new LitellmRegistryUnreadableError({
40
+ reason: 'no plugins array of rows with an id and a name',
41
+ resource: RESOURCE,
42
+ }),
43
+ );
44
+ }),
45
+ );
46
+
47
+ export const registerPlugin = (body: cc.RegisterPluginClaudeCodePluginsPostRequest) =>
48
+ throughFetch(cc.registerPluginClaudeCodePluginsPost(body)).pipe(Effect.asVoid);
49
+
50
+ export const updatePlugin = (
51
+ name: string,
52
+ body: Omit<cc.UpdatePluginClaudeCodePluginsPluginNamePutRequest, 'plugin_name'>,
53
+ ) =>
54
+ throughFetch(cc.updatePluginClaudeCodePluginsPluginNamePut({ ...body, plugin_name: name })).pipe(
55
+ Effect.asVoid,
56
+ );
57
+
58
+ export const setEnabled = (name: string, enabled: boolean) =>
59
+ throughFetch(
60
+ enabled
61
+ ? cc.enablePluginClaudeCodePluginsPluginNameEnablePost({ plugin_name: name })
62
+ : cc.disablePluginClaudeCodePluginsPluginNameDisablePost({ plugin_name: name }),
63
+ ).pipe(Effect.asVoid);
64
+
65
+ export const deletePlugin = (name: string) =>
66
+ throughFetch(cc.deletePluginClaudeCodePluginsPluginNameDelete({ plugin_name: name })).pipe(
67
+ Effect.asVoid,
68
+ Effect.catch((original) =>
69
+ listPlugins().pipe(
70
+ Effect.catch(() => Effect.fail(original)),
71
+ Effect.flatMap((rows) =>
72
+ rows.some((row) => row.pluginName === name) ? Effect.fail(original) : Effect.void,
73
+ ),
74
+ ),
75
+ ),
76
+ );