@homeflare/alchemy 0.52.0 → 0.53.1
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 +2 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -1
- package/dist/linux/index.js +5 -1
- package/dist/linux/index.js.map +3 -3
- 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/linux/container-env-quote.test.ts +54 -2
- package/src/linux/container-env-quote.ts +15 -0
- package/src/linux/container-fixture.test.ts +3 -3
- 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,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
|
+
});
|
|
@@ -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
|
+
);
|