@homeflare/alchemy 0.51.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/linux/container-env-quote.d.ts +39 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +14 -13
- package/dist/linux/container-form.d.ts.map +1 -1
- package/dist/linux/container-paths.d.ts +17 -0
- package/dist/linux/container-paths.d.ts.map +1 -0
- package/dist/linux/index.js +41 -8
- package/dist/linux/index.js.map +6 -4
- 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/dist/unifi/index.js.map +2 -2
- package/dist/unifi/network.d.ts +2 -1
- package/dist/unifi/network.d.ts.map +1 -1
- package/docs/litellm-marketplace.md +59 -0
- package/docs/litellm.md +1 -0
- package/docs/unifi.md +11 -10
- 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/linux/container-env-chain.test.ts +135 -0
- package/src/linux/container-env-live.test.ts +115 -0
- package/src/linux/container-env-oracle.ts +109 -0
- package/src/linux/container-env-quadlet-oracle.ts +174 -0
- package/src/linux/container-env-quote.test.ts +131 -0
- package/src/linux/container-env-quote.ts +126 -0
- package/src/linux/container-fixture.test.ts +3 -1
- package/src/linux/container-form.ts +32 -28
- package/src/linux/container-paths.ts +21 -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
- package/src/unifi/network-scope.test.ts +21 -0
- package/src/unifi/network.ts +2 -1
- package/src/unifi/write-op-reference.test.ts +3 -2
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* `containerProblems` below. A container that needs a secret takes `EnvironmentFile=` pointing at
|
|
17
17
|
* a host path an out-of-band secret renderer (openbao-agent) maintains; this family never reads
|
|
18
18
|
* or renders that file's contents.
|
|
19
|
+
* ⛔ `Environment=` VALUES ARE ALWAYS QUOTED, VIA container-env-quote.ts — measured 2026-10-11:
|
|
20
|
+
* unquoted, a spaced value splits at its first space, a quoted fragment re-parses into a bogus
|
|
21
|
+
* variable, and an unknown C escape drops the line's assignments silently (ice2/grafana-oidc.md;
|
|
22
|
+
* the live podman 5.4.2 generator reproduced all three). Quoting costs the PARSE nothing — the
|
|
23
|
+
* generator reads a quoted line as exactly one assignment — and it also stops systemd expanding
|
|
24
|
+
* `%` specifiers in values. It is NOT free at deploy: every Environment line's BYTES change, so
|
|
25
|
+
* the first deploy after this bump rewrites the file and RESTARTS every container declaring
|
|
26
|
+
* `environment` (all 13 on ct100; only grafana's values change meaning) — plan it as one window.
|
|
27
|
+
* Newlines are refused, never encoded (same module).
|
|
19
28
|
* ⛔ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
|
|
20
29
|
* "Currently, only the Alias, WantedBy and RequiredBy keys are supported") — NOT `Also=`, which
|
|
21
30
|
* plain `systemd.unit(5)` allows and unit-text.ts's `INSTALL_KEYS` therefore lists. See
|
|
@@ -23,9 +32,14 @@
|
|
|
23
32
|
*/
|
|
24
33
|
import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
|
|
25
34
|
import { podmanArgsProblems, secretLikeEnvironment, secretLikeLines } from './container-secrets.ts';
|
|
35
|
+
import { environmentLines, environmentValueProblems } from './container-env-quote.ts';
|
|
36
|
+
import { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES } from './container-paths.ts';
|
|
26
37
|
|
|
27
38
|
/** ★ Reused, not redefined: same digest and `restartOn`-join contract as Systemd.Unit. */
|
|
28
39
|
export { configDigest, digestOf };
|
|
40
|
+
// ★ The search path lives in container-paths.ts (file-length cap) and is re-exported here so every
|
|
41
|
+
// importer and the barrel keep one import site — the API does not move.
|
|
42
|
+
export { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES };
|
|
29
43
|
|
|
30
44
|
export type UnitLine = readonly [key: string, value: string];
|
|
31
45
|
|
|
@@ -64,7 +78,8 @@ export type ContainerSection = {
|
|
|
64
78
|
export type ContainerProps = {
|
|
65
79
|
/** The Quadlet file's basename, WITHOUT `.container`. Written as `<name>.container`. */
|
|
66
80
|
name: string;
|
|
67
|
-
/** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES
|
|
81
|
+
/** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES
|
|
82
|
+
* (container-paths.ts, where the search path now lives). */
|
|
68
83
|
directory?: string;
|
|
69
84
|
/** `[Unit]` — `Description=`/`Documentation=` typed, everything else verbatim (see file header). */
|
|
70
85
|
unit?: { description?: string; documentation?: string; lines?: readonly UnitLine[] };
|
|
@@ -103,25 +118,8 @@ export type ContainerAttributes = {
|
|
|
103
118
|
};
|
|
104
119
|
|
|
105
120
|
/**
|
|
106
|
-
*
|
|
107
|
-
* Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
|
|
108
|
-
* following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
|
|
109
|
-
* ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
|
|
110
|
-
* 2026-09-24: `/etc/containers/systemd/caddy.container`.
|
|
111
|
-
* ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
|
|
112
|
-
* a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
|
|
113
|
-
* unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
|
|
114
|
-
* it were enabled. See `containerProblems`.
|
|
121
|
+
* ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`.
|
|
115
122
|
*/
|
|
116
|
-
export const QUADLET_SEARCH_DIRECTORIES = [
|
|
117
|
-
'/run/containers/systemd',
|
|
118
|
-
'/etc/containers/systemd',
|
|
119
|
-
'/usr/share/containers/systemd',
|
|
120
|
-
] as const;
|
|
121
|
-
|
|
122
|
-
export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
|
|
123
|
-
|
|
124
|
-
/** ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`. */
|
|
125
123
|
export const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
126
124
|
|
|
127
125
|
export const containerPathFor = (props: Pick<ContainerProps, 'name' | 'directory'>): string =>
|
|
@@ -149,13 +147,8 @@ const containerLines = (c: ContainerSection): UnitLine[] => [
|
|
|
149
147
|
...(c.exec === undefined ? [] : [['Exec', c.exec] as UnitLine]),
|
|
150
148
|
...(c.autoUpdate === undefined ? [] : [['AutoUpdate', c.autoUpdate] as UnitLine]),
|
|
151
149
|
...(c.environmentFile ?? []).map((file): UnitLine => ['EnvironmentFile', file]),
|
|
152
|
-
//
|
|
153
|
-
...
|
|
154
|
-
.sort()
|
|
155
|
-
.map((key): UnitLine => [
|
|
156
|
-
'Environment',
|
|
157
|
-
`${key}=${(c.environment as Record<string, string>)[key]}`,
|
|
158
|
-
]),
|
|
150
|
+
// ⛔ Always quoted, via container-env-quote.ts (see this file's header) — sorted there too.
|
|
151
|
+
...environmentLines(c.environment ?? {}),
|
|
159
152
|
...(c.volume ?? []).map((volume): UnitLine => ['Volume', volume]),
|
|
160
153
|
...(c.publishPort ?? []).map((port): UnitLine => ['PublishPort', port]),
|
|
161
154
|
...(c.podmanArgs ?? []).map((arg): UnitLine => ['PodmanArgs', arg]),
|
|
@@ -225,19 +218,30 @@ export const containerProblems = (props: ContainerProps): string[] => {
|
|
|
225
218
|
(problem) => `container.${problem}`,
|
|
226
219
|
),
|
|
227
220
|
);
|
|
221
|
+
// ⛔ Newlines in a key or value are refused before anything renders: a .container file is read
|
|
222
|
+
// line by line, so a raw newline ends the assignment mid-value and whatever follows parses as a
|
|
223
|
+
// NEW directive (key injection when a value comes from external config). Strings here so one
|
|
224
|
+
// plan shows every refusal at once; the typed error stays the contract for direct renderers.
|
|
225
|
+
const envProblems = environmentValueProblems(props.container.environment ?? {});
|
|
226
|
+
found.push(...envProblems);
|
|
228
227
|
// ★ Every OTHER rendered line — `Exec=`/`PodmanArgs=` and the `unit`/`service`/`install` escape
|
|
229
228
|
// hatches — checked too, so a secret cannot reach the file through a field this typed check does
|
|
230
229
|
// not otherwise inspect. `container.environment`'s OWN lines are excluded: `secretLikeEnvironment`
|
|
231
230
|
// above already checked them, with a better error message (the actual env var name).
|
|
231
|
+
// ⛔ The sweep renders WITHOUT the environment (a newline value cannot throw here); the NUL
|
|
232
|
+
// check is skipped when the environment could not render — the refusals above are its result.
|
|
233
|
+
const { environment: _env, ...withoutEnvironment } = props.container;
|
|
232
234
|
found.push(
|
|
233
235
|
...secretLikeLines([
|
|
234
236
|
...unitLines(props),
|
|
235
|
-
|
|
237
|
+
// ⛔ No `Environment` filter needed: the destructure above removed the only field whose
|
|
238
|
+
// lines would carry it, so `containerLines` here emits none — a filter would be dead code.
|
|
239
|
+
...containerLines(withoutEnvironment),
|
|
236
240
|
...serviceLines(props),
|
|
237
241
|
...installLines(props),
|
|
238
242
|
]),
|
|
239
243
|
);
|
|
240
|
-
if (renderContainerFile(props).includes('\u0000'))
|
|
244
|
+
if (envProblems.length === 0 && renderContainerFile(props).includes('\u0000'))
|
|
241
245
|
found.push('the rendered .container file contains NUL');
|
|
242
246
|
return found;
|
|
243
247
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Quadlet looks for `.container` files — split out of container-form.ts under the file-length
|
|
3
|
+
* cap, with the measured context that justifies every entry kept intact.
|
|
4
|
+
*
|
|
5
|
+
* Podman's OWN rootful search path, most specific first — `podman-systemd.unit(5)`, "Podman Unit
|
|
6
|
+
* Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
|
|
7
|
+
* following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
|
|
8
|
+
* ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
|
|
9
|
+
* 2026-09-24: `/etc/containers/systemd/caddy.container`.
|
|
10
|
+
* ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
|
|
11
|
+
* a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
|
|
12
|
+
* unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
|
|
13
|
+
* it were enabled. See `containerProblems`.
|
|
14
|
+
*/
|
|
15
|
+
export const QUADLET_SEARCH_DIRECTORIES = [
|
|
16
|
+
'/run/containers/systemd',
|
|
17
|
+
'/etc/containers/systemd',
|
|
18
|
+
'/usr/share/containers/systemd',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
|
|
@@ -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
|
+
};
|
package/src/litellm/index.ts
CHANGED
|
@@ -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
|
+
);
|
|
@@ -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
|
+
});
|