@tagma/sdk 0.4.3 → 0.4.5
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/LICENSE +21 -21
- package/README.md +12 -11
- package/dist/drivers/claude-code.d.ts.map +1 -1
- package/dist/drivers/claude-code.js +4 -6
- package/dist/drivers/claude-code.js.map +1 -1
- package/dist/engine.js +2 -2
- package/dist/engine.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +19 -5
- package/dist/schema.js.map +1 -1
- package/dist/validate-raw.d.ts +21 -3
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +78 -8
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/drivers/claude-code.ts +4 -6
- package/src/engine.ts +2 -2
- package/src/registry.ts +214 -214
- package/src/schema.ts +19 -5
- package/src/validate-raw.ts +100 -8
package/src/registry.ts
CHANGED
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
PluginCategory, DriverPlugin, TriggerPlugin,
|
|
3
|
-
CompletionPlugin, MiddlewarePlugin, PluginManifest,
|
|
4
|
-
} from './types';
|
|
5
|
-
|
|
6
|
-
type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
|
|
7
|
-
|
|
8
|
-
const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
|
|
9
|
-
'drivers', 'triggers', 'completions', 'middlewares',
|
|
10
|
-
]);
|
|
11
|
-
|
|
12
|
-
const registries = {
|
|
13
|
-
drivers: new Map<string, DriverPlugin>(),
|
|
14
|
-
triggers: new Map<string, TriggerPlugin>(),
|
|
15
|
-
completions: new Map<string, CompletionPlugin>(),
|
|
16
|
-
middlewares: new Map<string, MiddlewarePlugin>(),
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Minimal contract enforcement so a malformed plugin fails fast at
|
|
21
|
-
* registration time rather than crashing the engine mid-run.
|
|
22
|
-
*
|
|
23
|
-
* For drivers we materialize `capabilities` and assert each field is a
|
|
24
|
-
* boolean — otherwise a plugin author can write
|
|
25
|
-
* get capabilities() { throw new Error('boom') }
|
|
26
|
-
* and pass the basic typeof check, then crash preflight when the engine
|
|
27
|
-
* touches `driver.capabilities.sessionResume`. (R8)
|
|
28
|
-
*/
|
|
29
|
-
function validateContract(category: PluginCategory, handler: unknown): void {
|
|
30
|
-
if (!handler || typeof handler !== 'object') {
|
|
31
|
-
throw new Error(`Plugin handler for category "${category}" must be an object`);
|
|
32
|
-
}
|
|
33
|
-
const h = handler as Record<string, unknown>;
|
|
34
|
-
if (typeof h.name !== 'string' || h.name.length === 0) {
|
|
35
|
-
throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
|
|
36
|
-
}
|
|
37
|
-
switch (category) {
|
|
38
|
-
case 'drivers': {
|
|
39
|
-
if (typeof h.buildCommand !== 'function') {
|
|
40
|
-
throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
|
|
41
|
-
}
|
|
42
|
-
// Materialize capabilities — this triggers any throwing getter NOW
|
|
43
|
-
// instead of during preflight.
|
|
44
|
-
let caps: unknown;
|
|
45
|
-
try {
|
|
46
|
-
caps = h.capabilities;
|
|
47
|
-
} catch (err) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`drivers plugin "${h.name}" capabilities accessor threw: ` +
|
|
50
|
-
(err instanceof Error ? err.message : String(err))
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
if (!caps || typeof caps !== 'object') {
|
|
54
|
-
throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
|
|
55
|
-
}
|
|
56
|
-
const c = caps as Record<string, unknown>;
|
|
57
|
-
for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
|
|
58
|
-
if (typeof c[field] !== 'boolean') {
|
|
59
|
-
throw new Error(
|
|
60
|
-
`drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
// Optional methods, but if present must be functions.
|
|
65
|
-
for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
|
|
66
|
-
if (h[opt] !== undefined && typeof h[opt] !== 'function') {
|
|
67
|
-
throw new Error(
|
|
68
|
-
`drivers plugin "${h.name}".${opt} must be a function or undefined`
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
case 'triggers':
|
|
75
|
-
if (typeof h.watch !== 'function') {
|
|
76
|
-
throw new Error(`triggers plugin "${h.name}" must export watch()`);
|
|
77
|
-
}
|
|
78
|
-
break;
|
|
79
|
-
case 'completions':
|
|
80
|
-
if (typeof h.check !== 'function') {
|
|
81
|
-
throw new Error(`completions plugin "${h.name}" must export check()`);
|
|
82
|
-
}
|
|
83
|
-
break;
|
|
84
|
-
case 'middlewares':
|
|
85
|
-
if (typeof h.enhance !== 'function') {
|
|
86
|
-
throw new Error(`middlewares plugin "${h.name}" must export enhance()`);
|
|
87
|
-
}
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Register a plugin under (category, type). Returns:
|
|
96
|
-
* - 'registered' on first registration
|
|
97
|
-
* - 'replaced' when an existing entry was overwritten with a different handler
|
|
98
|
-
* - 'unchanged' when the same handler instance was already present
|
|
99
|
-
*
|
|
100
|
-
* Throws if `category` is unknown, `type` is empty, or `handler` violates the
|
|
101
|
-
* minimum interface contract for the category.
|
|
102
|
-
*/
|
|
103
|
-
export function registerPlugin<T extends PluginType>(
|
|
104
|
-
category: PluginCategory, type: string, handler: T,
|
|
105
|
-
): RegisterResult {
|
|
106
|
-
if (!VALID_CATEGORIES.has(category)) {
|
|
107
|
-
throw new Error(`Unknown plugin category "${category}"`);
|
|
108
|
-
}
|
|
109
|
-
if (typeof type !== 'string' || type.length === 0) {
|
|
110
|
-
throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
|
|
111
|
-
}
|
|
112
|
-
validateContract(category, handler);
|
|
113
|
-
const registry = registries[category] as Map<string, T>;
|
|
114
|
-
const existing = registry.get(type);
|
|
115
|
-
if (existing === handler) return 'unchanged';
|
|
116
|
-
const wasReplaced = existing !== undefined;
|
|
117
|
-
registry.set(type, handler);
|
|
118
|
-
return wasReplaced ? 'replaced' : 'registered';
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Remove a plugin from the in-process registry. Returns true if a plugin
|
|
123
|
-
* was actually removed. Note: ESM module caching is not affected, so
|
|
124
|
-
* re-importing the same file after unregister will yield the cached module —
|
|
125
|
-
* callers wanting a fresh load must restart the host process.
|
|
126
|
-
*/
|
|
127
|
-
export function unregisterPlugin(category: PluginCategory, type: string): boolean {
|
|
128
|
-
if (!VALID_CATEGORIES.has(category)) return false;
|
|
129
|
-
return registries[category].delete(type);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function getHandler<T extends PluginType>(
|
|
133
|
-
category: PluginCategory, type: string,
|
|
134
|
-
): T {
|
|
135
|
-
const handler = registries[category].get(type);
|
|
136
|
-
if (!handler) {
|
|
137
|
-
throw new Error(
|
|
138
|
-
`${category} type "${type}" not registered.\n` +
|
|
139
|
-
`Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
return handler as T;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function hasHandler(category: PluginCategory, type: string): boolean {
|
|
146
|
-
return registries[category].has(type);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Plugin name must be a scoped npm package or a tagma-prefixed package.
|
|
150
|
-
// Reject absolute/relative paths and suspicious patterns to prevent
|
|
151
|
-
// arbitrary code execution via crafted YAML configs.
|
|
152
|
-
export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
|
|
153
|
-
|
|
154
|
-
export function isValidPluginName(name: unknown): name is string {
|
|
155
|
-
return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Parse and validate the `tagmaPlugin` field of a `package.json` blob.
|
|
160
|
-
*
|
|
161
|
-
* Returns the strongly-typed manifest if the field is present and
|
|
162
|
-
* well-formed (`category` is one of the four known categories and `type`
|
|
163
|
-
* is a non-empty string). Returns `null` if the field is absent — that
|
|
164
|
-
* is the host's signal that the package is a library, not a plugin.
|
|
165
|
-
*
|
|
166
|
-
* Throws if the field is present but malformed: that's a packaging bug
|
|
167
|
-
* the plugin author should hear about loudly, not a silent skip.
|
|
168
|
-
*
|
|
169
|
-
* Hosts use this during auto-discovery to decide whether to load a
|
|
170
|
-
* package as a plugin without having to dynamically `import()` it.
|
|
171
|
-
*/
|
|
172
|
-
export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
|
|
173
|
-
if (!pkgJson || typeof pkgJson !== 'object') return null;
|
|
174
|
-
const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
|
|
175
|
-
if (raw === undefined) return null;
|
|
176
|
-
if (!raw || typeof raw !== 'object') {
|
|
177
|
-
throw new Error('tagmaPlugin field must be an object with { category, type }');
|
|
178
|
-
}
|
|
179
|
-
const m = raw as Record<string, unknown>;
|
|
180
|
-
const category = m.category;
|
|
181
|
-
const type = m.type;
|
|
182
|
-
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
|
|
183
|
-
throw new Error(
|
|
184
|
-
`tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
if (typeof type !== 'string' || type.length === 0) {
|
|
188
|
-
throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
|
|
189
|
-
}
|
|
190
|
-
return { category: category as PluginCategory, type };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
|
|
194
|
-
for (const name of pluginNames) {
|
|
195
|
-
if (!isValidPluginName(name)) {
|
|
196
|
-
throw new Error(
|
|
197
|
-
`Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
|
|
198
|
-
`(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
|
|
199
|
-
`Relative/absolute paths are not allowed.`
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
const mod = await import(name);
|
|
203
|
-
if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
|
|
204
|
-
throw new Error(
|
|
205
|
-
`Plugin "${name}" must export pluginCategory, pluginType, and default`
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function listRegistered(category: PluginCategory): string[] {
|
|
213
|
-
return [...registries[category].keys()];
|
|
214
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
PluginCategory, DriverPlugin, TriggerPlugin,
|
|
3
|
+
CompletionPlugin, MiddlewarePlugin, PluginManifest,
|
|
4
|
+
} from './types';
|
|
5
|
+
|
|
6
|
+
type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
|
|
7
|
+
|
|
8
|
+
const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
|
|
9
|
+
'drivers', 'triggers', 'completions', 'middlewares',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const registries = {
|
|
13
|
+
drivers: new Map<string, DriverPlugin>(),
|
|
14
|
+
triggers: new Map<string, TriggerPlugin>(),
|
|
15
|
+
completions: new Map<string, CompletionPlugin>(),
|
|
16
|
+
middlewares: new Map<string, MiddlewarePlugin>(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal contract enforcement so a malformed plugin fails fast at
|
|
21
|
+
* registration time rather than crashing the engine mid-run.
|
|
22
|
+
*
|
|
23
|
+
* For drivers we materialize `capabilities` and assert each field is a
|
|
24
|
+
* boolean — otherwise a plugin author can write
|
|
25
|
+
* get capabilities() { throw new Error('boom') }
|
|
26
|
+
* and pass the basic typeof check, then crash preflight when the engine
|
|
27
|
+
* touches `driver.capabilities.sessionResume`. (R8)
|
|
28
|
+
*/
|
|
29
|
+
function validateContract(category: PluginCategory, handler: unknown): void {
|
|
30
|
+
if (!handler || typeof handler !== 'object') {
|
|
31
|
+
throw new Error(`Plugin handler for category "${category}" must be an object`);
|
|
32
|
+
}
|
|
33
|
+
const h = handler as Record<string, unknown>;
|
|
34
|
+
if (typeof h.name !== 'string' || h.name.length === 0) {
|
|
35
|
+
throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
|
|
36
|
+
}
|
|
37
|
+
switch (category) {
|
|
38
|
+
case 'drivers': {
|
|
39
|
+
if (typeof h.buildCommand !== 'function') {
|
|
40
|
+
throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
|
|
41
|
+
}
|
|
42
|
+
// Materialize capabilities — this triggers any throwing getter NOW
|
|
43
|
+
// instead of during preflight.
|
|
44
|
+
let caps: unknown;
|
|
45
|
+
try {
|
|
46
|
+
caps = h.capabilities;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`drivers plugin "${h.name}" capabilities accessor threw: ` +
|
|
50
|
+
(err instanceof Error ? err.message : String(err))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (!caps || typeof caps !== 'object') {
|
|
54
|
+
throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
|
|
55
|
+
}
|
|
56
|
+
const c = caps as Record<string, unknown>;
|
|
57
|
+
for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
|
|
58
|
+
if (typeof c[field] !== 'boolean') {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Optional methods, but if present must be functions.
|
|
65
|
+
for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
|
|
66
|
+
if (h[opt] !== undefined && typeof h[opt] !== 'function') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`drivers plugin "${h.name}".${opt} must be a function or undefined`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'triggers':
|
|
75
|
+
if (typeof h.watch !== 'function') {
|
|
76
|
+
throw new Error(`triggers plugin "${h.name}" must export watch()`);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
case 'completions':
|
|
80
|
+
if (typeof h.check !== 'function') {
|
|
81
|
+
throw new Error(`completions plugin "${h.name}" must export check()`);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
case 'middlewares':
|
|
85
|
+
if (typeof h.enhance !== 'function') {
|
|
86
|
+
throw new Error(`middlewares plugin "${h.name}" must export enhance()`);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register a plugin under (category, type). Returns:
|
|
96
|
+
* - 'registered' on first registration
|
|
97
|
+
* - 'replaced' when an existing entry was overwritten with a different handler
|
|
98
|
+
* - 'unchanged' when the same handler instance was already present
|
|
99
|
+
*
|
|
100
|
+
* Throws if `category` is unknown, `type` is empty, or `handler` violates the
|
|
101
|
+
* minimum interface contract for the category.
|
|
102
|
+
*/
|
|
103
|
+
export function registerPlugin<T extends PluginType>(
|
|
104
|
+
category: PluginCategory, type: string, handler: T,
|
|
105
|
+
): RegisterResult {
|
|
106
|
+
if (!VALID_CATEGORIES.has(category)) {
|
|
107
|
+
throw new Error(`Unknown plugin category "${category}"`);
|
|
108
|
+
}
|
|
109
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
110
|
+
throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
|
|
111
|
+
}
|
|
112
|
+
validateContract(category, handler);
|
|
113
|
+
const registry = registries[category] as Map<string, T>;
|
|
114
|
+
const existing = registry.get(type);
|
|
115
|
+
if (existing === handler) return 'unchanged';
|
|
116
|
+
const wasReplaced = existing !== undefined;
|
|
117
|
+
registry.set(type, handler);
|
|
118
|
+
return wasReplaced ? 'replaced' : 'registered';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove a plugin from the in-process registry. Returns true if a plugin
|
|
123
|
+
* was actually removed. Note: ESM module caching is not affected, so
|
|
124
|
+
* re-importing the same file after unregister will yield the cached module —
|
|
125
|
+
* callers wanting a fresh load must restart the host process.
|
|
126
|
+
*/
|
|
127
|
+
export function unregisterPlugin(category: PluginCategory, type: string): boolean {
|
|
128
|
+
if (!VALID_CATEGORIES.has(category)) return false;
|
|
129
|
+
return registries[category].delete(type);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getHandler<T extends PluginType>(
|
|
133
|
+
category: PluginCategory, type: string,
|
|
134
|
+
): T {
|
|
135
|
+
const handler = registries[category].get(type);
|
|
136
|
+
if (!handler) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${category} type "${type}" not registered.\n` +
|
|
139
|
+
`Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return handler as T;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function hasHandler(category: PluginCategory, type: string): boolean {
|
|
146
|
+
return registries[category].has(type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Plugin name must be a scoped npm package or a tagma-prefixed package.
|
|
150
|
+
// Reject absolute/relative paths and suspicious patterns to prevent
|
|
151
|
+
// arbitrary code execution via crafted YAML configs.
|
|
152
|
+
export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
|
|
153
|
+
|
|
154
|
+
export function isValidPluginName(name: unknown): name is string {
|
|
155
|
+
return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parse and validate the `tagmaPlugin` field of a `package.json` blob.
|
|
160
|
+
*
|
|
161
|
+
* Returns the strongly-typed manifest if the field is present and
|
|
162
|
+
* well-formed (`category` is one of the four known categories and `type`
|
|
163
|
+
* is a non-empty string). Returns `null` if the field is absent — that
|
|
164
|
+
* is the host's signal that the package is a library, not a plugin.
|
|
165
|
+
*
|
|
166
|
+
* Throws if the field is present but malformed: that's a packaging bug
|
|
167
|
+
* the plugin author should hear about loudly, not a silent skip.
|
|
168
|
+
*
|
|
169
|
+
* Hosts use this during auto-discovery to decide whether to load a
|
|
170
|
+
* package as a plugin without having to dynamically `import()` it.
|
|
171
|
+
*/
|
|
172
|
+
export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
|
|
173
|
+
if (!pkgJson || typeof pkgJson !== 'object') return null;
|
|
174
|
+
const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
|
|
175
|
+
if (raw === undefined) return null;
|
|
176
|
+
if (!raw || typeof raw !== 'object') {
|
|
177
|
+
throw new Error('tagmaPlugin field must be an object with { category, type }');
|
|
178
|
+
}
|
|
179
|
+
const m = raw as Record<string, unknown>;
|
|
180
|
+
const category = m.category;
|
|
181
|
+
const type = m.type;
|
|
182
|
+
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
188
|
+
throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
|
|
189
|
+
}
|
|
190
|
+
return { category: category as PluginCategory, type };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
|
|
194
|
+
for (const name of pluginNames) {
|
|
195
|
+
if (!isValidPluginName(name)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
|
|
198
|
+
`(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
|
|
199
|
+
`Relative/absolute paths are not allowed.`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const mod = await import(name);
|
|
203
|
+
if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Plugin "${name}" must export pluginCategory, pluginType, and default`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function listRegistered(category: PluginCategory): string[] {
|
|
213
|
+
return [...registries[category].keys()];
|
|
214
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -264,8 +264,9 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
264
264
|
? qualifyContinueFrom(rawTask.continue_from, rawTrack.id)
|
|
265
265
|
: undefined,
|
|
266
266
|
output: rawTask.output,
|
|
267
|
-
// Inheritance: Task > Track
|
|
268
|
-
|
|
267
|
+
// Inheritance: Task > Track > Pipeline
|
|
268
|
+
model: rawTask.model ?? rawTrack.model ?? raw.model,
|
|
269
|
+
reasoning_effort: rawTask.reasoning_effort ?? rawTrack.reasoning_effort ?? raw.reasoning_effort,
|
|
269
270
|
permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
270
271
|
driver: rawTask.driver ?? trackDriver ?? 'claude-code',
|
|
271
272
|
timeout: rawTask.timeout,
|
|
@@ -282,7 +283,8 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
282
283
|
name: rawTrack.name,
|
|
283
284
|
color: rawTrack.color,
|
|
284
285
|
agent_profile: rawTrack.agent_profile,
|
|
285
|
-
|
|
286
|
+
model: rawTrack.model ?? raw.model,
|
|
287
|
+
reasoning_effort: rawTrack.reasoning_effort ?? raw.reasoning_effort,
|
|
286
288
|
permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
287
289
|
driver: trackDriver ?? 'claude-code',
|
|
288
290
|
cwd: trackCwd,
|
|
@@ -295,6 +297,8 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
295
297
|
return {
|
|
296
298
|
name: raw.name,
|
|
297
299
|
driver: raw.driver,
|
|
300
|
+
model: raw.model,
|
|
301
|
+
reasoning_effort: raw.reasoning_effort,
|
|
298
302
|
timeout: raw.timeout,
|
|
299
303
|
plugins: raw.plugins,
|
|
300
304
|
hooks: raw.hooks,
|
|
@@ -334,6 +338,8 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
334
338
|
? relative(workDir, track.cwd)
|
|
335
339
|
: undefined;
|
|
336
340
|
const effectiveTrackDriver = track.driver ?? config.driver ?? 'claude-code';
|
|
341
|
+
const effectiveTrackModel = track.model ?? config.model;
|
|
342
|
+
const effectiveTrackReasoning = track.reasoning_effort ?? config.reasoning_effort;
|
|
337
343
|
|
|
338
344
|
const tasks: RawTaskConfig[] = track.tasks.map(task => {
|
|
339
345
|
const taskCwdRel = task.cwd && task.cwd !== track.cwd
|
|
@@ -350,7 +356,10 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
350
356
|
...(task.continue_from ? { continue_from: task.continue_from } : {}),
|
|
351
357
|
...(task.output ? { output: task.output } : {}),
|
|
352
358
|
...(taskCwdRel ? { cwd: taskCwdRel } : {}),
|
|
353
|
-
...(task.
|
|
359
|
+
...(task.model && task.model !== effectiveTrackModel ? { model: task.model } : {}),
|
|
360
|
+
...(task.reasoning_effort && task.reasoning_effort !== effectiveTrackReasoning
|
|
361
|
+
? { reasoning_effort: task.reasoning_effort }
|
|
362
|
+
: {}),
|
|
354
363
|
...(task.driver && task.driver !== effectiveTrackDriver ? { driver: task.driver } : {}),
|
|
355
364
|
...(task.timeout ? { timeout: task.timeout } : {}),
|
|
356
365
|
...(task.middlewares !== undefined ? { middlewares: task.middlewares } : {}),
|
|
@@ -367,7 +376,10 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
367
376
|
name: track.name,
|
|
368
377
|
...(track.color ? { color: track.color } : {}),
|
|
369
378
|
...(track.agent_profile ? { agent_profile: track.agent_profile } : {}),
|
|
370
|
-
...(track.
|
|
379
|
+
...(track.model && track.model !== config.model ? { model: track.model } : {}),
|
|
380
|
+
...(track.reasoning_effort && track.reasoning_effort !== config.reasoning_effort
|
|
381
|
+
? { reasoning_effort: track.reasoning_effort }
|
|
382
|
+
: {}),
|
|
371
383
|
...(track.driver && track.driver !== (config.driver ?? 'claude-code') ? { driver: track.driver } : {}),
|
|
372
384
|
...(trackCwdRel ? { cwd: trackCwdRel } : {}),
|
|
373
385
|
...(track.middlewares?.length ? { middlewares: track.middlewares } : {}),
|
|
@@ -382,6 +394,8 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
382
394
|
return {
|
|
383
395
|
name: config.name,
|
|
384
396
|
...(config.driver ? { driver: config.driver } : {}),
|
|
397
|
+
...(config.model ? { model: config.model } : {}),
|
|
398
|
+
...(config.reasoning_effort ? { reasoning_effort: config.reasoning_effort } : {}),
|
|
385
399
|
...(config.timeout ? { timeout: config.timeout } : {}),
|
|
386
400
|
...(config.plugins?.length ? { plugins: config.plugins } : {}),
|
|
387
401
|
...(config.hooks ? { hooks: config.hooks } : {}),
|
package/src/validate-raw.ts
CHANGED
|
@@ -14,7 +14,32 @@ function isValidDuration(input: string): boolean {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
|
|
17
|
-
const
|
|
17
|
+
const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
|
|
18
|
+
|
|
19
|
+
// Built-in plugin types always known to the SDK core, regardless of which
|
|
20
|
+
// external plugin packages are installed. These MUST stay in sync with the
|
|
21
|
+
// types that `bootstrapBuiltins()` registers, otherwise the editor will
|
|
22
|
+
// emit false-positive "unknown type" warnings for stock pipelines.
|
|
23
|
+
const BUILTIN_TRIGGER_TYPES: ReadonlySet<string> = new Set(['manual', 'file']);
|
|
24
|
+
const BUILTIN_COMPLETION_TYPES: ReadonlySet<string> = new Set([
|
|
25
|
+
'exit_code', 'file_exists', 'output_check',
|
|
26
|
+
]);
|
|
27
|
+
const BUILTIN_MIDDLEWARE_TYPES: ReadonlySet<string> = new Set(['static_context']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Optional second argument to `validateRaw`: the set of plugin types currently
|
|
31
|
+
* registered in the SDK runtime, keyed by category. Hosts (e.g. the editor
|
|
32
|
+
* server) pass this so `validateRaw` can emit a soft warning when a task
|
|
33
|
+
* references a type that isn't loaded — otherwise the Task panel would show
|
|
34
|
+
* no hint and the pipeline would only blow up at run time. Callers that
|
|
35
|
+
* legitimately validate a config offline (before plugins are loaded) can omit
|
|
36
|
+
* this argument and no plugin warnings will be produced.
|
|
37
|
+
*/
|
|
38
|
+
export interface KnownPluginTypes {
|
|
39
|
+
readonly triggers?: readonly string[];
|
|
40
|
+
readonly completions?: readonly string[];
|
|
41
|
+
readonly middlewares?: readonly string[];
|
|
42
|
+
}
|
|
18
43
|
|
|
19
44
|
export type ValidationSeverity = 'error' | 'warning';
|
|
20
45
|
|
|
@@ -38,16 +63,36 @@ export interface ValidationError {
|
|
|
38
63
|
* Checks structure, required fields, prompt/command exclusivity,
|
|
39
64
|
* depends_on reference integrity, and circular dependencies.
|
|
40
65
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
66
|
+
* Plugin type checks: when `knownTypes` is provided, task/track references to
|
|
67
|
+
* trigger/completion/middleware types that are neither built-in nor in the
|
|
68
|
+
* supplied set produce a soft warning (severity: 'warning') — these don't
|
|
69
|
+
* block save/run but light up the Task panel so users discover the broken
|
|
70
|
+
* reference in the editor instead of at run time. Omit `knownTypes` to skip
|
|
71
|
+
* plugin checks entirely (offline/pre-load validation).
|
|
43
72
|
*/
|
|
44
|
-
export function validateRaw(
|
|
73
|
+
export function validateRaw(
|
|
74
|
+
config: RawPipelineConfig,
|
|
75
|
+
knownTypes?: KnownPluginTypes,
|
|
76
|
+
): ValidationError[] {
|
|
45
77
|
const errors: ValidationError[] = [];
|
|
46
78
|
|
|
79
|
+
const knownTriggers = knownTypes
|
|
80
|
+
? new Set<string>([...BUILTIN_TRIGGER_TYPES, ...(knownTypes.triggers ?? [])])
|
|
81
|
+
: null;
|
|
82
|
+
const knownCompletions = knownTypes
|
|
83
|
+
? new Set<string>([...BUILTIN_COMPLETION_TYPES, ...(knownTypes.completions ?? [])])
|
|
84
|
+
: null;
|
|
85
|
+
const knownMiddlewares = knownTypes
|
|
86
|
+
? new Set<string>([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
|
|
87
|
+
: null;
|
|
88
|
+
|
|
47
89
|
// ── Top level ──
|
|
48
90
|
if (!config.name?.trim()) {
|
|
49
91
|
errors.push({ path: 'name', message: 'Pipeline name is required' });
|
|
50
92
|
}
|
|
93
|
+
if (config.reasoning_effort && !VALID_REASONING_EFFORT.has(config.reasoning_effort)) {
|
|
94
|
+
errors.push({ path: 'reasoning_effort', message: `Invalid reasoning_effort "${config.reasoning_effort}". Expected "low", "medium", or "high".` });
|
|
95
|
+
}
|
|
51
96
|
|
|
52
97
|
if (!config.tracks || config.tracks.length === 0) {
|
|
53
98
|
errors.push({ path: 'tracks', message: 'At least one track is required' });
|
|
@@ -94,8 +139,24 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
|
|
|
94
139
|
if (track.on_failure && !VALID_ON_FAILURE.has(track.on_failure)) {
|
|
95
140
|
errors.push({ path: `${trackPath}.on_failure`, message: `Invalid on_failure value "${track.on_failure}". Expected "skip_downstream", "stop_all", or "ignore".` });
|
|
96
141
|
}
|
|
97
|
-
if (track.
|
|
98
|
-
errors.push({ path: `${trackPath}.
|
|
142
|
+
if (track.reasoning_effort && !VALID_REASONING_EFFORT.has(track.reasoning_effort)) {
|
|
143
|
+
errors.push({ path: `${trackPath}.reasoning_effort`, message: `Invalid reasoning_effort "${track.reasoning_effort}". Expected "low", "medium", or "high".` });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Track-level middlewares can reference a plugin that was uninstalled
|
|
147
|
+
// after the YAML was written — surface a warning so the user notices
|
|
148
|
+
// before hitting Run.
|
|
149
|
+
if (knownMiddlewares && track.middlewares) {
|
|
150
|
+
for (let mi = 0; mi < track.middlewares.length; mi++) {
|
|
151
|
+
const mw = track.middlewares[mi];
|
|
152
|
+
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
153
|
+
errors.push({
|
|
154
|
+
path: `${trackPath}.middlewares[${mi}].type`,
|
|
155
|
+
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference — the pipeline will fail at run time.`,
|
|
156
|
+
severity: 'warning',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
99
160
|
}
|
|
100
161
|
|
|
101
162
|
if (!track.tasks || track.tasks.length === 0) {
|
|
@@ -153,8 +214,39 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
|
|
|
153
214
|
if (task.timeout && !isValidDuration(task.timeout)) {
|
|
154
215
|
errors.push({ path: `${taskPath}.timeout`, message: `Invalid duration format "${task.timeout}". Expected e.g. "30s", "5m", "1h".` });
|
|
155
216
|
}
|
|
156
|
-
if (task.
|
|
157
|
-
errors.push({ path: `${taskPath}.
|
|
217
|
+
if (task.reasoning_effort && !VALID_REASONING_EFFORT.has(task.reasoning_effort)) {
|
|
218
|
+
errors.push({ path: `${taskPath}.reasoning_effort`, message: `Invalid reasoning_effort "${task.reasoning_effort}". Expected "low", "medium", or "high".` });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Plugin type warnings (trigger / completion / middlewares) ──
|
|
222
|
+
// Only fire when the host supplied a `knownTypes` snapshot, so offline
|
|
223
|
+
// validation stays quiet. The messages deliberately name the npm
|
|
224
|
+
// scope so users can copy-paste the install command.
|
|
225
|
+
if (knownTriggers && task.trigger?.type && !knownTriggers.has(task.trigger.type)) {
|
|
226
|
+
errors.push({
|
|
227
|
+
path: `${taskPath}.trigger.type`,
|
|
228
|
+
message: `Trigger type "${task.trigger.type}" is not registered. Install the plugin (e.g. @tagma/trigger-${task.trigger.type}) or the task will fail at run time.`,
|
|
229
|
+
severity: 'warning',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (knownCompletions && task.completion?.type && !knownCompletions.has(task.completion.type)) {
|
|
233
|
+
errors.push({
|
|
234
|
+
path: `${taskPath}.completion.type`,
|
|
235
|
+
message: `Completion type "${task.completion.type}" is not registered. Install the plugin (e.g. @tagma/completion-${task.completion.type}) or the task will fail at run time.`,
|
|
236
|
+
severity: 'warning',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (knownMiddlewares && task.middlewares) {
|
|
240
|
+
for (let mi = 0; mi < task.middlewares.length; mi++) {
|
|
241
|
+
const mw = task.middlewares[mi];
|
|
242
|
+
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
243
|
+
errors.push({
|
|
244
|
+
path: `${taskPath}.middlewares[${mi}].type`,
|
|
245
|
+
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference — the pipeline will fail at run time.`,
|
|
246
|
+
severity: 'warning',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
158
250
|
}
|
|
159
251
|
|
|
160
252
|
// ── depends_on reference checks ──
|