@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.
- package/dist/grafana/folder-permission.d.ts +76 -0
- package/dist/grafana/folder-permission.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +3 -1
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +133 -46
- package/dist/grafana/index.js.map +7 -5
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +1 -1
- package/dist/grafana/provisioned.d.ts.map +1 -1
- package/dist/grafana/team.d.ts +64 -0
- package/dist/grafana/team.d.ts.map +1 -0
- package/dist/litellm/fake-marketplace-litellm.d.ts +33 -0
- package/dist/litellm/fake-marketplace-litellm.d.ts.map +1 -0
- package/dist/litellm/index.d.ts +1 -0
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +530 -329
- package/dist/litellm/index.js.map +7 -4
- package/dist/litellm/marketplace-form.d.ts +21 -0
- package/dist/litellm/marketplace-form.d.ts.map +1 -0
- package/dist/litellm/marketplace-operations.d.ts +27 -0
- package/dist/litellm/marketplace-operations.d.ts.map +1 -0
- package/dist/litellm/marketplace-plugin.d.ts +57 -0
- package/dist/litellm/marketplace-plugin.d.ts.map +1 -0
- package/dist/litellm/marketplace-types.d.ts +55 -0
- package/dist/litellm/marketplace-types.d.ts.map +1 -0
- package/dist/litellm/providers.d.ts +1 -1
- package/dist/litellm/providers.d.ts.map +1 -1
- package/docs/litellm-marketplace.md +59 -0
- package/docs/litellm.md +1 -0
- package/package.json +1 -1
- package/src/grafana/folder-permission.test.ts +107 -0
- package/src/grafana/folder-permission.ts +128 -0
- package/src/grafana/index.ts +10 -1
- package/src/grafana/providers.ts +4 -0
- package/src/grafana/provisioned.ts +1 -1
- package/src/grafana/team.test.ts +108 -0
- package/src/grafana/team.ts +105 -0
- package/src/litellm/fake-marketplace-litellm.test.ts +51 -0
- package/src/litellm/fake-marketplace-litellm.ts +134 -0
- package/src/litellm/index.ts +8 -0
- package/src/litellm/marketplace-form.ts +160 -0
- package/src/litellm/marketplace-operations.ts +76 -0
- package/src/litellm/marketplace-plugin-replace.test.ts +68 -0
- package/src/litellm/marketplace-plugin.test.ts +181 -0
- package/src/litellm/marketplace-plugin.ts +156 -0
- package/src/litellm/marketplace-types.ts +53 -0
- package/src/litellm/providers.ts +2 -0
- package/src/litellm/registry-exports.test.ts +6 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LiteLLM.ClaudeCodeMarketplacePlugin`'s `replace` when `pluginName` changes, through Alchemy's
|
|
3
|
+
* own Plan and Apply (fake-stack.ts): a rename is create-first under the default `retain`, and
|
|
4
|
+
* the old name is deleted only under `.pipe(RemovalPolicy.destroy())`.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, test } from 'bun:test';
|
|
7
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { LiteLLMClaudeCodeMarketplacePlugin } from './marketplace-plugin.ts';
|
|
10
|
+
import type { MarketplacePluginProps } from './marketplace-types.ts';
|
|
11
|
+
import { FAKE_BASE } from './fake-litellm.ts';
|
|
12
|
+
import {
|
|
13
|
+
type FakeMarketplaceLitellm,
|
|
14
|
+
startFakeMarketplaceLitellm,
|
|
15
|
+
} from './fake-marketplace-litellm.ts';
|
|
16
|
+
import { FAKE_KEY } from './fake-registry-base.ts';
|
|
17
|
+
import { fakeStack, writesOf } from './fake-stack.ts';
|
|
18
|
+
|
|
19
|
+
const stack = (fake: FakeMarketplaceLitellm) =>
|
|
20
|
+
fakeStack({ apiKey: FAKE_KEY, baseUrl: FAKE_BASE }, fake.fetch);
|
|
21
|
+
|
|
22
|
+
const declare = (props: MarketplacePluginProps, name = 'Workflows') =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
yield* LiteLLMClaudeCodeMarketplacePlugin(name, props);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const workflows: MarketplacePluginProps = {
|
|
28
|
+
description: 'FAKE workflows',
|
|
29
|
+
keywords: ['fake-a', 'fake-b'],
|
|
30
|
+
pluginName: 'fake-workflows',
|
|
31
|
+
source: { repo: 'FAKE-org/FAKE-repo', source: 'github' },
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
test('a changed pluginName plans replace and leaves the old plugin live under retain', async () => {
|
|
36
|
+
const fake = startFakeMarketplaceLitellm();
|
|
37
|
+
const same = stack(fake);
|
|
38
|
+
expect(await same.deploy(declare(workflows))).toEqual({ Workflows: 'create' });
|
|
39
|
+
|
|
40
|
+
const before = fake.requests().length;
|
|
41
|
+
const renamed = { ...workflows, pluginName: 'fake-other' };
|
|
42
|
+
expect(await same.deploy(declare(renamed))).toEqual({ Workflows: 'replace' });
|
|
43
|
+
|
|
44
|
+
expect(fake.plugins().map((row) => row['name'])).toContain('fake-workflows');
|
|
45
|
+
expect(fake.plugins().map((row) => row['name'])).toContain('fake-other');
|
|
46
|
+
expect(writesOf(fake.requests().slice(before))).not.toContain(
|
|
47
|
+
'DELETE /claude-code/plugins/fake-workflows',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('under RemovalPolicy.destroy() a replace creates the new plugin, then deletes the old one', async () => {
|
|
52
|
+
const fake = startFakeMarketplaceLitellm();
|
|
53
|
+
const same = stack(fake);
|
|
54
|
+
const destroy = (props: MarketplacePluginProps) =>
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
yield* LiteLLMClaudeCodeMarketplacePlugin('Workflows', props).pipe(RemovalPolicy.destroy());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await same.deploy(destroy(workflows));
|
|
60
|
+
const before = fake.requests().length;
|
|
61
|
+
await same.deploy(destroy({ ...workflows, pluginName: 'fake-other' }));
|
|
62
|
+
const writes = writesOf(fake.requests().slice(before));
|
|
63
|
+
|
|
64
|
+
expect(fake.plugins().map((row) => row['name'])).toEqual(['fake-other']);
|
|
65
|
+
expect(writes.indexOf('POST /claude-code/plugins')).toBeLessThan(
|
|
66
|
+
writes.indexOf('DELETE /claude-code/plugins/fake-workflows'),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LiteLLM.ClaudeCodeMarketplacePlugin` through Alchemy's real Plan and Apply over the fake proxy
|
|
3
|
+
* (`fake-marketplace-litellm.ts`): register, no-op, update, adopt, enable, retain, delete.
|
|
4
|
+
*
|
|
5
|
+
* ★ EVERY VALUE IS `FAKE-*`. ⚠️ THE FAKE'S ANSWERS ARE UNMEASURED (its header).
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from 'bun:test';
|
|
8
|
+
import { credentials } from '@distilled.cloud/litellm/Credentials';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import * as FetchHttpClient from 'effect/http/FetchHttpClient';
|
|
12
|
+
import { LiteLLMClaudeCodeMarketplacePlugin } from './marketplace-plugin.ts';
|
|
13
|
+
import { deletePlugin } from './marketplace-operations.ts';
|
|
14
|
+
import type { MarketplacePluginProps } from './marketplace-types.ts';
|
|
15
|
+
import { FAKE_BASE } from './fake-litellm.ts';
|
|
16
|
+
import {
|
|
17
|
+
type FakeMarketplaceLitellm,
|
|
18
|
+
pluginRow,
|
|
19
|
+
startFakeMarketplaceLitellm,
|
|
20
|
+
} from './fake-marketplace-litellm.ts';
|
|
21
|
+
import { FAKE_KEY } from './fake-registry-base.ts';
|
|
22
|
+
import { fakeStack, writesOf } from './fake-stack.ts';
|
|
23
|
+
|
|
24
|
+
const stack = (fake: FakeMarketplaceLitellm) =>
|
|
25
|
+
fakeStack({ apiKey: FAKE_KEY, baseUrl: FAKE_BASE }, fake.fetch);
|
|
26
|
+
const declare = (props: MarketplacePluginProps, name = 'Workflows') =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
yield* LiteLLMClaudeCodeMarketplacePlugin(name, props);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const workflows: MarketplacePluginProps = {
|
|
32
|
+
description: 'FAKE workflows',
|
|
33
|
+
keywords: ['fake-a', 'fake-b'],
|
|
34
|
+
pluginName: 'fake-workflows',
|
|
35
|
+
source: { repo: 'FAKE-org/FAKE-repo', source: 'github' },
|
|
36
|
+
version: '1.0.0',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test('registers a plugin, lists it in the marketplace, then a second deploy writes nothing', async () => {
|
|
40
|
+
const fake = startFakeMarketplaceLitellm();
|
|
41
|
+
const same = stack(fake);
|
|
42
|
+
expect(await same.deploy(declare(workflows))).toEqual({ Workflows: 'create' });
|
|
43
|
+
expect(fake.plugins()[0]).toMatchObject({ enabled: true, name: 'fake-workflows' });
|
|
44
|
+
expect(await fake.listed()).toEqual(['fake-workflows']);
|
|
45
|
+
const before = fake.requests().length;
|
|
46
|
+
expect(await same.deploy(declare(workflows))).toEqual({ Workflows: 'noop' });
|
|
47
|
+
expect(writesOf(fake.requests().slice(before))).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a plugin the proxy registers disabled is enabled, because the marketplace lists only enabled ones', async () => {
|
|
51
|
+
const fake = startFakeMarketplaceLitellm({ registerDisabled: true });
|
|
52
|
+
await stack(fake).deploy(declare(workflows));
|
|
53
|
+
expect(writesOf(fake.requests())).toContain('POST /claude-code/plugins/fake-workflows/enable');
|
|
54
|
+
expect(await fake.listed()).toEqual(['fake-workflows']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('enabled: false disables a listed plugin through the disable route', async () => {
|
|
58
|
+
const fake = startFakeMarketplaceLitellm();
|
|
59
|
+
const same = stack(fake);
|
|
60
|
+
await same.deploy(declare(workflows));
|
|
61
|
+
expect(await same.deploy(declare({ ...workflows, enabled: false }))).toEqual({
|
|
62
|
+
Workflows: 'update',
|
|
63
|
+
});
|
|
64
|
+
expect(await fake.listed()).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('an update re-sends what the live plugin has, so an undeclared field is not wiped', async () => {
|
|
68
|
+
const fake = startFakeMarketplaceLitellm();
|
|
69
|
+
const same = stack(fake);
|
|
70
|
+
await same.deploy(declare(workflows));
|
|
71
|
+
const { description: _d, keywords: _k, version: _v, ...withoutOptional } = workflows;
|
|
72
|
+
const moved = {
|
|
73
|
+
...withoutOptional,
|
|
74
|
+
source: { repo: 'FAKE-org/FAKE-other', source: 'github' },
|
|
75
|
+
};
|
|
76
|
+
expect(await same.deploy(declare(moved))).toEqual({ Workflows: 'update' });
|
|
77
|
+
expect(fake.plugins()[0]).toMatchObject({
|
|
78
|
+
description: 'FAKE workflows',
|
|
79
|
+
keywords: ['fake-a', 'fake-b'],
|
|
80
|
+
source: moved.source,
|
|
81
|
+
version: '1.0.0',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('an update keeps a live author email when the declaration gives only a name', async () => {
|
|
86
|
+
const fake = startFakeMarketplaceLitellm();
|
|
87
|
+
const same = stack(fake);
|
|
88
|
+
const withAuthor = { ...workflows, author: { name: 'FAKE Author', email: 'fake@example.com' } };
|
|
89
|
+
await same.deploy(declare(withAuthor));
|
|
90
|
+
const moved = {
|
|
91
|
+
...withAuthor,
|
|
92
|
+
source: { repo: 'FAKE-org/FAKE-other', source: 'github' },
|
|
93
|
+
author: { name: 'FAKE Author' },
|
|
94
|
+
};
|
|
95
|
+
expect(await same.deploy(declare(moved))).toEqual({ Workflows: 'update' });
|
|
96
|
+
expect(fake.plugins()[0]).toMatchObject({
|
|
97
|
+
author: { name: 'FAKE Author', email: 'fake@example.com' },
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a live plugin is Unowned: refused without --adopt, taken by name with it, writing nothing', async () => {
|
|
102
|
+
const live = pluginRow({
|
|
103
|
+
description: 'FAKE workflows',
|
|
104
|
+
keywords: ['fake-a', 'fake-b'],
|
|
105
|
+
name: 'fake-workflows',
|
|
106
|
+
version: '1.0.0',
|
|
107
|
+
});
|
|
108
|
+
const fake = startFakeMarketplaceLitellm({ seed: [live] });
|
|
109
|
+
const engine = stack(fake);
|
|
110
|
+
await expect(engine.deploy(declare(workflows))).rejects.toThrow();
|
|
111
|
+
expect(writesOf(fake.requests())).toEqual([]);
|
|
112
|
+
expect(Object.values(await engine.deploy(declare(workflows), { adopt: true }))).toEqual([
|
|
113
|
+
'adopted',
|
|
114
|
+
]);
|
|
115
|
+
expect(writesOf(fake.requests())).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a proxy that drops the source on a PUT fails the deploy instead of claiming success', async () => {
|
|
119
|
+
const fake = startFakeMarketplaceLitellm({ ignoreSource: true });
|
|
120
|
+
const same = stack(fake);
|
|
121
|
+
await same.deploy(declare(workflows));
|
|
122
|
+
await expect(
|
|
123
|
+
same.deploy(declare({ ...workflows, source: { repo: 'FAKE-org/FAKE-x', source: 'github' } })),
|
|
124
|
+
).rejects.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a write refused for lack of rights fails the deploy and leaves nothing registered', async () => {
|
|
128
|
+
const fake = startFakeMarketplaceLitellm({ forbidWrites: true });
|
|
129
|
+
await expect(stack(fake).deploy(declare(workflows))).rejects.toThrow();
|
|
130
|
+
expect(fake.plugins()).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a refused declaration fails the plan before a single request', async () => {
|
|
134
|
+
for (const props of [
|
|
135
|
+
{ ...workflows, pluginName: 'Not_Kebab' },
|
|
136
|
+
{ ...workflows, source: {} },
|
|
137
|
+
{ ...workflows, source: { repo: ' ', source: 'github' } },
|
|
138
|
+
{ ...workflows, source: { source: 'arxiv' } },
|
|
139
|
+
{ ...workflows, description: ' ' },
|
|
140
|
+
{ ...workflows, keywords: ['fake-a', 'fake-a'] },
|
|
141
|
+
{ ...workflows, author: { name: ' ' } },
|
|
142
|
+
]) {
|
|
143
|
+
const fake = startFakeMarketplaceLitellm();
|
|
144
|
+
await expect(stack(fake).deploy(declare(props))).rejects.toThrow();
|
|
145
|
+
expect(fake.requests()).toEqual([]);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('removing the declaration RETAINS the plugin: nothing deletes an install source unless the stack opts in', async () => {
|
|
150
|
+
const fake = startFakeMarketplaceLitellm();
|
|
151
|
+
const same = stack(fake);
|
|
152
|
+
await same.deploy(declare(workflows));
|
|
153
|
+
await same.deploy(Effect.void);
|
|
154
|
+
expect(fake.plugins()).toHaveLength(1);
|
|
155
|
+
expect(writesOf(fake.requests())).not.toContain('DELETE /claude-code/plugins/fake-workflows');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const run = <A, E>(fetchFn: typeof globalThis.fetch, effect: Effect.Effect<A, E, unknown>) =>
|
|
159
|
+
Effect.runPromise(
|
|
160
|
+
effect.pipe(
|
|
161
|
+
Effect.provide(FetchHttpClient.layer),
|
|
162
|
+
Effect.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)),
|
|
163
|
+
Effect.provide(credentials({ apiKey: FAKE_KEY, baseUrl: FAKE_BASE })),
|
|
164
|
+
) as Effect.Effect<A, E, never>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
test('delete removes the plugin, and a missing one is swallowed only because a real read says so', async () => {
|
|
168
|
+
const fake = startFakeMarketplaceLitellm({ seed: [pluginRow({ name: 'fake-workflows' })] });
|
|
169
|
+
await run(fake.fetch, deletePlugin('fake-workflows'));
|
|
170
|
+
expect(fake.plugins()).toEqual([]);
|
|
171
|
+
await run(fake.fetch, deletePlugin('fake-workflows'));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a delete refused for lack of rights re-raises the ORIGINAL error and the plugin stays', async () => {
|
|
175
|
+
const fake = startFakeMarketplaceLitellm({
|
|
176
|
+
forbidWrites: true,
|
|
177
|
+
seed: [pluginRow({ name: 'fake-workflows' })],
|
|
178
|
+
});
|
|
179
|
+
await expect(run(fake.fetch, deletePlugin('fake-workflows'))).rejects.toThrow();
|
|
180
|
+
expect(fake.plugins()).toHaveLength(1);
|
|
181
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LiteLLM.ClaudeCodeMarketplacePlugin` — one plugin registered in LiteLLM's Claude Code marketplace
|
|
3
|
+
* (`/claude-code/plugins`), which LiteLLM serves as `/claude-code/marketplace.json`. Types and what
|
|
4
|
+
* is left out: marketplace-types.ts.
|
|
5
|
+
*
|
|
6
|
+
* ★ ADOPT BY NAME. `name` is unique, so `pluginName` finds the one live row. A live row with no
|
|
7
|
+
* state is `Unowned`, so it needs `--adopt`.
|
|
8
|
+
* ★ `defaultRemovalPolicy: 'retain'`: deleting a plugin removes the install source every Claude Code
|
|
9
|
+
* client has pointed at. Opt in with `.pipe(RemovalPolicy.destroy())`.
|
|
10
|
+
* ★ A NEW NAME IS A REPLACE: the name is the path key of every call, so there is no rename. Under
|
|
11
|
+
* the default `retain` the old plugin stays registered and listed until it is removed by hand or
|
|
12
|
+
* the stack opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
13
|
+
* ★ THE PROOF IS THE MARKETPLACE FILE: after a deploy, `GET /claude-code/marketplace.json` lists the
|
|
14
|
+
* plugin only while it is enabled, which is why `enabled` is part of the declaration.
|
|
15
|
+
* ⛔ NO LITELLM CREDENTIAL IS A PROP; `litellmProviders`' layer supplies `Credentials`.
|
|
16
|
+
*/
|
|
17
|
+
import { Resource } from 'alchemy';
|
|
18
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
19
|
+
import { isResolved } from 'alchemy/Diff';
|
|
20
|
+
import type { Input } from 'alchemy/Input';
|
|
21
|
+
import * as Provider from 'alchemy/Provider';
|
|
22
|
+
import * as Effect from 'effect/Effect';
|
|
23
|
+
import * as Predicate from 'effect/Predicate';
|
|
24
|
+
import { differing, firstProblem, registerBody, updateBody } from './marketplace-form.ts';
|
|
25
|
+
import {
|
|
26
|
+
deletePlugin,
|
|
27
|
+
listPlugins,
|
|
28
|
+
registerPlugin,
|
|
29
|
+
setEnabled,
|
|
30
|
+
updatePlugin,
|
|
31
|
+
} from './marketplace-operations.ts';
|
|
32
|
+
import type { MarketplacePluginAttributes, MarketplacePluginProps } from './marketplace-types.ts';
|
|
33
|
+
import {
|
|
34
|
+
LitellmRegistryAbsentAfterWriteError,
|
|
35
|
+
LitellmRegistryNotConvergedError,
|
|
36
|
+
} from './registry-errors.ts';
|
|
37
|
+
import { dieIfInvalid, failIfInvalid } from './registry-support.ts';
|
|
38
|
+
|
|
39
|
+
export type { MarketplacePluginAttributes, MarketplacePluginProps };
|
|
40
|
+
export type { RegistryError as MarketplacePluginError } from './registry-errors.ts';
|
|
41
|
+
|
|
42
|
+
const RESOURCE = 'LiteLLM.ClaudeCodeMarketplacePlugin';
|
|
43
|
+
|
|
44
|
+
export interface LiteLLMClaudeCodeMarketplacePlugin extends Resource<
|
|
45
|
+
'LiteLLM.ClaudeCodeMarketplacePlugin',
|
|
46
|
+
MarketplacePluginProps,
|
|
47
|
+
MarketplacePluginAttributes
|
|
48
|
+
> {}
|
|
49
|
+
|
|
50
|
+
export const LiteLLMClaudeCodeMarketplacePlugin = Resource<LiteLLMClaudeCodeMarketplacePlugin>(
|
|
51
|
+
'LiteLLM.ClaudeCodeMarketplacePlugin',
|
|
52
|
+
{ defaultRemovalPolicy: 'retain' },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const isLiteLLMClaudeCodeMarketplacePlugin = (
|
|
56
|
+
value: unknown,
|
|
57
|
+
): value is LiteLLMClaudeCodeMarketplacePlugin =>
|
|
58
|
+
Predicate.hasProperty(value, 'Type') && value.Type === 'LiteLLM.ClaudeCodeMarketplacePlugin';
|
|
59
|
+
|
|
60
|
+
/** The name is the identity: a recorded name is looked up by that name, state or no state. */
|
|
61
|
+
const locate = (
|
|
62
|
+
rows: readonly MarketplacePluginAttributes[],
|
|
63
|
+
props: MarketplacePluginProps,
|
|
64
|
+
output: MarketplacePluginAttributes | undefined,
|
|
65
|
+
) => rows.find((row) => row.pluginName === (output?.pluginName ?? props.pluginName));
|
|
66
|
+
|
|
67
|
+
const findNamed = (name: string) =>
|
|
68
|
+
listPlugins().pipe(Effect.map((rows) => rows.find((row) => row.pluginName === name)));
|
|
69
|
+
|
|
70
|
+
/** ⛔ TEST-ONLY EXPORT. Not on index.ts's barrel. */
|
|
71
|
+
export const marketplacePluginHandlers = {
|
|
72
|
+
/** ⛔ With no `output` this is the adoption probe, so the refusals run here too. */
|
|
73
|
+
read: ({
|
|
74
|
+
olds,
|
|
75
|
+
output,
|
|
76
|
+
}: {
|
|
77
|
+
olds: MarketplacePluginProps;
|
|
78
|
+
output: MarketplacePluginAttributes | undefined;
|
|
79
|
+
}) =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
if (output === undefined) {
|
|
82
|
+
yield* dieIfInvalid(RESOURCE, String(olds.pluginName), firstProblem(olds));
|
|
83
|
+
}
|
|
84
|
+
const live = locate(yield* listPlugins(), olds, output);
|
|
85
|
+
if (live === undefined) return undefined;
|
|
86
|
+
return output === undefined ? Unowned(live) : live;
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
diff: ({
|
|
90
|
+
news,
|
|
91
|
+
output,
|
|
92
|
+
}: {
|
|
93
|
+
news: Input<MarketplacePluginProps>;
|
|
94
|
+
output: MarketplacePluginAttributes | undefined;
|
|
95
|
+
}) =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
if (!isResolved(news)) return undefined;
|
|
98
|
+
yield* failIfInvalid(RESOURCE, String(news.pluginName), firstProblem(news));
|
|
99
|
+
if (output === undefined) return undefined;
|
|
100
|
+
if (output.pluginName !== news.pluginName) return { action: 'replace' } as const;
|
|
101
|
+
return differing(output, news).length === 0
|
|
102
|
+
? ({ action: 'noop' } as const)
|
|
103
|
+
: ({ action: 'update' } as const);
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
reconcile: ({
|
|
107
|
+
news,
|
|
108
|
+
output,
|
|
109
|
+
}: {
|
|
110
|
+
news: MarketplacePluginProps;
|
|
111
|
+
output: MarketplacePluginAttributes | undefined;
|
|
112
|
+
}) =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
yield* failIfInvalid(RESOURCE, String(news.pluginName), firstProblem(news));
|
|
115
|
+
const before = locate(yield* listPlugins(), news, output);
|
|
116
|
+
// ★ register is create-only and returns 409 if the name exists (`claude_code_marketplace.ts:546`);
|
|
117
|
+
// it is issued only after the list showed the name absent. The metadata write and the enable
|
|
118
|
+
// switch are separate calls, each issued only when the live row differs.
|
|
119
|
+
if (before === undefined) yield* registerPlugin(registerBody(news));
|
|
120
|
+
else if (differing(before, news).some((field) => field !== 'enabled')) {
|
|
121
|
+
yield* updatePlugin(news.pluginName, updateBody(news, before));
|
|
122
|
+
}
|
|
123
|
+
const written = yield* findNamed(news.pluginName);
|
|
124
|
+
if (written !== undefined && written.enabled !== (news.enabled ?? true)) {
|
|
125
|
+
yield* setEnabled(news.pluginName, news.enabled ?? true);
|
|
126
|
+
}
|
|
127
|
+
const after = yield* findNamed(news.pluginName);
|
|
128
|
+
if (after === undefined) {
|
|
129
|
+
return yield* Effect.fail(
|
|
130
|
+
new LitellmRegistryAbsentAfterWriteError({ name: news.pluginName, resource: RESOURCE }),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const left = differing(after, news);
|
|
134
|
+
if (left.length > 0) {
|
|
135
|
+
return yield* Effect.fail(
|
|
136
|
+
new LitellmRegistryNotConvergedError({
|
|
137
|
+
fields: left,
|
|
138
|
+
name: news.pluginName,
|
|
139
|
+
resource: RESOURCE,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return after;
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
delete: ({ output }: { output: MarketplacePluginAttributes }) => deletePlugin(output.pluginName),
|
|
147
|
+
|
|
148
|
+
/** ⛔ EMPTY: adoption is an explicit act (`--adopt`), and no ownership mark exists to filter by. */
|
|
149
|
+
list: () => Effect.succeed([]),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const LiteLLMClaudeCodeMarketplacePluginProvider = () =>
|
|
153
|
+
Provider.effect(
|
|
154
|
+
LiteLLMClaudeCodeMarketplacePlugin,
|
|
155
|
+
Effect.succeed(LiteLLMClaudeCodeMarketplacePlugin.Provider.of(marketplacePluginHandlers)),
|
|
156
|
+
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a `LiteLLM.ClaudeCodeMarketplacePlugin` declares (props) and remembers (attributes).
|
|
3
|
+
*
|
|
4
|
+
* ★ A PLUGIN IS A MARKETPLACE ENTRY, NOT A SKILL. LiteLLM serves `/claude-code/marketplace.json`
|
|
5
|
+
* from the plugins registered under `/claude-code/plugins`; Claude Code installs from that
|
|
6
|
+
* marketplace. The plugin folders a seat loads stay what they are (Tim, 2026-10-01); this entry
|
|
7
|
+
* points the marketplace at the same folder through `source`.
|
|
8
|
+
* ★ THE NAME IS THE IDENTITY. `name` is the path key of every read, update and delete, so it cannot
|
|
9
|
+
* be renamed in place: changing `pluginName` is a replace, never an update.
|
|
10
|
+
* ⚠️ `enabled` DEFAULTS TO `true`. A registered but disabled plugin is absent from the marketplace
|
|
11
|
+
* file, so a declaration that means "listed" must also mean "enabled".
|
|
12
|
+
*/
|
|
13
|
+
export interface PluginAuthor {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly email?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MarketplacePluginProps {
|
|
19
|
+
/** kebab-case plugin name, e.g. `homeflare-workflows`. Also the way an existing row is adopted. */
|
|
20
|
+
readonly pluginName: string;
|
|
21
|
+
/**
|
|
22
|
+
* Where Claude Code fetches the plugin from, as LiteLLM takes it: `{ source: 'github', repo }`,
|
|
23
|
+
* `{ source: 'url', url }`, `{ source: 'git-subdir', url, path }` or `{ source: 'archive', url, sha256? }`.
|
|
24
|
+
* Always compared.
|
|
25
|
+
*/
|
|
26
|
+
readonly source: Readonly<Record<string, string>>;
|
|
27
|
+
/** Compared only when declared: adopting never clears text a person wrote. */
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
readonly version?: string;
|
|
30
|
+
readonly domain?: string;
|
|
31
|
+
readonly namespace?: string;
|
|
32
|
+
readonly category?: string;
|
|
33
|
+
readonly homepage?: string;
|
|
34
|
+
readonly keywords?: readonly string[];
|
|
35
|
+
readonly author?: PluginAuthor;
|
|
36
|
+
/** Whether the marketplace lists it. Default `true`. */
|
|
37
|
+
readonly enabled?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MarketplacePluginAttributes {
|
|
41
|
+
readonly pluginId: string;
|
|
42
|
+
readonly pluginName: string;
|
|
43
|
+
readonly source: Readonly<Record<string, string>>;
|
|
44
|
+
readonly enabled: boolean;
|
|
45
|
+
readonly description: string | null;
|
|
46
|
+
readonly version: string | null;
|
|
47
|
+
readonly domain: string | null;
|
|
48
|
+
readonly namespace: string | null;
|
|
49
|
+
readonly category: string | null;
|
|
50
|
+
readonly homepage: string | null;
|
|
51
|
+
readonly keywords: readonly string[];
|
|
52
|
+
readonly author: { readonly name: string; readonly email: string | null } | null;
|
|
53
|
+
}
|
package/src/litellm/providers.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { type Credentials, CredentialsFromEnv } from '@distilled.cloud/litellm/C
|
|
|
22
22
|
import * as Layer from 'effect/Layer';
|
|
23
23
|
import { LiteLLMAccessGroupProvider } from './access-group.ts';
|
|
24
24
|
import { LiteLLMBudgetProvider } from './budget.ts';
|
|
25
|
+
import { LiteLLMClaudeCodeMarketplacePluginProvider } from './marketplace-plugin.ts';
|
|
25
26
|
import { LiteLLMCredentialProvider } from './credential.ts';
|
|
26
27
|
import { LiteLLMMCPServerProvider } from './mcp-server.ts';
|
|
27
28
|
import { LiteLLMKeyProvider } from './key.ts';
|
|
@@ -40,6 +41,7 @@ export const litellmProviders = (creds: Layer.Layer<Credentials> = CredentialsFr
|
|
|
40
41
|
LiteLLMCredentialProvider(),
|
|
41
42
|
LiteLLMMCPServerProvider(),
|
|
42
43
|
LiteLLMAccessGroupProvider(),
|
|
44
|
+
LiteLLMClaudeCodeMarketplacePluginProvider(),
|
|
43
45
|
LiteLLMToolsetProvider(),
|
|
44
46
|
LiteLLMToolPolicyProvider(),
|
|
45
47
|
LiteLLMPolicyProvider(),
|
|
@@ -14,6 +14,12 @@ const RESOURCES = [
|
|
|
14
14
|
'LiteLLM.AccessGroup',
|
|
15
15
|
'LiteLLMAccessGroupProvider',
|
|
16
16
|
],
|
|
17
|
+
[
|
|
18
|
+
'LiteLLMClaudeCodeMarketplacePlugin',
|
|
19
|
+
'isLiteLLMClaudeCodeMarketplacePlugin',
|
|
20
|
+
'LiteLLM.ClaudeCodeMarketplacePlugin',
|
|
21
|
+
'LiteLLMClaudeCodeMarketplacePluginProvider',
|
|
22
|
+
],
|
|
17
23
|
['LiteLLMToolset', 'isLiteLLMToolset', 'LiteLLM.Toolset', 'LiteLLMToolsetProvider'],
|
|
18
24
|
['LiteLLMPolicy', 'isLiteLLMPolicy', 'LiteLLM.Policy', 'LiteLLMPolicyProvider'],
|
|
19
25
|
[
|