kritya 0.8.10-beta → 0.8.11-beta
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/README.md +13 -0
- package/dist/headless.js +16 -0
- package/dist/index.js +0 -0
- package/package.json +4 -1
- package/dist/agent/plugins.js +0 -408
- package/dist/commands/pluginsCommand.js +0 -204
package/README.md
CHANGED
|
@@ -236,6 +236,19 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
|
|
|
236
236
|
anywhere, including while a permission prompt is on screen. The statusline
|
|
237
237
|
shows `⛔ KILLED`, and both the stop and the release are written to the audit
|
|
238
238
|
log. It's session-only: restarting kritya comes up in the normal state.
|
|
239
|
+
Some terminals steal `Ctrl+K` for their own shortcuts before kritya ever
|
|
240
|
+
sees it — VS Code's integrated terminal is the common case, where it's
|
|
241
|
+
bound as a chord prefix. `/kill [reason]` always works there instead; to
|
|
242
|
+
get `Ctrl+K` itself working, add a `terminalFocus`-scoped override to VS
|
|
243
|
+
Code's `keybindings.json`:
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"key": "ctrl+k",
|
|
247
|
+
"command": "workbench.action.terminal.sendSequence",
|
|
248
|
+
"args": { "text": "\u000b" },
|
|
249
|
+
"when": "terminalFocus"
|
|
250
|
+
}
|
|
251
|
+
```
|
|
239
252
|
- **Subagents** — the agent can dispatch one or more focused investigations to
|
|
240
253
|
fresh contexts at once (`spawn_agent`), each returning only its findings —
|
|
241
254
|
keeps the main conversation lean on big searches. It can also dispatch
|
package/dist/headless.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Agent } from "./agent/loop.js";
|
|
3
|
+
import { KillSwitchError } from "./agent/killSwitch.js";
|
|
3
4
|
import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
4
5
|
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
5
6
|
import { PermissionManager } from "./permissions/permissions.js";
|
|
@@ -184,6 +185,16 @@ export async function runHeadless(args) {
|
|
|
184
185
|
const controller = new AbortController();
|
|
185
186
|
const timer = setTimeout(() => controller.abort(), Math.max(1, args.timeoutSeconds) * 1000);
|
|
186
187
|
timer.unref();
|
|
188
|
+
// Node's default SIGINT/SIGTERM handling kills the process outright —
|
|
189
|
+
// orphaning background shells/MCP children and leaving no audit trail that
|
|
190
|
+
// this was a deliberate stop rather than a crash. Route both through the
|
|
191
|
+
// same kill switch Ctrl+K uses interactively instead, so the turn tears
|
|
192
|
+
// down cleanly and the stop is logged.
|
|
193
|
+
const stopForSignal = (sig) => {
|
|
194
|
+
agent.kill.engage(`stopped by ${sig}`);
|
|
195
|
+
};
|
|
196
|
+
process.on("SIGINT", stopForSignal);
|
|
197
|
+
process.on("SIGTERM", stopForSignal);
|
|
187
198
|
const handlers = {
|
|
188
199
|
onTextDelta: () => { },
|
|
189
200
|
onReasoningDelta: () => { },
|
|
@@ -237,12 +248,17 @@ export async function runHeadless(args) {
|
|
|
237
248
|
: " No other provider has an API key configured to fall back to.";
|
|
238
249
|
errorMessage = `${err.message}.${hint}`;
|
|
239
250
|
}
|
|
251
|
+
else if (err instanceof KillSwitchError) {
|
|
252
|
+
errorMessage = `Stopped (${err.killReason ?? "kill switch engaged"})`;
|
|
253
|
+
}
|
|
240
254
|
else {
|
|
241
255
|
errorMessage = err instanceof Error ? err.message : String(err);
|
|
242
256
|
}
|
|
243
257
|
}
|
|
244
258
|
finally {
|
|
245
259
|
clearTimeout(timer);
|
|
260
|
+
process.off("SIGINT", stopForSignal);
|
|
261
|
+
process.off("SIGTERM", stopForSignal);
|
|
246
262
|
backgroundManager.killAll();
|
|
247
263
|
lspManager.disposeAll();
|
|
248
264
|
shutdownMcp();
|
package/dist/index.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kritya",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.11-beta",
|
|
4
4
|
"description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"provenance": true
|
|
8
|
+
},
|
|
6
9
|
"repository": {
|
|
7
10
|
"type": "git",
|
|
8
11
|
"url": "git+https://github.com/GenAICloudDevOps/Kritya.git"
|
package/dist/agent/plugins.js
DELETED
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
/**
|
|
5
|
-
* Agent Plugins specification: a portable package format for reusable components
|
|
6
|
-
* that extend AI agents. Version 1.0.0 defines a shared format for Agent Skills
|
|
7
|
-
* and MCP servers that compatible clients can discover and load consistently.
|
|
8
|
-
*
|
|
9
|
-
* Plugin structure:
|
|
10
|
-
* my-plugin/
|
|
11
|
-
* ├── plugin.json # Required manifest
|
|
12
|
-
* ├── skills/ # Agent Skills (in Agent Skills format)
|
|
13
|
-
* ├── mcp.json # MCP server configuration
|
|
14
|
-
* └── com.example.client/ # Optional client-specific extensions
|
|
15
|
-
*
|
|
16
|
-
* @see https://agent-plugins.org/
|
|
17
|
-
* @see https://developers.googleblog.com/agent-plugins-package-your-skills-tools-and-more/
|
|
18
|
-
*/
|
|
19
|
-
/** The agent-plugins manifest file name. */
|
|
20
|
-
export const PLUGIN_MANIFEST_FILE = "plugin.json";
|
|
21
|
-
/** The skills directory name within a plugin. */
|
|
22
|
-
export const SKILLS_DIR_NAME = "skills";
|
|
23
|
-
/** The MCP config file name within a plugin. */
|
|
24
|
-
export const MCP_CONFIG_FILE = "mcp.json";
|
|
25
|
-
/** User-global plugins root, available across all workspaces. */
|
|
26
|
-
export function userPluginsDir() {
|
|
27
|
-
return path.join(os.homedir(), ".kritya", "plugins");
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Get the workspace plugins directory.
|
|
31
|
-
*/
|
|
32
|
-
export function pluginsDir(workspace) {
|
|
33
|
-
return path.join(workspace, ".kritya", "plugins");
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Extract plugin ID from manifest.
|
|
37
|
-
* Per spec, id should go in extensions under reverse-domain namespace.
|
|
38
|
-
* For simplicity, we use the plugin directory basename if not in extensions.
|
|
39
|
-
*/
|
|
40
|
-
export function getPluginId(manifest, dir) {
|
|
41
|
-
const extensions = manifest.extensions;
|
|
42
|
-
if (extensions?.["kritya.id"]) {
|
|
43
|
-
return extensions["kritya.id"];
|
|
44
|
-
}
|
|
45
|
-
// Fallback to basename of plugin directory
|
|
46
|
-
return path.basename(dir);
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Resolve a path relative to the plugin root.
|
|
50
|
-
* Ensures the result stays within the plugin directory.
|
|
51
|
-
* Throws if path escape is detected.
|
|
52
|
-
*/
|
|
53
|
-
export function resolvePluginPath(pluginDir, relativePath) {
|
|
54
|
-
const resolved = path.resolve(pluginDir, relativePath);
|
|
55
|
-
const normalizedResolved = path.normalize(resolved);
|
|
56
|
-
const normalizedPlugin = path.normalize(pluginDir);
|
|
57
|
-
// Check that resolved path starts with plugin directory (plus separator or is same)
|
|
58
|
-
if (normalizedResolved === normalizedPlugin) {
|
|
59
|
-
return normalizedResolved;
|
|
60
|
-
}
|
|
61
|
-
if (!normalizedResolved.startsWith(normalizedPlugin + path.sep)) {
|
|
62
|
-
throw new Error(`Path escape detected: ${relativePath} is outside plugin root`);
|
|
63
|
-
}
|
|
64
|
-
return normalizedResolved;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Check if a path is contained within the plugin root.
|
|
68
|
-
* Resolves symlinks to verify containment.
|
|
69
|
-
*/
|
|
70
|
-
export function isPathContained(pluginDir, candidate) {
|
|
71
|
-
try {
|
|
72
|
-
const resolvedPlugin = path.resolve(pluginDir);
|
|
73
|
-
const resolvedCandidate = path.resolve(candidate);
|
|
74
|
-
// Resolve symlinks
|
|
75
|
-
const realPlugin = fs.existsSync(resolvedPlugin) ? fs.realpathSync(resolvedPlugin) : resolvedPlugin;
|
|
76
|
-
const realCandidate = fs.existsSync(resolvedCandidate) ? fs.realpathSync(resolvedCandidate) : resolvedCandidate;
|
|
77
|
-
if (realCandidate === realPlugin)
|
|
78
|
-
return true;
|
|
79
|
-
if (!realCandidate.startsWith(realPlugin + path.sep))
|
|
80
|
-
return false;
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
// If resolution fails, fall back to basic check
|
|
85
|
-
const resolvedPlugin = path.resolve(pluginDir);
|
|
86
|
-
const resolvedCandidate = path.resolve(candidate);
|
|
87
|
-
if (resolvedCandidate === resolvedPlugin)
|
|
88
|
-
return true;
|
|
89
|
-
return resolvedCandidate.startsWith(resolvedPlugin + path.sep);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Validate plugin name per spec constraints:
|
|
94
|
-
* - 1-64 characters
|
|
95
|
-
* - lowercase ASCII letters, digits, hyphens, and periods
|
|
96
|
-
* - begins and ends with alphanumeric
|
|
97
|
-
* - cannot contain consecutive ".." or "--"
|
|
98
|
-
* @returns true if name is valid
|
|
99
|
-
*/
|
|
100
|
-
export function isValidPluginName(name) {
|
|
101
|
-
if (name.length < 1 || name.length > 64)
|
|
102
|
-
return false;
|
|
103
|
-
if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(name) && name.length > 1) {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
// Forbid consecutive ".." or "--"
|
|
107
|
-
if (name.includes("..") || name.includes("--"))
|
|
108
|
-
return false;
|
|
109
|
-
return true;
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Parse a plugin.json manifest file.
|
|
113
|
-
* Validates required fields per spec: $schema (required), name (required).
|
|
114
|
-
* Unknown fields are allowed but reported.
|
|
115
|
-
* @returns null if the file doesn't exist, is invalid JSON, or missing required fields.
|
|
116
|
-
*/
|
|
117
|
-
export function parsePluginManifest(raw) {
|
|
118
|
-
try {
|
|
119
|
-
const parsed = JSON.parse(raw);
|
|
120
|
-
if (!parsed || typeof parsed !== "object") {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
// Per spec v1.0.0, $schema and name are REQUIRED
|
|
124
|
-
if (typeof parsed.$schema !== "string" || !parsed.$schema.trim()) {
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
if (typeof parsed.name !== "string" || !parsed.name.trim()) {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
// Validate name constraints
|
|
131
|
-
const name = parsed.name;
|
|
132
|
-
if (!isValidPluginName(name)) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
return parsed;
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Load and parse a plugin manifest from a directory.
|
|
143
|
-
* Returns null if no valid manifest exists or if path escape is detected.
|
|
144
|
-
*/
|
|
145
|
-
export function loadPluginManifest(dir) {
|
|
146
|
-
const manifestPath = path.join(dir, PLUGIN_MANIFEST_FILE);
|
|
147
|
-
// Check path containment before reading
|
|
148
|
-
if (!isPathContained(dir, manifestPath)) {
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
153
|
-
return parsePluginManifest(raw);
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Discover plugins in a directory tree.
|
|
161
|
-
* Scans immediate subdirectories for plugin.json files.
|
|
162
|
-
* Validates path containment for each discovered plugin.
|
|
163
|
-
*/
|
|
164
|
-
export function scanPlugins(root) {
|
|
165
|
-
const loaded = [];
|
|
166
|
-
const skipped = [];
|
|
167
|
-
// Check if root exists and is contained
|
|
168
|
-
if (!isPathContained(path.dirname(root) || "/", root)) {
|
|
169
|
-
return { loaded, skipped };
|
|
170
|
-
}
|
|
171
|
-
let entries;
|
|
172
|
-
try {
|
|
173
|
-
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
return { loaded, skipped };
|
|
177
|
-
}
|
|
178
|
-
for (const entry of entries) {
|
|
179
|
-
if (!entry.isDirectory())
|
|
180
|
-
continue;
|
|
181
|
-
const dir = path.join(root, entry.name);
|
|
182
|
-
// Check path containment
|
|
183
|
-
if (!isPathContained(root, dir)) {
|
|
184
|
-
skipped.push({
|
|
185
|
-
dir,
|
|
186
|
-
reason: "path escape detected - directory outside plugin root",
|
|
187
|
-
});
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
const manifest = loadPluginManifest(dir);
|
|
191
|
-
if (!manifest) {
|
|
192
|
-
skipped.push({
|
|
193
|
-
dir,
|
|
194
|
-
reason: `missing or invalid ${PLUGIN_MANIFEST_FILE}`,
|
|
195
|
-
});
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
const skillsDirPath = path.join(dir, SKILLS_DIR_NAME);
|
|
199
|
-
const mcpConfigPath = path.join(dir, MCP_CONFIG_FILE);
|
|
200
|
-
// Verify skills directory is contained
|
|
201
|
-
if (fs.existsSync(skillsDirPath) && !isPathContained(dir, skillsDirPath)) {
|
|
202
|
-
skipped.push({
|
|
203
|
-
dir,
|
|
204
|
-
reason: "skills directory outside plugin root",
|
|
205
|
-
});
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
// Verify mcp.json is contained
|
|
209
|
-
if (fs.existsSync(mcpConfigPath) && !isPathContained(dir, mcpConfigPath)) {
|
|
210
|
-
skipped.push({
|
|
211
|
-
dir,
|
|
212
|
-
reason: "mcp.json outside plugin root",
|
|
213
|
-
});
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
const plugin = {
|
|
217
|
-
id: getPluginId(manifest, dir),
|
|
218
|
-
name: manifest.name,
|
|
219
|
-
version: manifest.version,
|
|
220
|
-
description: manifest.description,
|
|
221
|
-
dir,
|
|
222
|
-
manifest,
|
|
223
|
-
skillsDir: fs.existsSync(skillsDirPath) ? skillsDirPath : undefined,
|
|
224
|
-
mcpConfigFile: fs.existsSync(mcpConfigPath) ? mcpConfigPath : undefined,
|
|
225
|
-
};
|
|
226
|
-
loaded.push(plugin);
|
|
227
|
-
}
|
|
228
|
-
return { loaded, skipped };
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* Scan multiple roots for plugins, merging results.
|
|
232
|
-
* On ID collision, the first plugin found wins.
|
|
233
|
-
* The roots are scanned in order, so earlier roots take precedence.
|
|
234
|
-
*/
|
|
235
|
-
export function scanPluginsDetailed(roots) {
|
|
236
|
-
const seen = new Map();
|
|
237
|
-
const skipped = [];
|
|
238
|
-
for (const root of roots) {
|
|
239
|
-
const { loaded, skipped: rootSkipped } = scanPlugins(root);
|
|
240
|
-
skipped.push(...rootSkipped);
|
|
241
|
-
for (const plugin of loaded) {
|
|
242
|
-
const existing = seen.get(plugin.id);
|
|
243
|
-
if (existing) {
|
|
244
|
-
skipped.push({
|
|
245
|
-
dir: plugin.dir,
|
|
246
|
-
reason: `duplicate plugin ID "${plugin.id}" (already loaded from ${existing.dir})`,
|
|
247
|
-
});
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
seen.set(plugin.id, plugin);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return { loaded: [...seen.values()], skipped };
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Scan for plugins across user-global and workspace plugin directories.
|
|
257
|
-
* Project plugins are checked first, then user plugins, so project plugins win on collision.
|
|
258
|
-
*/
|
|
259
|
-
export function scanAllPlugins(workspace, userRoot) {
|
|
260
|
-
const projectRoot = pluginsDir(workspace);
|
|
261
|
-
const userPluginsRoot = userRoot ?? userPluginsDir();
|
|
262
|
-
// Project plugins take precedence over user plugins
|
|
263
|
-
const roots = [projectRoot, userPluginsRoot];
|
|
264
|
-
return scanPluginsDetailed(roots);
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Type guard for MCP server config.
|
|
268
|
-
*/
|
|
269
|
-
function isMcpServerConfig(v) {
|
|
270
|
-
if (!v || typeof v !== "object")
|
|
271
|
-
return false;
|
|
272
|
-
const cfg = v;
|
|
273
|
-
if (!cfg.type)
|
|
274
|
-
return false;
|
|
275
|
-
if (cfg.type === "stdio") {
|
|
276
|
-
return typeof cfg.command === "string";
|
|
277
|
-
}
|
|
278
|
-
if (cfg.type === "streamable-http" || cfg.type === "legacy-http-sse") {
|
|
279
|
-
return typeof cfg.url === "string";
|
|
280
|
-
}
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Expand ${VAR} from process.env; unknown variables are left as-is.
|
|
285
|
-
* Per spec, only command args and env support expansion, not url.
|
|
286
|
-
*/
|
|
287
|
-
function expandVars(value) {
|
|
288
|
-
const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
289
|
-
return value.replace(VAR_RE, (whole, name) => {
|
|
290
|
-
const v = process.env[name];
|
|
291
|
-
return v !== undefined ? v : whole;
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
/**
|
|
295
|
-
* Expand server config with environment variables and resolve relative paths.
|
|
296
|
-
* Per spec:
|
|
297
|
-
* - command, args, env: expand ${VAR} and resolve relative paths against plugin root
|
|
298
|
-
* - url: expand ${VAR} is NOT allowed per spec
|
|
299
|
-
* - headers: expand ${VAR}
|
|
300
|
-
* - cwd: defaults to plugin root if not specified
|
|
301
|
-
* - PLUGIN_ROOT and PLUGIN_DATA are injected for stdio plugins
|
|
302
|
-
*/
|
|
303
|
-
export function expandServerConfig(cfg, pluginDir) {
|
|
304
|
-
function expandRecord(rec) {
|
|
305
|
-
if (!rec)
|
|
306
|
-
return undefined;
|
|
307
|
-
const out = {};
|
|
308
|
-
for (const [k, v] of Object.entries(rec))
|
|
309
|
-
out[k] = expandVars(v);
|
|
310
|
-
return out;
|
|
311
|
-
}
|
|
312
|
-
// Resolve cwd relative to plugin root, defaulting to plugin root if not specified
|
|
313
|
-
// If path escape is detected, REJECT this entry (throw) - do not fallback
|
|
314
|
-
let resolvedCwd = pluginDir;
|
|
315
|
-
if (cfg.cwd) {
|
|
316
|
-
try {
|
|
317
|
-
resolvedCwd = resolvePluginPath(pluginDir, cfg.cwd);
|
|
318
|
-
}
|
|
319
|
-
catch (err) {
|
|
320
|
-
// Re-throw to reject this MCP entry
|
|
321
|
-
throw new Error(`Invalid cwd "${cfg.cwd}": ${err instanceof Error ? err.message : String(err)}`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
// Resolve command relative to plugin root for stdio
|
|
325
|
-
// If path escape is detected, REJECT this entry (throw) - do not fallback
|
|
326
|
-
let resolvedCommand;
|
|
327
|
-
if (cfg.type === "stdio" && cfg.command) {
|
|
328
|
-
try {
|
|
329
|
-
resolvedCommand = resolvePluginPath(pluginDir, cfg.command);
|
|
330
|
-
}
|
|
331
|
-
catch (err) {
|
|
332
|
-
// Re-throw to reject this MCP entry
|
|
333
|
-
throw new Error(`Invalid command "${cfg.command}": ${err instanceof Error ? err.message : String(err)}`);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
// Inject PLUGIN_ROOT and PLUGIN_DATA for stdio plugins
|
|
337
|
-
const env = expandRecord(cfg.env) ?? {};
|
|
338
|
-
env.PLUGIN_ROOT = pluginDir;
|
|
339
|
-
env.PLUGIN_DATA = path.join(pluginDir, ".kritya", "plugin-data");
|
|
340
|
-
return {
|
|
341
|
-
type: cfg.type,
|
|
342
|
-
command: resolvedCommand ?? cfg.command,
|
|
343
|
-
args: cfg.args?.map(expandVars),
|
|
344
|
-
env,
|
|
345
|
-
cwd: resolvedCwd,
|
|
346
|
-
url: cfg.url, // Per spec, ${VAR} expansion is NOT allowed for url
|
|
347
|
-
headers: expandRecord(cfg.headers),
|
|
348
|
-
tools: cfg.tools,
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
/**
|
|
352
|
-
* Load MCP servers from an Agent Plugins structure.
|
|
353
|
-
* Reads from plugin/mcp.json if it exists.
|
|
354
|
-
* Validates transport type and path containment.
|
|
355
|
-
* Rejects entries with path escapes instead of falling back.
|
|
356
|
-
*/
|
|
357
|
-
export function loadPluginMcpServers(plugin) {
|
|
358
|
-
if (!plugin.mcpConfigFile)
|
|
359
|
-
return undefined;
|
|
360
|
-
// Check path containment
|
|
361
|
-
if (!isPathContained(plugin.dir, plugin.mcpConfigFile)) {
|
|
362
|
-
console.warn(`mcp.json is outside plugin root: ${plugin.mcpConfigFile}`);
|
|
363
|
-
return undefined;
|
|
364
|
-
}
|
|
365
|
-
let parsed;
|
|
366
|
-
try {
|
|
367
|
-
parsed = JSON.parse(fs.readFileSync(plugin.mcpConfigFile, "utf8"));
|
|
368
|
-
}
|
|
369
|
-
catch (err) {
|
|
370
|
-
console.warn(`Failed to read MCP config from ${plugin.mcpConfigFile}: ${err}`);
|
|
371
|
-
return undefined;
|
|
372
|
-
}
|
|
373
|
-
// Per spec, mcpServers object is required
|
|
374
|
-
if (!parsed?.mcpServers || typeof parsed.mcpServers !== "object") {
|
|
375
|
-
return undefined;
|
|
376
|
-
}
|
|
377
|
-
const servers = {};
|
|
378
|
-
for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
|
|
379
|
-
if (!isMcpServerConfig(cfg)) {
|
|
380
|
-
console.warn(`Invalid MCP server config for "${name}": missing required fields`);
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
// Resolve relative paths and expand env vars
|
|
384
|
-
// expandServerConfig throws on path escape, which rejects this entry
|
|
385
|
-
try {
|
|
386
|
-
const expanded = expandServerConfig(cfg, plugin.dir);
|
|
387
|
-
servers[name] = expanded;
|
|
388
|
-
}
|
|
389
|
-
catch (err) {
|
|
390
|
-
console.warn(`Rejected MCP server "${name}" in plugin "${plugin.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
return Object.keys(servers).length ? servers : undefined;
|
|
395
|
-
}
|
|
396
|
-
/**
|
|
397
|
-
* Get plugin directory path for a given plugin ID.
|
|
398
|
-
* This allows looking up where a plugin is installed.
|
|
399
|
-
*/
|
|
400
|
-
export function getPluginDirById(workspace, pluginId, userRoot) {
|
|
401
|
-
const { loaded: plugins } = scanAllPlugins(workspace, userRoot);
|
|
402
|
-
for (const plugin of plugins) {
|
|
403
|
-
if (plugin.id === pluginId) {
|
|
404
|
-
return plugin.dir;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
return undefined;
|
|
408
|
-
}
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { scanAllPlugins, loadPluginMcpServers } from "../agent/plugins.js";
|
|
2
|
-
import { connectServer, mcpStatus, replaceStatus, disconnectServer, forgetStatus, isToolOf } from "../mcp/client.js";
|
|
3
|
-
/**
|
|
4
|
-
* The `/plugins` command family: list, show details, and manage plugins.
|
|
5
|
-
*
|
|
6
|
-
* Plugins are Agent Plugins packages that bundle skills and MCP servers
|
|
7
|
-
* into a portable format following the agent-plugins specification.
|
|
8
|
-
*
|
|
9
|
-
* @see https://agent-plugins.org/
|
|
10
|
-
*/
|
|
11
|
-
const USAGE = `Usage:
|
|
12
|
-
/plugins list all discovered plugins
|
|
13
|
-
/plugins <name> show details for a specific plugin
|
|
14
|
-
/plugins <name> connect connect plugins MCP servers to kritya
|
|
15
|
-
/plugins <name> disconnect disconnect plugins MCP servers from kritya`;
|
|
16
|
-
export async function runPluginsCommand(ctx) {
|
|
17
|
-
const parts = ctx.arg.trim().split(/\s+/).filter(Boolean);
|
|
18
|
-
const sub = parts[0]?.toLowerCase();
|
|
19
|
-
switch (sub) {
|
|
20
|
-
case undefined:
|
|
21
|
-
return listPlugins(ctx);
|
|
22
|
-
case "connect":
|
|
23
|
-
return connectPlugin(ctx, parts[1]);
|
|
24
|
-
case "disconnect":
|
|
25
|
-
return disconnectPlugin(ctx, parts[1]);
|
|
26
|
-
default:
|
|
27
|
-
// If it's not a subcommand, treat it as a plugin name to show details
|
|
28
|
-
return showPlugin(ctx, sub);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
function listPlugins(ctx) {
|
|
32
|
-
const { loaded: plugins } = scanAllPlugins(ctx.workspace);
|
|
33
|
-
if (!plugins.length) {
|
|
34
|
-
ctx.addItem({
|
|
35
|
-
kind: "info",
|
|
36
|
-
text: "No plugins found.\n\n" +
|
|
37
|
-
"Plugins are loaded from ~/.kritya/plugins (user-global) and .kritya/plugins (project).\n" +
|
|
38
|
-
"Create a plugin with a plugin.json manifest, skills/ directory, and optional mcp.json.",
|
|
39
|
-
});
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
const lines = plugins.map((p) => {
|
|
43
|
-
const versionInfo = p.version ? ` v${p.version}` : "";
|
|
44
|
-
const desc = p.description ? ` - ${p.description}` : "";
|
|
45
|
-
return ` ${p.name}${versionInfo}${desc} (${p.id})`;
|
|
46
|
-
});
|
|
47
|
-
ctx.addItem({
|
|
48
|
-
kind: "info",
|
|
49
|
-
text: `Plugins:\n${lines.join("\n")}\n\n${USAGE}`,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
async function showPlugin(ctx, pluginName) {
|
|
53
|
-
const { loaded: plugins } = scanAllPlugins(ctx.workspace);
|
|
54
|
-
const plugin = plugins.find((p) => p.name === pluginName || p.id === pluginName);
|
|
55
|
-
if (!plugin) {
|
|
56
|
-
ctx.addItem({
|
|
57
|
-
kind: "info",
|
|
58
|
-
text: `No plugin named "${pluginName}". Run /plugins to see available plugins.`,
|
|
59
|
-
});
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
// Report skills - note that they are loaded from plugin.skillsDir
|
|
63
|
-
const skillsText = plugin.skillsDir
|
|
64
|
-
? `Skills: Available from ${plugin.skillsDir}\n(Use /skills to list discovered skills including plugin skills)`
|
|
65
|
-
: "No skills directory";
|
|
66
|
-
// Load MCP servers from this plugin
|
|
67
|
-
const mcpServers = loadPluginMcpServers(plugin);
|
|
68
|
-
const mcpText = mcpServers
|
|
69
|
-
? `MCP Servers (${Object.keys(mcpServers).length}):\n${Object.entries(mcpServers).map(([name, cfg]) => {
|
|
70
|
-
const target = typeof cfg.command === "string" ? `command: ${cfg.command}` : `url: ${cfg.url}`;
|
|
71
|
-
return ` - ${name}: ${target}`;
|
|
72
|
-
}).join("\n")}`
|
|
73
|
-
: "No MCP servers";
|
|
74
|
-
// Check if MCP servers are currently connected
|
|
75
|
-
const status = mcpStatus();
|
|
76
|
-
const connectedServers = status
|
|
77
|
-
.filter((s) => s.ok && Object.keys(mcpServers || {}).includes(s.name))
|
|
78
|
-
.map((s) => s.name);
|
|
79
|
-
const connectionState = connectedServers.length
|
|
80
|
-
? `Connected servers: ${connectedServers.join(", ")}`
|
|
81
|
-
: "MCP servers not connected";
|
|
82
|
-
ctx.addItem({
|
|
83
|
-
kind: "info",
|
|
84
|
-
text: `Plugin: ${plugin.name} ${plugin.version ? `v${plugin.version}` : ""} (${plugin.id})
|
|
85
|
-
${plugin.description ? `Description: ${plugin.description}` : ""}
|
|
86
|
-
|
|
87
|
-
${skillsText}
|
|
88
|
-
|
|
89
|
-
${mcpText}
|
|
90
|
-
|
|
91
|
-
${connectionState}
|
|
92
|
-
|
|
93
|
-
${USAGE}`,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
async function connectPlugin(ctx, pluginName) {
|
|
97
|
-
if (!pluginName) {
|
|
98
|
-
ctx.addItem({ kind: "info", text: `Usage: /plugins <name> connect` });
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const { loaded: plugins } = scanAllPlugins(ctx.workspace);
|
|
102
|
-
const plugin = plugins.find((p) => p.name === pluginName || p.id === pluginName);
|
|
103
|
-
if (!plugin) {
|
|
104
|
-
ctx.addItem({
|
|
105
|
-
kind: "info",
|
|
106
|
-
text: `No plugin named "${pluginName}". Run /plugins to see available plugins.`,
|
|
107
|
-
});
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
// Load MCP servers from this plugin
|
|
111
|
-
const mcpServers = loadPluginMcpServers(plugin);
|
|
112
|
-
if (!mcpServers || !Object.keys(mcpServers).length) {
|
|
113
|
-
ctx.addItem({
|
|
114
|
-
kind: "info",
|
|
115
|
-
text: `Plugin "${pluginName}" has no MCP servers to connect.`,
|
|
116
|
-
});
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
// Connect each server with plugin-specific naming
|
|
120
|
-
const connected = [];
|
|
121
|
-
const failed = [];
|
|
122
|
-
for (const [name, cfg] of Object.entries(mcpServers)) {
|
|
123
|
-
// Namespace server name with plugin ID to avoid conflicts
|
|
124
|
-
const pluginNamespace = plugin.id.replace(/[^a-z0-9_-]/g, "_");
|
|
125
|
-
const serverName = `${pluginNamespace}:${name}`;
|
|
126
|
-
try {
|
|
127
|
-
const { tools, status } = await connectServer(serverName, cfg, {
|
|
128
|
-
tracer: ctx.agent.tracer,
|
|
129
|
-
audit: ctx.agent.audit,
|
|
130
|
-
workspace: ctx.workspace,
|
|
131
|
-
});
|
|
132
|
-
replaceStatus(status);
|
|
133
|
-
if (status.ok) {
|
|
134
|
-
ctx.agent.addTools(tools);
|
|
135
|
-
connected.push(serverName);
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
failed.push({ name: serverName, error: status.error || "unknown error" });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
catch (err) {
|
|
142
|
-
failed.push({ name: serverName, error: err instanceof Error ? err.message : String(err) });
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
let response = `Plugin "${pluginName}":\n`;
|
|
146
|
-
if (connected.length) {
|
|
147
|
-
response += `Connected ${connected.length} server(s): ${connected.join(", ")}\n`;
|
|
148
|
-
}
|
|
149
|
-
if (failed.length) {
|
|
150
|
-
response += `Failed to connect ${failed.length} server(s):\n`;
|
|
151
|
-
response += failed.map((f) => ` - ${f.name}: ${f.error}`).join("\n");
|
|
152
|
-
}
|
|
153
|
-
ctx.addItem({ kind: "info", text: response });
|
|
154
|
-
}
|
|
155
|
-
async function disconnectPlugin(ctx, pluginName) {
|
|
156
|
-
if (!pluginName) {
|
|
157
|
-
ctx.addItem({ kind: "info", text: `Usage: /plugins <name> disconnect` });
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
const { loaded: plugins } = scanAllPlugins(ctx.workspace);
|
|
161
|
-
const plugin = plugins.find((p) => p.name === pluginName || p.id === pluginName);
|
|
162
|
-
if (!plugin) {
|
|
163
|
-
ctx.addItem({
|
|
164
|
-
kind: "info",
|
|
165
|
-
text: `No plugin named "${pluginName}". Run /plugins to see available plugins.`,
|
|
166
|
-
});
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
// Load MCP servers from this plugin
|
|
170
|
-
const mcpServers = loadPluginMcpServers(plugin);
|
|
171
|
-
if (!mcpServers || !Object.keys(mcpServers).length) {
|
|
172
|
-
ctx.addItem({
|
|
173
|
-
kind: "info",
|
|
174
|
-
text: `Plugin "${pluginName}" has no MCP servers to disconnect.`,
|
|
175
|
-
});
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
// Disconnect each server
|
|
179
|
-
const disconnected = [];
|
|
180
|
-
let removedTools = 0;
|
|
181
|
-
for (const [name] of Object.entries(mcpServers)) {
|
|
182
|
-
// Use the same namespace as connect
|
|
183
|
-
const pluginNamespace = plugin.id.replace(/[^a-z0-9_-]/g, "_");
|
|
184
|
-
const serverName = `${pluginNamespace}:${name}`;
|
|
185
|
-
disconnected.push(serverName);
|
|
186
|
-
// Get tools to remove
|
|
187
|
-
const status = mcpStatus().find((s) => s.name === serverName && s.ok);
|
|
188
|
-
if (status) {
|
|
189
|
-
removedTools += status.tools.length;
|
|
190
|
-
}
|
|
191
|
-
// Actually disconnect the server
|
|
192
|
-
disconnectServer(serverName);
|
|
193
|
-
// Remove tools from agent
|
|
194
|
-
ctx.agent.removeTools(isToolOf(serverName));
|
|
195
|
-
// Forget status so it doesn't remain visible
|
|
196
|
-
forgetStatus(serverName);
|
|
197
|
-
}
|
|
198
|
-
ctx.addItem({
|
|
199
|
-
kind: "info",
|
|
200
|
-
text: `Plugin "${pluginName}":\n` +
|
|
201
|
-
`Disconnected ${disconnected.length} server(s): ${disconnected.join(", ")}\n` +
|
|
202
|
-
`Removed ${removedTools} tool(s).`,
|
|
203
|
-
});
|
|
204
|
-
}
|