feat(sf): Tier 4 — ASK_USER_ELICITATION, CONFIGURE_COPILOT_AGENT, BACKGROUND_SESSIONS, MULTI_TURN_AGENTS, marketplace Enter install
- ask_user_elicitation tool: structured select/input form when flag is on - spawn_agent tool: persistent named sub-agent via file-backed .sf/agents/<name>/history.jsonl - /configure-agent command: list/add/remove MCP servers in .mcp.json (CONFIGURE_COPILOT_AGENT flag) - Ctrl+Alt+B: opens bg session switcher overlay from .sf/sessions-queue.json (BACKGROUND_SESSIONS flag) - openBgSessionSwitcher(): TUI ctx.ui.select picker for session switching - marketplace.js: Enter key triggers installExtensionNpm (EXTENSIONS flag); footer hint updated - Fix require() → ESM-safe imports in sf-tui/index.js (spawn, execSync, platform from static imports) - catalog.js: /configure-agent entry added Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3017663a69
commit
9450b4a11d
4 changed files with 444 additions and 13 deletions
|
|
@ -7,7 +7,10 @@
|
|||
* - Prompt history: Ctrl+Alt+H overlay
|
||||
* - Mode cycling: Ctrl+Shift+M, Ctrl+Shift+R, Ctrl+Shift+A, Ctrl+Shift+S
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { platform } from "node:os";
|
||||
import { Key } from "@singularity-forge/pi-tui";
|
||||
import { getAutoSession } from "../sf/auto/session.js";
|
||||
import { isAutoActive } from "../sf/auto.js";
|
||||
|
|
@ -185,7 +188,6 @@ export default function sfTui(pi) {
|
|||
);
|
||||
return;
|
||||
}
|
||||
const { spawn } = require("node:child_process");
|
||||
spawn(editor, [projectRoot() ?? "."], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
|
|
@ -202,19 +204,21 @@ export default function sfTui(pi) {
|
|||
ctx.ui.notify(`Reasoning display ${current ? "OFF" : "ON"}`, "info");
|
||||
},
|
||||
});
|
||||
// Ctrl+X B — open background tasks surface (/tasks)
|
||||
// Ctrl+X B — open background session switcher (BACKGROUND_SESSIONS flag)
|
||||
pi.registerShortcut(Key.ctrlAlt("b"), {
|
||||
description: "Open background tasks surface",
|
||||
handler: () => {
|
||||
ctx.sendMessage?.("/tasks");
|
||||
description: "Open background session switcher",
|
||||
handler: async () => {
|
||||
if (!getExperimentalFlag("background_sessions")) {
|
||||
ctx.sendMessage?.("/tasks");
|
||||
return;
|
||||
}
|
||||
await openBgSessionSwitcher(ctx);
|
||||
},
|
||||
});
|
||||
// Ctrl+X O — open URL from last agent message
|
||||
pi.registerShortcut(Key.ctrlAlt("o"), {
|
||||
description: "Open last URL from agent output in browser",
|
||||
handler: () => {
|
||||
const { execSync } = require("node:child_process");
|
||||
const os = require("node:os");
|
||||
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
||||
const lastText =
|
||||
[...entries].reverse().find((e) => e.type === "assistant")?.content ??
|
||||
|
|
@ -227,9 +231,9 @@ export default function sfTui(pi) {
|
|||
const url = urlMatch[0];
|
||||
try {
|
||||
const openCmd =
|
||||
os.platform() === "darwin"
|
||||
platform() === "darwin"
|
||||
? "open"
|
||||
: os.platform() === "win32"
|
||||
: platform() === "win32"
|
||||
? "start"
|
||||
: "xdg-open";
|
||||
execSync(`${openCmd} "${url}"`, { stdio: "ignore" });
|
||||
|
|
@ -325,6 +329,173 @@ export default function sfTui(pi) {
|
|||
},
|
||||
renderResult: ({ output }) => output,
|
||||
});
|
||||
|
||||
// ASK_USER_ELICITATION — structured form-based ask_user replacement.
|
||||
// When the flag is on and the agent calls this tool with choices, a TUI
|
||||
// select overlay is shown instead of a plain text prompt.
|
||||
pi.registerTool({
|
||||
name: "ask_user_elicitation",
|
||||
description:
|
||||
"Ask the user a question using a structured form with optional choices. When ASK_USER_ELICITATION is enabled this is preferred over plain ask_user for questions with known choices.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
question: {
|
||||
type: "string",
|
||||
description: "The question to ask the user",
|
||||
},
|
||||
choices: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional list of choices to show as a select menu",
|
||||
},
|
||||
allow_freeform: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Whether to allow freeform text input in addition to choices",
|
||||
},
|
||||
},
|
||||
required: ["question"],
|
||||
},
|
||||
execute: async ({ question, choices, allow_freeform }, ctx) => {
|
||||
if (!ctx?.hasUI) {
|
||||
return { output: "No UI available for elicitation." };
|
||||
}
|
||||
if (!getExperimentalFlag("ask_elicitation")) {
|
||||
return {
|
||||
output:
|
||||
"ASK_USER_ELICITATION is not enabled. Run /experimental on ask_elicitation to enable.",
|
||||
};
|
||||
}
|
||||
if (choices?.length) {
|
||||
const answer = await ctx.ui.select(question, choices);
|
||||
if (!answer && allow_freeform) {
|
||||
const freeform = await ctx.ui.input(question);
|
||||
return { output: freeform ?? "" };
|
||||
}
|
||||
return { output: answer ?? "" };
|
||||
}
|
||||
const answer = await ctx.ui.input(question);
|
||||
return { output: answer ?? "" };
|
||||
},
|
||||
renderResult: ({ output }) => (output ? `**Answer:** ${output}` : ""),
|
||||
});
|
||||
|
||||
// MULTI_TURN_AGENTS — persistent named sub-agent sessions via file-backed state.
|
||||
// Tool that spawns or resumes a named SF child process, relaying messages.
|
||||
pi.registerTool({
|
||||
name: "spawn_agent",
|
||||
description:
|
||||
"Spawn or resume a named persistent sub-agent. Sends a message and waits for the response. The agent persists across calls using file-backed state in .sf/agents/<name>/.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description:
|
||||
"Unique agent name (alphanumeric + hyphens, e.g. 'researcher')",
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Message to send to the agent",
|
||||
},
|
||||
reset: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If true, clear the agent's state and start fresh (default: false)",
|
||||
},
|
||||
},
|
||||
required: ["name", "message"],
|
||||
},
|
||||
execute: async ({ name, message, reset }) => {
|
||||
if (!getExperimentalFlag("multi_turn_agents")) {
|
||||
return {
|
||||
output:
|
||||
"MULTI_TURN_AGENTS is not enabled. Run /experimental on multi_turn_agents to enable.",
|
||||
};
|
||||
}
|
||||
if (!/^[a-z0-9-]{1,32}$/i.test(name)) {
|
||||
return {
|
||||
output: "Agent name must be 1-32 alphanumeric/hyphen characters.",
|
||||
};
|
||||
}
|
||||
const { join: pathJoin } = await import("node:path");
|
||||
const { mkdirSync, writeFileSync, readFileSync, existsSync } =
|
||||
await import("node:fs");
|
||||
const stateDir = pathJoin(
|
||||
projectRoot() ?? process.cwd(),
|
||||
".sf",
|
||||
"agents",
|
||||
name,
|
||||
);
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const historyPath = pathJoin(stateDir, "history.jsonl");
|
||||
if (reset && existsSync(historyPath)) {
|
||||
writeFileSync(historyPath, "", "utf-8");
|
||||
}
|
||||
// Append user message to history
|
||||
const entry = JSON.stringify({
|
||||
role: "user",
|
||||
content: message,
|
||||
ts: Date.now(),
|
||||
});
|
||||
const { appendFileSync } = await import("node:fs");
|
||||
appendFileSync(historyPath, `${entry}\n`, "utf-8");
|
||||
// Dispatch to SF headless with the conversation history as context
|
||||
const historyLines = existsSync(historyPath)
|
||||
? readFileSync(historyPath, "utf-8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
try {
|
||||
return JSON.parse(l);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const contextMsg = historyLines
|
||||
.slice(-10) // last 10 turns for context
|
||||
.map((e) => `${e.role === "user" ? "User" : "Agent"}: ${e.content}`)
|
||||
.join("\n");
|
||||
const fullPrompt = `[Agent: ${name}]\n\nConversation history:\n${contextMsg}\n\nRespond to the last user message only.`;
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"-y",
|
||||
"node@24",
|
||||
process.env.SF_LOADER ?? "dist/loader.js",
|
||||
"headless",
|
||||
"--print",
|
||||
fullPrompt,
|
||||
],
|
||||
{
|
||||
timeout: 60000,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env },
|
||||
},
|
||||
);
|
||||
const response = stdout.trim();
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`${JSON.stringify({ role: "assistant", content: response, ts: Date.now() })}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return { output: response };
|
||||
} catch (err) {
|
||||
return {
|
||||
output: `Agent dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
renderResult: ({ output }) => output,
|
||||
});
|
||||
}
|
||||
|
||||
/** Run the STATUS_LINE user script on a 5s interval, posting stdout to footer. */
|
||||
|
|
@ -357,3 +528,54 @@ function startStatusLineRunner(ctx) {
|
|||
_statusLineInterval = setInterval(tick, 5000);
|
||||
tick();
|
||||
}
|
||||
|
||||
// ─── Background Session Switcher ─────────────────────────────────────────────
|
||||
|
||||
const BG_SESSIONS_FILE = ".sf/sessions-queue.json";
|
||||
|
||||
/**
|
||||
* Open a TUI overlay listing background sessions from .sf/sessions-queue.json.
|
||||
* Selecting one sends /resume <sessionId> to the chat.
|
||||
*
|
||||
* Purpose: BACKGROUND_SESSIONS parity — let users switch between concurrent
|
||||
* sessions without leaving the TUI.
|
||||
* Consumer: Ctrl+Alt+B keybinding (session_start hook).
|
||||
*/
|
||||
async function openBgSessionSwitcher(ctx) {
|
||||
if (!ctx?.hasUI) return;
|
||||
const { existsSync, readFileSync } = await import("node:fs");
|
||||
const { join: pathJoin } = await import("node:path");
|
||||
let root;
|
||||
try {
|
||||
root = projectRoot();
|
||||
} catch {
|
||||
root = process.cwd();
|
||||
}
|
||||
const queuePath = pathJoin(root ?? process.cwd(), BG_SESSIONS_FILE);
|
||||
let sessions = [];
|
||||
if (existsSync(queuePath)) {
|
||||
try {
|
||||
sessions = JSON.parse(readFileSync(queuePath, "utf-8"));
|
||||
} catch {
|
||||
/* malformed */
|
||||
}
|
||||
}
|
||||
if (!sessions.length) {
|
||||
ctx.ui.notify(
|
||||
"No background sessions queued.\nStart one with /resume <description> or via BACKGROUND_SESSIONS controls.",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const labels = sessions.map(
|
||||
(s, i) =>
|
||||
`${(i + 1).toString().padStart(2)}. ${s.id ?? "unknown"} — ${s.summary ?? "no summary"}`,
|
||||
);
|
||||
const chosen = await ctx.ui.select("Switch to session:", labels);
|
||||
if (!chosen) return;
|
||||
const idx = labels.indexOf(chosen);
|
||||
const session = sessions[idx];
|
||||
if (session?.id) {
|
||||
ctx.sendMessage?.(`/resume ${session.id}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@singularity-forge/pi-tui";
|
||||
import { getExperimentalFlag } from "../sf/experimental.js";
|
||||
|
||||
const CATEGORIES = ["all", "extension", "skill", "theme"];
|
||||
const FEATURED = [
|
||||
|
|
@ -167,10 +168,17 @@ class MarketplaceOverlay {
|
|||
if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) {
|
||||
const item = this.filtered[this.sel];
|
||||
if (item) {
|
||||
// In a full implementation this would trigger install/uninstall
|
||||
// For now we just show info and close
|
||||
if (item.source === "installed") {
|
||||
// Already installed — close
|
||||
this.onClose();
|
||||
} else {
|
||||
// Trigger install via npm
|
||||
this.onClose();
|
||||
installExtensionNpm(item.id, item.name);
|
||||
}
|
||||
} else {
|
||||
this.onClose();
|
||||
}
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
invalidate() {
|
||||
|
|
@ -203,7 +211,7 @@ class MarketplaceOverlay {
|
|||
lines.push(
|
||||
pad(
|
||||
box(
|
||||
`${th.fg("dim", "filter:")} ${filterLabel} ${th.fg("dim", "↑/jk navigate • f filter • Esc close")}`,
|
||||
`${th.fg("dim", "filter:")} ${filterLabel} ${th.fg("dim", "↑/jk navigate • f filter • Enter install • Esc close")}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -295,3 +303,44 @@ export async function openMarketplaceOverlay(ctx) {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a marketplace extension via npm into ~/.sf/agent/extensions/.
|
||||
*
|
||||
* Purpose: wire the EXTENSIONS experimental flag — press Enter on a featured
|
||||
* package to trigger `npm install` into the user extensions directory.
|
||||
* Consumer: MarketplaceOverlay.handleInput on Enter for non-installed items.
|
||||
*/
|
||||
export function installExtensionNpm(packageId, displayName) {
|
||||
if (!getExperimentalFlag("extensions")) {
|
||||
// Silently skip if EXTENSIONS flag is off; overlay already closed
|
||||
return;
|
||||
}
|
||||
import("node:child_process").then(({ spawn }) => {
|
||||
const target = join(homedir(), ".sf", "agent", "extensions");
|
||||
const proc = spawn("npm", ["install", "--prefix", target, packageId], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
let stderr = "";
|
||||
proc.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
process.stdout.write(
|
||||
`\r\n\x1b[32m✓ Installed ${displayName || packageId}\x1b[0m\r\n`,
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`\r\n\x1b[31m✗ Install failed for ${displayName || packageId}: ${stderr.slice(0, 200)}\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
proc.on("error", () => {
|
||||
process.stdout.write(
|
||||
`\r\n\x1b[33m⚠ npm not found — install npm to enable extension installs\x1b[0m\r\n`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,6 +305,10 @@ export const TOP_LEVEL_SUBCOMMANDS = [
|
|||
cmd: "resume",
|
||||
desc: "List or switch to another session (BACKGROUND_SESSIONS flag for live switching)",
|
||||
},
|
||||
{
|
||||
cmd: "configure-agent",
|
||||
desc: "Manage MCP servers and agent settings (CONFIGURE_COPILOT_AGENT flag)",
|
||||
},
|
||||
];
|
||||
|
||||
export const DIRECT_SF_COMMANDS = TOP_LEVEL_SUBCOMMANDS.filter(
|
||||
|
|
|
|||
|
|
@ -521,6 +521,13 @@ Examples:
|
|||
await handleShareCommand(trimmed.replace(/^share\s*/, "").trim(), ctx);
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "configure-agent" || trimmed.startsWith("configure-agent ")) {
|
||||
await handleConfigureAgentCommand(
|
||||
trimmed.replace(/^configure-agent\s*/, "").trim(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -718,3 +725,152 @@ async function handleShareCommand(format, ctx) {
|
|||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /configure-agent ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interactive wizard for MCP servers and agent settings.
|
||||
*
|
||||
* Purpose: CONFIGURE_COPILOT_AGENT parity — let the user view and toggle
|
||||
* MCP server entries without leaving SF.
|
||||
* Consumer: /configure-agent command dispatch.
|
||||
*/
|
||||
async function handleConfigureAgentCommand(args, ctx) {
|
||||
if (!getExperimentalFlag("configure_agent")) {
|
||||
ctx.ui.notify(
|
||||
"CONFIGURE_COPILOT_AGENT is not enabled.\nRun /experimental on configure_agent to enable.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join: pathJoin } = await import("node:path");
|
||||
|
||||
const root = projectRoot();
|
||||
const mcpPath = pathJoin(root, ".mcp.json");
|
||||
const sfMcpPath = pathJoin(root, ".sf", "mcp.json");
|
||||
|
||||
// Sub-command routing
|
||||
if (args === "mcp" || args === "servers" || !args) {
|
||||
// Show interactive MCP server list
|
||||
let config = {};
|
||||
const activePath = existsSync(mcpPath)
|
||||
? mcpPath
|
||||
: existsSync(sfMcpPath)
|
||||
? sfMcpPath
|
||||
: null;
|
||||
if (activePath) {
|
||||
try {
|
||||
config = JSON.parse(readFileSync(activePath, "utf-8"));
|
||||
} catch {
|
||||
/* malformed */
|
||||
}
|
||||
}
|
||||
const servers = Object.entries(config.mcpServers ?? config.servers ?? {});
|
||||
if (!servers.length) {
|
||||
const lines = [
|
||||
"No MCP servers configured.",
|
||||
"",
|
||||
"Add a server:",
|
||||
" /configure-agent add-mcp <name> <command> [args...]",
|
||||
"",
|
||||
"Example:",
|
||||
" /configure-agent add-mcp filesystem npx @modelcontextprotocol/server-filesystem /tmp",
|
||||
"",
|
||||
"Config file: .mcp.json (or .sf/mcp.json)",
|
||||
];
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
return;
|
||||
}
|
||||
const lines = [`MCP Servers (${servers.length}) — config: ${activePath}\n`];
|
||||
for (const [name, cfg] of servers) {
|
||||
const endpoint = cfg.command ?? cfg.url ?? "(unknown)";
|
||||
lines.push(` ● ${name.padEnd(18)} ${endpoint}`);
|
||||
}
|
||||
lines.push("\n/configure-agent add-mcp <name> <cmd> — add a server");
|
||||
lines.push("/configure-agent remove-mcp <name> — remove a server");
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// add-mcp <name> <command> [args...]
|
||||
if (args.startsWith("add-mcp ")) {
|
||||
const parts = args.slice(8).trim().split(/\s+/);
|
||||
const [name, command, ...cmdArgs] = parts;
|
||||
if (!name || !command) {
|
||||
ctx.ui.notify(
|
||||
"Usage: /configure-agent add-mcp <name> <command> [args...]\nExample: /configure-agent add-mcp fs npx @modelcontextprotocol/server-filesystem /tmp",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const targetPath = existsSync(mcpPath) ? mcpPath : mcpPath;
|
||||
let config = {};
|
||||
if (existsSync(targetPath)) {
|
||||
try {
|
||||
config = JSON.parse(readFileSync(targetPath, "utf-8"));
|
||||
} catch {
|
||||
/* malformed */
|
||||
}
|
||||
}
|
||||
config.mcpServers = config.mcpServers ?? {};
|
||||
config.mcpServers[name] = {
|
||||
command,
|
||||
...(cmdArgs.length ? { args: cmdArgs } : {}),
|
||||
};
|
||||
mkdirSync(projectRoot(), { recursive: true });
|
||||
writeFileSync(targetPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
ctx.ui.notify(
|
||||
`MCP server "${name}" added to ${targetPath}.\nRestart SF to load the new server.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove-mcp <name>
|
||||
if (args.startsWith("remove-mcp ")) {
|
||||
const name = args.slice(11).trim();
|
||||
const activePath = existsSync(mcpPath)
|
||||
? mcpPath
|
||||
: existsSync(sfMcpPath)
|
||||
? sfMcpPath
|
||||
: null;
|
||||
if (!activePath || !existsSync(activePath)) {
|
||||
ctx.ui.notify("No MCP config file found.", "warning");
|
||||
return;
|
||||
}
|
||||
let config = {};
|
||||
try {
|
||||
config = JSON.parse(readFileSync(activePath, "utf-8"));
|
||||
} catch {
|
||||
ctx.ui.notify("Could not parse MCP config.", "error");
|
||||
return;
|
||||
}
|
||||
if (config.mcpServers?.[name]) {
|
||||
delete config.mcpServers[name];
|
||||
writeFileSync(activePath, JSON.stringify(config, null, 2), "utf-8");
|
||||
ctx.ui.notify(`MCP server "${name}" removed.`, "info");
|
||||
} else if (config.servers?.[name]) {
|
||||
delete config.servers[name];
|
||||
writeFileSync(activePath, JSON.stringify(config, null, 2), "utf-8");
|
||||
ctx.ui.notify(`MCP server "${name}" removed.`, "info");
|
||||
} else {
|
||||
ctx.ui.notify(`No server named "${name}" found.`, "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(
|
||||
[
|
||||
"configure-agent subcommands:",
|
||||
" /configure-agent — List MCP servers",
|
||||
" /configure-agent mcp — Same as above",
|
||||
" /configure-agent add-mcp <name> <cmd> — Add MCP server",
|
||||
" /configure-agent remove-mcp <name> — Remove MCP server",
|
||||
].join("\n"),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue