Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e2756e87 | ||
|
|
dd99b9d67e | ||
|
|
f625303d13 | ||
|
|
42d039998d | ||
|
|
3497be2963 | ||
|
|
d62b7c0d1e | ||
|
|
2ad3508a33 |
No files matched your search
@@ -201,7 +201,7 @@ For ad-hoc workflows, call Lobster directly.
|
|||||||
|
|
||||||
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
|
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
|
||||||
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
|
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
|
||||||
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
|
- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended).
|
||||||
- If you pass `lobsterPath`, it must be an **absolute path**.
|
- If you pass `lobsterPath`, it must be an **absolute path**.
|
||||||
|
|
||||||
See [Lobster](/tools/lobster) for full usage and examples.
|
See [Lobster](/tools/lobster) for full usage and examples.
|
||||||
|
|||||||
+15
-3
@@ -158,7 +158,19 @@ If you want to use a custom binary location, pass an **absolute** `lobsterPath`
|
|||||||
|
|
||||||
## Enable the tool
|
## Enable the tool
|
||||||
|
|
||||||
Lobster is an **optional** plugin tool (not enabled by default). Allow it per agent:
|
Lobster is an **optional** plugin tool (not enabled by default).
|
||||||
|
|
||||||
|
Recommended (additive, safe):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"alsoAllow": ["lobster"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or per-agent:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -167,7 +179,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
|
|||||||
{
|
{
|
||||||
"id": "main",
|
"id": "main",
|
||||||
"tools": {
|
"tools": {
|
||||||
"allow": ["lobster"]
|
"alsoAllow": ["lobster"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -175,7 +187,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also allow it globally with `tools.allow` if every agent should see it.
|
Avoid using `tools.allow: ["lobster"]` unless you intend to run in restrictive allowlist mode.
|
||||||
|
|
||||||
Note: allowlists are opt-in for optional plugins. If your allowlist only names
|
Note: allowlists are opt-in for optional plugins. If your allowlist only names
|
||||||
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
|
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
|
||||||
|
|||||||
@@ -96,13 +96,28 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP
|
|||||||
|
|
||||||
type ToolPolicyConfig = {
|
type ToolPolicyConfig = {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
|
alsoAllow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
profile?: string;
|
profile?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function unionAllow(base?: string[], extra?: string[]) {
|
||||||
|
if (!Array.isArray(extra) || extra.length === 0) return base;
|
||||||
|
// If the user is using alsoAllow without an allowlist, treat it as additive on top of
|
||||||
|
// an implicit allow-all policy.
|
||||||
|
if (!Array.isArray(base) || base.length === 0) {
|
||||||
|
return Array.from(new Set(["*", ...extra]));
|
||||||
|
}
|
||||||
|
return Array.from(new Set([...base, ...extra]));
|
||||||
|
}
|
||||||
|
|
||||||
function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined {
|
function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined {
|
||||||
if (!config) return undefined;
|
if (!config) return undefined;
|
||||||
const allow = Array.isArray(config.allow) ? config.allow : undefined;
|
const allow = Array.isArray(config.allow)
|
||||||
|
? unionAllow(config.allow, config.alsoAllow)
|
||||||
|
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
|
||||||
|
? unionAllow(undefined, config.alsoAllow)
|
||||||
|
: undefined;
|
||||||
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
||||||
if (!allow && !deny) return undefined;
|
if (!allow && !deny) return undefined;
|
||||||
return { allow, deny };
|
return { allow, deny };
|
||||||
@@ -195,6 +210,17 @@ export function resolveEffectiveToolPolicy(params: {
|
|||||||
agentProviderPolicy: pickToolPolicy(agentProviderPolicy),
|
agentProviderPolicy: pickToolPolicy(agentProviderPolicy),
|
||||||
profile,
|
profile,
|
||||||
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
|
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
|
||||||
|
// alsoAllow is applied at the profile stage (to avoid being filtered out early).
|
||||||
|
profileAlsoAllow: Array.isArray(agentTools?.alsoAllow)
|
||||||
|
? agentTools?.alsoAllow
|
||||||
|
: Array.isArray(globalTools?.alsoAllow)
|
||||||
|
? globalTools?.alsoAllow
|
||||||
|
: undefined,
|
||||||
|
providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow)
|
||||||
|
? agentProviderPolicy?.alsoAllow
|
||||||
|
: Array.isArray(providerPolicy?.alsoAllow)
|
||||||
|
? providerPolicy?.alsoAllow
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -157,6 +157,8 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
agentProviderPolicy,
|
agentProviderPolicy,
|
||||||
profile,
|
profile,
|
||||||
providerProfile,
|
providerProfile,
|
||||||
|
profileAlsoAllow,
|
||||||
|
providerProfileAlsoAllow,
|
||||||
} = resolveEffectiveToolPolicy({
|
} = resolveEffectiveToolPolicy({
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
sessionKey: options?.sessionKey,
|
sessionKey: options?.sessionKey,
|
||||||
@@ -175,14 +177,25 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
});
|
});
|
||||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||||
|
|
||||||
|
const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
|
||||||
|
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
|
||||||
|
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
|
||||||
|
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
|
||||||
|
providerProfilePolicy,
|
||||||
|
providerProfileAlsoAllow,
|
||||||
|
);
|
||||||
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
|
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
|
||||||
const subagentPolicy =
|
const subagentPolicy =
|
||||||
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
|
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
|
||||||
? resolveSubagentToolPolicy(options.config)
|
? resolveSubagentToolPolicy(options.config)
|
||||||
: undefined;
|
: undefined;
|
||||||
const allowBackground = isToolAllowedByPolicies("process", [
|
const allowBackground = isToolAllowedByPolicies("process", [
|
||||||
profilePolicy,
|
profilePolicyWithAlsoAllow,
|
||||||
providerProfilePolicy,
|
providerProfilePolicyWithAlsoAllow,
|
||||||
globalPolicy,
|
globalPolicy,
|
||||||
globalProviderPolicy,
|
globalProviderPolicy,
|
||||||
agentPolicy,
|
agentPolicy,
|
||||||
@@ -333,18 +346,18 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
if (resolved.unknownAllowlist.length > 0) {
|
if (resolved.unknownAllowlist.length > 0) {
|
||||||
const entries = resolved.unknownAllowlist.join(", ");
|
const entries = resolved.unknownAllowlist.join(", ");
|
||||||
const suffix = resolved.strippedAllowlist
|
const suffix = resolved.strippedAllowlist
|
||||||
? "Ignoring allowlist so core tools remain available."
|
? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
|
||||||
: "These entries won't match any tool unless the plugin is enabled.";
|
: "These entries won't match any tool unless the plugin is enabled.";
|
||||||
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
|
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
|
||||||
}
|
}
|
||||||
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
|
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
|
||||||
};
|
};
|
||||||
const profilePolicyExpanded = resolvePolicy(
|
const profilePolicyExpanded = resolvePolicy(
|
||||||
profilePolicy,
|
profilePolicyWithAlsoAllow,
|
||||||
profile ? `tools.profile (${profile})` : "tools.profile",
|
profile ? `tools.profile (${profile})` : "tools.profile",
|
||||||
);
|
);
|
||||||
const providerProfileExpanded = resolvePolicy(
|
const providerProfileExpanded = resolvePolicy(
|
||||||
providerProfilePolicy,
|
providerProfilePolicyWithAlsoAllow,
|
||||||
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
|
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
|
||||||
);
|
);
|
||||||
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
|
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
|
||||||
|
|||||||
@@ -310,16 +310,6 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain("/status shows Reasoning");
|
expect(prompt).toContain("/status shows Reasoning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes /plan mode instructions", () => {
|
|
||||||
const prompt = buildAgentSystemPrompt({
|
|
||||||
workspaceDir: "/tmp/clawd",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prompt).toContain("## Plan Mode (/plan)");
|
|
||||||
expect(prompt).toContain("If the user message starts with /plan");
|
|
||||||
expect(prompt).toContain("plans/<timestamp>-<slug>/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds runtime line with agent and channel details", () => {
|
it("builds runtime line with agent and channel details", () => {
|
||||||
const line = buildRuntimeLine(
|
const line = buildRuntimeLine(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -558,27 +558,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan mode is conversational but stateful: compile a messy goal into a structured artifact.
|
|
||||||
if (!isMinimal) {
|
|
||||||
lines.push(
|
|
||||||
"## Plan Mode (/plan)",
|
|
||||||
"If the user message starts with /plan, enter *planning mode*.",
|
|
||||||
"- Treat everything after /plan as the goal.",
|
|
||||||
"- Ask targeted follow-up questions (one at a time) to remove ambiguity.",
|
|
||||||
"- Persist state in the workspace so planning can resume without losing context.",
|
|
||||||
"",
|
|
||||||
"Artifacts:",
|
|
||||||
"- Create a directory plans/<timestamp>-<slug>/ in the workspace.",
|
|
||||||
"- Maintain plans/<id>/answers.json (incremental) and plans/<id>/plan.md (human-readable).",
|
|
||||||
"- Optionally create plans/<id>/plan.json (structured) when helpful.",
|
|
||||||
"",
|
|
||||||
"Rules:",
|
|
||||||
"- Be token-efficient: do not restate the entire plan on every turn; ask the next question.",
|
|
||||||
"- When enough information is collected, produce a crisp plan with milestones + next actions.",
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
"## Runtime",
|
"## Runtime",
|
||||||
buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel),
|
buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel),
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ export function stripPluginOnlyAllowlist(
|
|||||||
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
|
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
|
||||||
}
|
}
|
||||||
const strippedAllowlist = !hasCoreEntry;
|
const strippedAllowlist = !hasCoreEntry;
|
||||||
|
// When an allowlist contains only plugin tools, we strip it to avoid accidentally
|
||||||
|
// disabling core tools. Users who want additive behavior should prefer `tools.alsoAllow`.
|
||||||
|
if (strippedAllowlist) {
|
||||||
|
// Note: logging happens in the caller (pi-tools/tools-invoke) after this function returns.
|
||||||
|
// We keep this note here for future maintainers.
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
|
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
|
||||||
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
|
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
|
||||||
|
|||||||
@@ -157,20 +157,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Show current status.",
|
description: "Show current status.",
|
||||||
textAlias: "/status",
|
textAlias: "/status",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
|
||||||
key: "plan",
|
|
||||||
nativeName: "plan",
|
|
||||||
description: "Interactive project planning (/plan) with saved artifacts.",
|
|
||||||
textAlias: "/plan",
|
|
||||||
acceptsArgs: true,
|
|
||||||
}),
|
|
||||||
defineChatCommand({
|
|
||||||
key: "plans",
|
|
||||||
nativeName: "plans",
|
|
||||||
description: "List/show/rename saved /plan artifacts.",
|
|
||||||
textAlias: "/plans",
|
|
||||||
acceptsArgs: true,
|
|
||||||
}),
|
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "allowlist",
|
key: "allowlist",
|
||||||
description: "List/add/remove allowlist entries.",
|
description: "List/add/remove allowlist entries.",
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import {
|
|||||||
handleUsageCommand,
|
handleUsageCommand,
|
||||||
} from "./commands-session.js";
|
} from "./commands-session.js";
|
||||||
import { handlePluginCommand } from "./commands-plugin.js";
|
import { handlePluginCommand } from "./commands-plugin.js";
|
||||||
import { handlePlansCommand } from "./commands-plans.js";
|
|
||||||
import { handlePlanCommand } from "./commands-plan.js";
|
|
||||||
import type {
|
import type {
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
CommandHandlerResult,
|
CommandHandlerResult,
|
||||||
@@ -45,8 +43,6 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleRestartCommand,
|
handleRestartCommand,
|
||||||
handleTtsCommands,
|
handleTtsCommands,
|
||||||
handleHelpCommand,
|
handleHelpCommand,
|
||||||
handlePlanCommand,
|
|
||||||
handlePlansCommand,
|
|
||||||
handleCommandsListCommand,
|
handleCommandsListCommand,
|
||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
handleAllowlistCommand,
|
handleAllowlistCommand,
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
|
||||||
|
|
||||||
const CANCEL = Symbol.for("clack:cancel");
|
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => {
|
|
||||||
const state = {
|
|
||||||
selectQueue: [] as any[],
|
|
||||||
textQueue: [] as any[],
|
|
||||||
confirmQueue: [] as any[],
|
|
||||||
multiselectQueue: [] as any[],
|
|
||||||
|
|
||||||
// Embedded runner control
|
|
||||||
baseQuestions: [
|
|
||||||
{
|
|
||||||
id: "budget",
|
|
||||||
section: "Constraints",
|
|
||||||
prompt: "Budget?",
|
|
||||||
kind: "select",
|
|
||||||
required: true,
|
|
||||||
options: ["$", "$$"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "deadline",
|
|
||||||
section: "Timeline",
|
|
||||||
prompt: "Deadline?",
|
|
||||||
kind: "text",
|
|
||||||
},
|
|
||||||
] as any[],
|
|
||||||
extraQuestions: [
|
|
||||||
{
|
|
||||||
id: "transport",
|
|
||||||
section: "Constraints",
|
|
||||||
prompt: "Preferred transport?",
|
|
||||||
kind: "multiselect",
|
|
||||||
required: true,
|
|
||||||
options: ["Car", "Plane"],
|
|
||||||
},
|
|
||||||
] as any[],
|
|
||||||
};
|
|
||||||
|
|
||||||
function resetQueues() {
|
|
||||||
state.selectQueue = [];
|
|
||||||
state.textQueue = [];
|
|
||||||
state.confirmQueue = [];
|
|
||||||
state.multiselectQueue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const clack = {
|
|
||||||
select: vi.fn(async ({ options, message }: any) => {
|
|
||||||
const queued = state.selectQueue.shift();
|
|
||||||
if (queued !== undefined) return queued;
|
|
||||||
|
|
||||||
// Default behavior: choose first section once, then review.
|
|
||||||
if (String(message).includes("Choose a section")) {
|
|
||||||
// If we already chose a section once, go to review.
|
|
||||||
const already = (clack.select as any)._chosenOnce === true;
|
|
||||||
(clack.select as any)._chosenOnce = true;
|
|
||||||
if (already) return "__review";
|
|
||||||
return options[0].value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return options[0].value;
|
|
||||||
}),
|
|
||||||
text: vi.fn(async ({ initialValue }: any) => {
|
|
||||||
const queued = state.textQueue.shift();
|
|
||||||
if (queued !== undefined) return queued;
|
|
||||||
return initialValue ?? "";
|
|
||||||
}),
|
|
||||||
confirm: vi.fn(async () => {
|
|
||||||
const queued = state.confirmQueue.shift();
|
|
||||||
if (queued !== undefined) return queued;
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
multiselect: vi.fn(async ({ options }: any) => {
|
|
||||||
const queued = state.multiselectQueue.shift();
|
|
||||||
if (queued !== undefined) return queued;
|
|
||||||
return options.map((o: any) => o.value);
|
|
||||||
}),
|
|
||||||
isCancel: vi.fn((v: any) => v === CANCEL),
|
|
||||||
};
|
|
||||||
|
|
||||||
const embedded = {
|
|
||||||
runEmbeddedPiAgent: vi.fn(async ({ prompt }: any) => {
|
|
||||||
if (String(prompt).includes("Generate a compact questionnaire")) {
|
|
||||||
return {
|
|
||||||
payloads: [
|
|
||||||
{
|
|
||||||
text: JSON.stringify({
|
|
||||||
goal: "demo",
|
|
||||||
questions: state.baseQuestions,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(prompt).includes("propose any missing high-signal questions")) {
|
|
||||||
return {
|
|
||||||
payloads: [
|
|
||||||
{
|
|
||||||
text: JSON.stringify({
|
|
||||||
goal: "demo",
|
|
||||||
questions: state.extraQuestions,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return { state, resetQueues, clack, embedded };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock clack prompts to simulate TUI interaction.
|
|
||||||
vi.mock("@clack/prompts", async () => {
|
|
||||||
return {
|
|
||||||
confirm: hoisted.clack.confirm,
|
|
||||||
isCancel: hoisted.clack.isCancel,
|
|
||||||
select: hoisted.clack.select,
|
|
||||||
multiselect: hoisted.clack.multiselect,
|
|
||||||
text: hoisted.clack.text,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// llm-task extension dynamically imports embedded runner in src-first/dist-fallback form.
|
|
||||||
vi.mock("../../../src/agents/pi-embedded-runner.js", () => ({
|
|
||||||
runEmbeddedPiAgent: hoisted.embedded.runEmbeddedPiAgent,
|
|
||||||
}));
|
|
||||||
vi.mock("../../../agents/pi-embedded-runner.js", () => ({
|
|
||||||
runEmbeddedPiAgent: hoisted.embedded.runEmbeddedPiAgent,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let testWorkspaceDir = os.tmpdir();
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
testWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plan-"));
|
|
||||||
await fs.writeFile(path.join(testWorkspaceDir, "AGENTS.md"), "# Agents\n", "utf-8");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(testWorkspaceDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
hoisted.resetQueues();
|
|
||||||
(hoisted.clack.select as any)._chosenOnce = false;
|
|
||||||
// defaults
|
|
||||||
hoisted.state.baseQuestions = [
|
|
||||||
{
|
|
||||||
id: "budget",
|
|
||||||
section: "Constraints",
|
|
||||||
prompt: "Budget?",
|
|
||||||
kind: "select",
|
|
||||||
required: true,
|
|
||||||
options: ["$", "$$"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "deadline",
|
|
||||||
section: "Timeline",
|
|
||||||
prompt: "Deadline?",
|
|
||||||
kind: "text",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
hoisted.state.extraQuestions = [
|
|
||||||
{
|
|
||||||
id: "transport",
|
|
||||||
section: "Constraints",
|
|
||||||
prompt: "Preferred transport?",
|
|
||||||
kind: "multiselect",
|
|
||||||
required: true,
|
|
||||||
options: ["Car", "Plane"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
|
||||||
const ctx = {
|
|
||||||
Body: commandBody,
|
|
||||||
CommandBody: commandBody,
|
|
||||||
CommandSource: "cli",
|
|
||||||
CommandAuthorized: true,
|
|
||||||
Provider: "cli",
|
|
||||||
Surface: "cli",
|
|
||||||
...ctxOverrides,
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const command = buildCommandContext({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
isGroup: false,
|
|
||||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
command,
|
|
||||||
directives: parseInlineDirectives(commandBody),
|
|
||||||
elevated: { enabled: true, allowed: true, failures: [] },
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
workspaceDir: testWorkspaceDir,
|
|
||||||
defaultGroupActivation: () => "mention",
|
|
||||||
resolvedVerboseLevel: "off" as const,
|
|
||||||
resolvedReasoningLevel: "off" as const,
|
|
||||||
resolveDefaultThinkingLevel: async () => undefined,
|
|
||||||
provider: "cli",
|
|
||||||
model: "test-model",
|
|
||||||
contextTokens: 0,
|
|
||||||
isGroup: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLatestPlanDir() {
|
|
||||||
const plansDir = path.join(testWorkspaceDir, "plans");
|
|
||||||
const entries = await fs.readdir(plansDir, { withFileTypes: true });
|
|
||||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
||||||
expect(dirs.length).toBeGreaterThan(0);
|
|
||||||
// Sort for determinism (timestamp prefix in name)
|
|
||||||
dirs.sort();
|
|
||||||
return path.join(plansDir, dirs[dirs.length - 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("/plan TUI", () => {
|
|
||||||
it("creates a plan directory and writes plan.md + answers.json + questions.json", async () => {
|
|
||||||
(process.stdin as any).isTTY = true;
|
|
||||||
(process.stdout as any).isTTY = true;
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Plan saved");
|
|
||||||
|
|
||||||
const createdDir = await getLatestPlanDir();
|
|
||||||
|
|
||||||
const planMd = await fs.readFile(path.join(createdDir, "plan.md"), "utf-8");
|
|
||||||
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
|
|
||||||
const questions = JSON.parse(
|
|
||||||
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(planMd).toContain("# Plan");
|
|
||||||
expect(Object.keys(answers).length).toBeGreaterThan(0);
|
|
||||||
expect(questions.questions.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports one-time extension at the end and uses multiselect for extra questions", async () => {
|
|
||||||
(process.stdin as any).isTTY = true;
|
|
||||||
(process.stdout as any).isTTY = true;
|
|
||||||
|
|
||||||
// Ensure we accept the extra questions and multiselect returns both.
|
|
||||||
hoisted.state.confirmQueue.push(true);
|
|
||||||
hoisted.state.multiselectQueue.push(["Car", "Plane"]);
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
|
|
||||||
const createdDir = await getLatestPlanDir();
|
|
||||||
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
|
|
||||||
const questions = JSON.parse(
|
|
||||||
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(questions.questions.some((q: any) => q.id === "transport")).toBe(true);
|
|
||||||
expect(Array.isArray(answers.transport)).toBe(true);
|
|
||||||
expect(answers.transport).toEqual(["Car", "Plane"]);
|
|
||||||
expect(hoisted.clack.multiselect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not ask extension confirm when extension returns no extra questions", async () => {
|
|
||||||
(process.stdin as any).isTTY = true;
|
|
||||||
(process.stdout as any).isTTY = true;
|
|
||||||
|
|
||||||
hoisted.state.extraQuestions = [];
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
|
|
||||||
// confirm might still be used for other prompts; but in this template it shouldn't.
|
|
||||||
// We assert it was not called with the extension message.
|
|
||||||
const confirmCalls = (hoisted.clack.confirm as any).mock.calls as any[];
|
|
||||||
const extensionCall = confirmCalls.find((c) =>
|
|
||||||
String(c?.[0]?.message ?? "").includes("tighter the plan"),
|
|
||||||
);
|
|
||||||
expect(extensionCall).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dedupes extension questions by id", async () => {
|
|
||||||
(process.stdin as any).isTTY = true;
|
|
||||||
(process.stdout as any).isTTY = true;
|
|
||||||
|
|
||||||
// Extension repeats an existing id.
|
|
||||||
hoisted.state.extraQuestions = [
|
|
||||||
{
|
|
||||||
id: "budget",
|
|
||||||
section: "Constraints",
|
|
||||||
prompt: "Budget again?",
|
|
||||||
kind: "select",
|
|
||||||
required: true,
|
|
||||||
options: ["$"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
|
|
||||||
const createdDir = await getLatestPlanDir();
|
|
||||||
const questions = JSON.parse(
|
|
||||||
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
|
|
||||||
);
|
|
||||||
const ids = questions.questions.map((q: any) => q.id);
|
|
||||||
const uniqueIds = new Set(ids);
|
|
||||||
expect(uniqueIds.size).toBe(ids.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels cleanly if plan name prompt is cancelled", async () => {
|
|
||||||
(process.stdin as any).isTTY = true;
|
|
||||||
(process.stdout as any).isTTY = true;
|
|
||||||
|
|
||||||
hoisted.state.textQueue.push(CANCEL);
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Cancelled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on required multiselect when user selects nothing", async () => {
|
|
||||||
(process.stdin as any).isTTY = true;
|
|
||||||
(process.stdout as any).isTTY = true;
|
|
||||||
|
|
||||||
// Force extension with a required multiselect and accept it.
|
|
||||||
hoisted.state.confirmQueue.push(true);
|
|
||||||
hoisted.state.multiselectQueue.push([]);
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Missing required answer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not run TUI handler when not in a TTY", async () => {
|
|
||||||
(process.stdin as any).isTTY = false;
|
|
||||||
(process.stdout as any).isTTY = false;
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
|
||||||
expect(result.shouldContinue).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { confirm, isCancel, select, text, multiselect } from "@clack/prompts";
|
|
||||||
|
|
||||||
import type { ClawdbotPluginApi } from "../../plugins/types.js";
|
|
||||||
import type { CommandHandler } from "./commands-types.js";
|
|
||||||
|
|
||||||
// We import the llm-task tool directly (Clawdbot-native) so standalone TUI runs can
|
|
||||||
// still use the agent's configured models via the embedded runner.
|
|
||||||
import { createLlmTaskTool } from "../../../extensions/llm-task/src/llm-task-tool.js";
|
|
||||||
|
|
||||||
type QuestionKind = "text" | "select" | "multiselect" | "confirm";
|
|
||||||
|
|
||||||
export type QuestionSpec = {
|
|
||||||
id: string;
|
|
||||||
section: string;
|
|
||||||
prompt: string;
|
|
||||||
kind: QuestionKind;
|
|
||||||
required?: boolean;
|
|
||||||
options?: string[];
|
|
||||||
placeholder?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QuestionSet = {
|
|
||||||
title?: string;
|
|
||||||
goal: string;
|
|
||||||
questions: QuestionSpec[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const QUESTIONS_SCHEMA = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
title: { type: "string" },
|
|
||||||
goal: { type: "string" },
|
|
||||||
questions: {
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
id: { type: "string" },
|
|
||||||
section: { type: "string" },
|
|
||||||
prompt: { type: "string" },
|
|
||||||
kind: { type: "string", enum: ["text", "select", "multiselect", "confirm"] },
|
|
||||||
required: { type: "boolean" },
|
|
||||||
options: { type: "array", items: { type: "string" } },
|
|
||||||
placeholder: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["id", "section", "prompt", "kind"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["goal", "questions"],
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function slugify(input: string): string {
|
|
||||||
return input
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.slice(0, 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowStamp() {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(
|
|
||||||
d.getMinutes(),
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeReadJson(filePath: string): Promise<any | null> {
|
|
||||||
try {
|
|
||||||
const text = await fs.readFile(filePath, "utf-8");
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "ENOENT") return null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJson(filePath: string, value: any) {
|
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
||||||
await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeTextFile(filePath: string, textContent: string) {
|
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
||||||
await fs.writeFile(filePath, textContent, "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLlmQuestionPrompt(goal: string) {
|
|
||||||
return (
|
|
||||||
`You are helping a user plan a project. Generate a compact questionnaire grouped into sections.\n` +
|
|
||||||
`Return JSON matching the provided schema.\n\n` +
|
|
||||||
`Requirements:\n` +
|
|
||||||
`- Ask only high-signal questions. Prefer multiple choice when the user can pick from common options.\n` +
|
|
||||||
`- Use sections like Goals, Constraints, Inputs, Outputs, Timeline, Risks (as appropriate).\n` +
|
|
||||||
`- Keep it to ~8-15 questions unless the goal clearly needs more.\n` +
|
|
||||||
`- Use stable ids (snake_case).\n` +
|
|
||||||
`- Use multiselect when multiple options may apply.\n\n` +
|
|
||||||
`GOAL:\n${goal}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLlmExtendPrompt(goal: string) {
|
|
||||||
return (
|
|
||||||
`You are helping a user plan a project.\n` +
|
|
||||||
`Given the current answers and existing question ids, propose any missing high-signal questions.\n` +
|
|
||||||
`Return JSON matching the provided schema.\n\n` +
|
|
||||||
`Rules:\n` +
|
|
||||||
`- If nothing important is missing, return {"goal": <same>, "questions": []}.\n` +
|
|
||||||
`- Do not repeat existing question ids.\n` +
|
|
||||||
`- Keep it short: up to 5 additional questions.\n\n` +
|
|
||||||
`GOAL:\n${goal}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlanMarkdown(goal: string, answers: Record<string, any>, questions: QuestionSpec[]) {
|
|
||||||
const byId = new Map(questions.map((q) => [q.id, q] as const));
|
|
||||||
const sections = new Map<string, Array<{ q: QuestionSpec; a: any }>>();
|
|
||||||
for (const [id, a] of Object.entries(answers)) {
|
|
||||||
const q = byId.get(id);
|
|
||||||
if (!q) continue;
|
|
||||||
const list = sections.get(q.section) ?? [];
|
|
||||||
list.push({ q, a });
|
|
||||||
sections.set(q.section, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push(`# Plan\n\n## Goal\n${goal}\n`);
|
|
||||||
for (const [section, items] of sections) {
|
|
||||||
lines.push(`## ${section}`);
|
|
||||||
for (const { q, a } of items) {
|
|
||||||
const rendered = Array.isArray(a)
|
|
||||||
? a.join(", ")
|
|
||||||
: typeof a === "boolean"
|
|
||||||
? a
|
|
||||||
? "yes"
|
|
||||||
: "no"
|
|
||||||
: String(a);
|
|
||||||
lines.push(`- **${q.prompt}**: ${rendered}`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n").trim() + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFakePluginApi(cfg: any, workspaceDir: string): ClawdbotPluginApi {
|
|
||||||
// Minimal stub for llm-task tool; it only relies on `config` and `pluginConfig`.
|
|
||||||
return {
|
|
||||||
id: "local",
|
|
||||||
name: "local",
|
|
||||||
source: "internal",
|
|
||||||
config: cfg,
|
|
||||||
pluginConfig: {},
|
|
||||||
runtime: {} as any,
|
|
||||||
logger: {
|
|
||||||
info() {},
|
|
||||||
warn() {},
|
|
||||||
error() {},
|
|
||||||
debug() {},
|
|
||||||
},
|
|
||||||
registerTool() {},
|
|
||||||
registerHook() {},
|
|
||||||
registerHttpHandler() {},
|
|
||||||
registerChannel() {},
|
|
||||||
registerGatewayMethod() {},
|
|
||||||
registerCli() {},
|
|
||||||
registerService() {},
|
|
||||||
registerProvider() {},
|
|
||||||
registerCommand() {},
|
|
||||||
resolvePath: (input: string) => path.resolve(workspaceDir, input),
|
|
||||||
on() {},
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handlePlanCommand: CommandHandler = async (params, allowTextCommands) => {
|
|
||||||
if (!allowTextCommands) return null;
|
|
||||||
|
|
||||||
if (!params.command.commandBodyNormalized.startsWith("/plan")) return null;
|
|
||||||
|
|
||||||
// Only implement interactive plan builder in the local CLI.
|
|
||||||
const isInteractiveCli =
|
|
||||||
params.ctx.CommandSource === "cli" &&
|
|
||||||
Boolean(process.stdin.isTTY) &&
|
|
||||||
Boolean(process.stdout.isTTY);
|
|
||||||
if (!isInteractiveCli) return null;
|
|
||||||
|
|
||||||
const raw =
|
|
||||||
params.ctx.BodyForCommands ??
|
|
||||||
params.ctx.CommandBody ??
|
|
||||||
params.ctx.RawBody ??
|
|
||||||
params.ctx.BodyStripped ??
|
|
||||||
params.ctx.Body ??
|
|
||||||
"/plan";
|
|
||||||
|
|
||||||
const goal = raw.replace(/^\s*\/plan\s*/i, "").trim();
|
|
||||||
if (!goal) {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: "Usage: /plan <goal>" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultName = `${nowStamp()}-${slugify(goal.split(/\s+/).slice(0, 6).join(" ")) || "plan"}`;
|
|
||||||
const chosenName = await text({
|
|
||||||
message: "Plan name (folder under workspace/plans/)",
|
|
||||||
initialValue: defaultName,
|
|
||||||
validate: (v) => (!v?.trim() ? "Name required" : undefined),
|
|
||||||
});
|
|
||||||
if (isCancel(chosenName)) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const planName = slugify(String(chosenName).trim()) || defaultName;
|
|
||||||
const plansDir = path.join(params.workspaceDir, "plans");
|
|
||||||
const planDir = path.join(plansDir, planName);
|
|
||||||
await fs.mkdir(planDir, { recursive: true });
|
|
||||||
|
|
||||||
const metaPath = path.join(planDir, "meta.json");
|
|
||||||
const answersPath = path.join(planDir, "answers.json");
|
|
||||||
const planMdPath = path.join(planDir, "plan.md");
|
|
||||||
|
|
||||||
const existingAnswers = (await safeReadJson(answersPath)) ?? {};
|
|
||||||
|
|
||||||
await writeJson(metaPath, {
|
|
||||||
name: planName,
|
|
||||||
goal,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ask the LLM to generate the question set.
|
|
||||||
const api = createFakePluginApi(params.cfg, params.workspaceDir);
|
|
||||||
const tool = createLlmTaskTool(api);
|
|
||||||
|
|
||||||
const llmResult = await tool.execute("llm-task", {
|
|
||||||
prompt: buildLlmQuestionPrompt(goal),
|
|
||||||
input: { goal, existingAnswers },
|
|
||||||
schema: QUESTIONS_SCHEMA,
|
|
||||||
});
|
|
||||||
|
|
||||||
const questionSet = (llmResult as any).details?.json as QuestionSet;
|
|
||||||
const questions: QuestionSpec[] = Array.isArray(questionSet?.questions)
|
|
||||||
? questionSet.questions
|
|
||||||
: [];
|
|
||||||
if (questions.length === 0) {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: "No questions generated (unexpected)." },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const questionsPath = path.join(planDir, "questions.json");
|
|
||||||
await writeJson(questionsPath, { goal, questions });
|
|
||||||
|
|
||||||
// Group by section.
|
|
||||||
const sectionOrder: string[] = [];
|
|
||||||
const bySection = new Map<string, QuestionSpec[]>();
|
|
||||||
for (const q of questions) {
|
|
||||||
const section = q.section?.trim() || "General";
|
|
||||||
if (!bySection.has(section)) sectionOrder.push(section);
|
|
||||||
const list = bySection.get(section) ?? [];
|
|
||||||
list.push(q);
|
|
||||||
bySection.set(section, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const answers: Record<string, any> = { ...existingAnswers };
|
|
||||||
|
|
||||||
// Iterate sections with a review loop.
|
|
||||||
while (true) {
|
|
||||||
const section = await select({
|
|
||||||
message: "Choose a section",
|
|
||||||
options: [
|
|
||||||
...sectionOrder.map((s) => ({ label: s, value: s })),
|
|
||||||
{ label: "Review + finalize", value: "__review" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (isCancel(section)) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section === "__review") break;
|
|
||||||
|
|
||||||
const qs = bySection.get(String(section)) ?? [];
|
|
||||||
for (const q of qs) {
|
|
||||||
const existing = answers[q.id];
|
|
||||||
const required = Boolean(q.required);
|
|
||||||
|
|
||||||
if (q.kind === "confirm") {
|
|
||||||
const res = await confirm({ message: q.prompt, initialValue: Boolean(existing ?? false) });
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
answers[q.id] = Boolean(res);
|
|
||||||
} else if (q.kind === "select") {
|
|
||||||
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
|
|
||||||
if (opts.length === 0) {
|
|
||||||
const res = await text({
|
|
||||||
message: q.prompt,
|
|
||||||
initialValue: existing ? String(existing) : "",
|
|
||||||
});
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const v = String(res).trim();
|
|
||||||
if (required && !v)
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
answers[q.id] = v;
|
|
||||||
} else {
|
|
||||||
const res = await select({ message: q.prompt, options: opts });
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
answers[q.id] = res;
|
|
||||||
}
|
|
||||||
} else if (q.kind === "multiselect") {
|
|
||||||
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
|
|
||||||
if (opts.length === 0) {
|
|
||||||
const res = await text({
|
|
||||||
message: q.prompt,
|
|
||||||
initialValue: Array.isArray(existing) ? existing.join(", ") : "",
|
|
||||||
placeholder: q.placeholder,
|
|
||||||
});
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const arr = String(res)
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (required && arr.length === 0) {
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
}
|
|
||||||
answers[q.id] = arr;
|
|
||||||
} else {
|
|
||||||
const res = await multiselect({
|
|
||||||
message: q.prompt,
|
|
||||||
options: opts,
|
|
||||||
required: required ? true : false,
|
|
||||||
} as any);
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const arr = Array.isArray(res) ? res : [];
|
|
||||||
if (required && arr.length === 0) {
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
}
|
|
||||||
answers[q.id] = arr;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const res = await text({
|
|
||||||
message: q.prompt,
|
|
||||||
initialValue: existing ? String(existing) : "",
|
|
||||||
placeholder: q.placeholder,
|
|
||||||
});
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const v = String(res).trim();
|
|
||||||
if (required && !v) {
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
}
|
|
||||||
answers[q.id] = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeJson(answersPath, answers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-time dynamic extension at the end.
|
|
||||||
const existingIds = new Set(questions.map((q) => q.id));
|
|
||||||
const extend = await tool.execute("llm-task", {
|
|
||||||
prompt: buildLlmExtendPrompt(goal),
|
|
||||||
input: {
|
|
||||||
goal,
|
|
||||||
answers,
|
|
||||||
existingQuestionIds: Array.from(existingIds),
|
|
||||||
},
|
|
||||||
schema: QUESTIONS_SCHEMA,
|
|
||||||
});
|
|
||||||
|
|
||||||
const extendSet = (extend as any).details?.json as QuestionSet;
|
|
||||||
const extraQuestions: QuestionSpec[] = Array.isArray(extendSet?.questions)
|
|
||||||
? extendSet.questions
|
|
||||||
: [];
|
|
||||||
const filteredExtras = extraQuestions.filter((q) => q && q.id && !existingIds.has(q.id));
|
|
||||||
|
|
||||||
if (filteredExtras.length > 0) {
|
|
||||||
const ok = await confirm({
|
|
||||||
message: `I have ${filteredExtras.length} more question(s) to tighten the plan. Add them?`,
|
|
||||||
});
|
|
||||||
if (!isCancel(ok) && ok === true) {
|
|
||||||
for (const q of filteredExtras) {
|
|
||||||
const section = q.section?.trim() || "General";
|
|
||||||
if (!bySection.has(section)) {
|
|
||||||
sectionOrder.push(section);
|
|
||||||
bySection.set(section, []);
|
|
||||||
}
|
|
||||||
bySection.get(section)!.push(q);
|
|
||||||
questions.push(q);
|
|
||||||
existingIds.add(q.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask only the newly added questions (once).
|
|
||||||
for (const q of filteredExtras) {
|
|
||||||
const existing = answers[q.id];
|
|
||||||
const required = Boolean(q.required);
|
|
||||||
|
|
||||||
if (q.kind === "confirm") {
|
|
||||||
const res = await confirm({
|
|
||||||
message: q.prompt,
|
|
||||||
initialValue: Boolean(existing ?? false),
|
|
||||||
});
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
answers[q.id] = Boolean(res);
|
|
||||||
} else if (q.kind === "select") {
|
|
||||||
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
|
|
||||||
if (opts.length === 0) {
|
|
||||||
const res = await text({
|
|
||||||
message: q.prompt,
|
|
||||||
initialValue: existing ? String(existing) : "",
|
|
||||||
});
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const v = String(res).trim();
|
|
||||||
if (required && !v)
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
answers[q.id] = v;
|
|
||||||
} else {
|
|
||||||
const res = await select({ message: q.prompt, options: opts });
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
answers[q.id] = res;
|
|
||||||
}
|
|
||||||
} else if (q.kind === "multiselect") {
|
|
||||||
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
|
|
||||||
if (opts.length === 0) {
|
|
||||||
const res = await text({
|
|
||||||
message: q.prompt,
|
|
||||||
initialValue: Array.isArray(existing) ? existing.join(", ") : "",
|
|
||||||
});
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const arr = String(res)
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (required && arr.length === 0)
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
answers[q.id] = arr;
|
|
||||||
} else {
|
|
||||||
const res = await multiselect({
|
|
||||||
message: q.prompt,
|
|
||||||
options: opts,
|
|
||||||
required: required ? true : false,
|
|
||||||
} as any);
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const arr = Array.isArray(res) ? res : [];
|
|
||||||
if (required && arr.length === 0)
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
answers[q.id] = arr;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const res = await text({
|
|
||||||
message: q.prompt,
|
|
||||||
initialValue: existing ? String(existing) : "",
|
|
||||||
});
|
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const v = String(res).trim();
|
|
||||||
if (required && !v)
|
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
|
||||||
answers[q.id] = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeJson(answersPath, answers);
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeJson(questionsPath, { goal, questions });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const md = buildPlanMarkdown(goal, answers, questions);
|
|
||||||
await writeTextFile(planMdPath, md);
|
|
||||||
await writeJson(metaPath, {
|
|
||||||
name: planName,
|
|
||||||
goal,
|
|
||||||
createdAt: (await safeReadJson(metaPath))?.createdAt ?? new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: {
|
|
||||||
text:
|
|
||||||
`✅ Plan saved: ${planName}\n` +
|
|
||||||
`- ${path.relative(params.workspaceDir, planMdPath)}\n` +
|
|
||||||
`- ${path.relative(params.workspaceDir, answersPath)}\n\n` +
|
|
||||||
`Use /plans show ${planName} to view it.`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { confirm, isCancel, select, text } from "@clack/prompts";
|
|
||||||
|
|
||||||
import type { CommandHandler } from "./commands-types.js";
|
|
||||||
|
|
||||||
type PlansArgs = {
|
|
||||||
action: "list" | "show" | "rename" | "trash" | "help";
|
|
||||||
a?: string;
|
|
||||||
b?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parsePlansArgs(raw: string): PlansArgs {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
const parts = trimmed.split(/\s+/).filter(Boolean);
|
|
||||||
const cmd = parts[0]?.toLowerCase();
|
|
||||||
if (cmd !== "/plans") return { action: "help" };
|
|
||||||
const sub = parts[1]?.toLowerCase();
|
|
||||||
if (!sub || sub === "list") return { action: "list" };
|
|
||||||
if (sub === "help") return { action: "help" };
|
|
||||||
if (sub === "show") return { action: "show", a: parts[2] };
|
|
||||||
if (sub === "rename") return { action: "rename", a: parts[2], b: parts[3] };
|
|
||||||
if (sub === "trash" || sub === "delete" || sub === "rm") return { action: "trash", a: parts[2] };
|
|
||||||
// Default: treat unknown as help.
|
|
||||||
return { action: "help" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listPlanDirs(plansDir: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(plansDir, { withFileTypes: true });
|
|
||||||
return entries
|
|
||||||
.filter((e) => e.isDirectory())
|
|
||||||
.map((e) => e.name)
|
|
||||||
.filter((name) => !name.startsWith("."))
|
|
||||||
.sort();
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "ENOENT") return [];
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readFirstExisting(paths: string[]): Promise<string | null> {
|
|
||||||
for (const p of paths) {
|
|
||||||
try {
|
|
||||||
const text = await fs.readFile(p, "utf-8");
|
|
||||||
return text;
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "ENOENT") continue;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handlePlansCommand: CommandHandler = async (params, allowTextCommands) => {
|
|
||||||
if (!allowTextCommands) return null;
|
|
||||||
|
|
||||||
// Match on normalized command body
|
|
||||||
if (!params.command.commandBodyNormalized.startsWith("/plans")) return null;
|
|
||||||
|
|
||||||
const raw =
|
|
||||||
params.ctx.BodyForCommands ??
|
|
||||||
params.ctx.CommandBody ??
|
|
||||||
params.ctx.RawBody ??
|
|
||||||
params.ctx.BodyStripped ??
|
|
||||||
params.ctx.Body ??
|
|
||||||
"/plans";
|
|
||||||
|
|
||||||
const args = parsePlansArgs(raw);
|
|
||||||
|
|
||||||
const plansDir = path.join(params.workspaceDir, "plans");
|
|
||||||
|
|
||||||
const isInteractiveCli =
|
|
||||||
params.ctx.CommandSource === "cli" &&
|
|
||||||
Boolean(process.stdin.isTTY) &&
|
|
||||||
Boolean(process.stdout.isTTY);
|
|
||||||
|
|
||||||
if (args.action === "help") {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: {
|
|
||||||
text:
|
|
||||||
"Usage:\n" +
|
|
||||||
" /plans list\n" +
|
|
||||||
" /plans show <name>\n" +
|
|
||||||
" /plans rename <old> <new>\n" +
|
|
||||||
" /plans trash <name>\n\n" +
|
|
||||||
"TUI:\n" +
|
|
||||||
" /plans (with no args) opens an interactive picker in the CLI.\n\n" +
|
|
||||||
"Notes:\n" +
|
|
||||||
"- Plans are stored under workspace/plans/.\n" +
|
|
||||||
"- Use /plan <goal> to start a new plan; /plans helps manage saved plan folders.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.action === "list") {
|
|
||||||
const names = await listPlanDirs(plansDir);
|
|
||||||
if (names.length === 0) {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: "No plans found yet. Create one with: /plan <goal>" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interactive picker only when running in the CLI TUI.
|
|
||||||
// For chat channels (Discord/Telegram/etc.), return plain text.
|
|
||||||
const wantsInteractive =
|
|
||||||
isInteractiveCli && (raw.trim() === "/plans" || raw.trim() === "/plans list");
|
|
||||||
if (wantsInteractive) {
|
|
||||||
const selected = await select({
|
|
||||||
message: "Select a plan",
|
|
||||||
options: names
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((n) => ({ label: n, value: n })),
|
|
||||||
});
|
|
||||||
if (isCancel(selected)) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const planName = String(selected);
|
|
||||||
const action = await select({
|
|
||||||
message: `Action for ${planName}`,
|
|
||||||
options: [
|
|
||||||
{ label: "Show", value: "show" },
|
|
||||||
{ label: "Rename", value: "rename" },
|
|
||||||
{ label: "Trash", value: "trash" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (isCancel(action)) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "show") {
|
|
||||||
const planDir = path.join(plansDir, planName);
|
|
||||||
const planMd = await readFirstExisting([
|
|
||||||
path.join(planDir, "plan.md"),
|
|
||||||
path.join(planDir, "README.md"),
|
|
||||||
]);
|
|
||||||
if (!planMd) {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `No plan.md found for '${planName}'. Looked in: ${planDir}` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const trimmed = planMd.trim();
|
|
||||||
const preview =
|
|
||||||
trimmed.length > 6000 ? `${trimmed.slice(0, 6000)}\n\n…(truncated)` : trimmed;
|
|
||||||
return { shouldContinue: false, reply: { text: `Plan: ${planName}\n\n${preview}` } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "rename") {
|
|
||||||
const next = await text({
|
|
||||||
message: `Rename '${planName}' to:`,
|
|
||||||
initialValue: planName,
|
|
||||||
validate: (v) => (!v?.trim() ? "Name required" : undefined),
|
|
||||||
});
|
|
||||||
if (isCancel(next)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
const toName = String(next).trim();
|
|
||||||
if (toName && toName !== planName) {
|
|
||||||
await fs.rename(path.join(plansDir, planName), path.join(plansDir, toName));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `Renamed plan '${planName}' → '${toName}'.` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "trash") {
|
|
||||||
const ok = await confirm({ message: `Move '${planName}' to trash?` });
|
|
||||||
if (isCancel(ok) || ok === false) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
|
||||||
}
|
|
||||||
const fromDir = path.join(plansDir, planName);
|
|
||||||
const trashDir = path.join(plansDir, ".trash");
|
|
||||||
await fs.mkdir(trashDir, { recursive: true });
|
|
||||||
const stamped = `${planName}__${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
||||||
const toDir = path.join(trashDir, stamped);
|
|
||||||
await fs.rename(fromDir, toDir);
|
|
||||||
return { shouldContinue: false, reply: { text: `Moved plan '${planName}' to trash.` } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ["Plans:", ...names.map((n) => `- ${n}`), "", "Tip: /plans show <name>"];
|
|
||||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.action === "show") {
|
|
||||||
const name = String(args.a ?? "").trim();
|
|
||||||
if (!name) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Usage: /plans show <name>" } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const planDir = path.join(plansDir, name);
|
|
||||||
const planMd = await readFirstExisting([
|
|
||||||
path.join(planDir, "plan.md"),
|
|
||||||
path.join(planDir, "README.md"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!planMd) {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `No plan.md found for '${name}'. Looked in: ${planDir}` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = planMd.trim();
|
|
||||||
const preview = trimmed.length > 1800 ? `${trimmed.slice(0, 1800)}\n\n…(truncated)` : trimmed;
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `Plan: ${name}\n\n${preview}` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.action === "rename") {
|
|
||||||
const from = String(args.a ?? "").trim();
|
|
||||||
const to = String(args.b ?? "").trim();
|
|
||||||
if (!from || !to) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Usage: /plans rename <old> <new>" } };
|
|
||||||
}
|
|
||||||
const fromDir = path.join(plansDir, from);
|
|
||||||
const toDir = path.join(plansDir, to);
|
|
||||||
|
|
||||||
await fs.mkdir(plansDir, { recursive: true });
|
|
||||||
await fs.rename(fromDir, toDir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `Renamed plan '${from}' → '${to}'.` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.action === "trash") {
|
|
||||||
const name = String(args.a ?? "").trim();
|
|
||||||
if (!name) {
|
|
||||||
return { shouldContinue: false, reply: { text: "Usage: /plans trash <name>" } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromDir = path.join(plansDir, name);
|
|
||||||
const trashDir = path.join(plansDir, ".trash");
|
|
||||||
await fs.mkdir(trashDir, { recursive: true });
|
|
||||||
|
|
||||||
const stamped = `${name}__${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
||||||
const toDir = path.join(trashDir, stamped);
|
|
||||||
await fs.rename(fromDir, toDir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `Moved plan '${name}' to trash.` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -132,44 +132,6 @@ describe("handleCommands gating", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/plans", () => {
|
|
||||||
it("lists plan directories", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
whatsapp: { allowFrom: ["*"] },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const plansDir = path.join(testWorkspaceDir, "plans");
|
|
||||||
await fs.mkdir(path.join(plansDir, "2026-01-24-trip"), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(plansDir, "2026-01-24-trip", "plan.md"),
|
|
||||||
"# Trip Plan\n\nDo stuff\n",
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const params = buildParams("/plans list", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("2026-01-24-trip");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows plan.md", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
whatsapp: { allowFrom: ["*"] },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const plansDir = path.join(testWorkspaceDir, "plans");
|
|
||||||
await fs.mkdir(path.join(plansDir, "demo"), { recursive: true });
|
|
||||||
await fs.writeFile(path.join(plansDir, "demo", "plan.md"), "Hello plan", "utf-8");
|
|
||||||
|
|
||||||
const params = buildParams("/plans show demo", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Hello plan");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleCommands bash alias", () => {
|
describe("handleCommands bash alias", () => {
|
||||||
it("routes !poll through the /bash handler", async () => {
|
it("routes !poll through the /bash handler", async () => {
|
||||||
resetBashChatCommandForTests();
|
resetBashChatCommandForTests();
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { validateConfigObject } from "./validation.js";
|
||||||
|
|
||||||
|
// NOTE: These tests ensure allow + alsoAllow cannot be set in the same scope.
|
||||||
|
|
||||||
|
describe("config: tools.alsoAllow", () => {
|
||||||
|
it("rejects tools.allow + tools.alsoAllow together", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
tools: {
|
||||||
|
allow: ["group:fs"],
|
||||||
|
alsoAllow: ["lobster"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
expect(res.issues.some((i) => i.path === "tools")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects agents.list[].tools.allow + alsoAllow together", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
tools: {
|
||||||
|
allow: ["group:fs"],
|
||||||
|
alsoAllow: ["lobster"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
expect(res.issues.some((i) => i.path.includes("agents.list"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows profile + alsoAllow", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
tools: {
|
||||||
|
profile: "coding",
|
||||||
|
alsoAllow: ["lobster"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
resolveDefaultAgentIdFromRaw,
|
resolveDefaultAgentIdFromRaw,
|
||||||
} from "./legacy.shared.js";
|
} from "./legacy.shared.js";
|
||||||
|
|
||||||
|
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
|
||||||
|
|
||||||
|
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
|
||||||
|
|
||||||
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||||
{
|
{
|
||||||
id: "auth.anthropic-claude-cli-mode-oauth",
|
id: "auth.anthropic-claude-cli-mode-oauth",
|
||||||
@@ -24,6 +28,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
|||||||
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
|
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// tools.alsoAllow migration removed (field not shipped in prod; enforce via schema instead).
|
||||||
{
|
{
|
||||||
id: "tools.bash->tools.exec",
|
id: "tools.bash->tools.exec",
|
||||||
describe: "Move tools.bash to tools.exec",
|
describe: "Move tools.bash to tools.exec",
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.links.models": "Link Understanding Models",
|
"tools.links.models": "Link Understanding Models",
|
||||||
"tools.links.scope": "Link Understanding Scope",
|
"tools.links.scope": "Link Understanding Scope",
|
||||||
"tools.profile": "Tool Profile",
|
"tools.profile": "Tool Profile",
|
||||||
|
"tools.alsoAllow": "Tool Allowlist Additions",
|
||||||
"agents.list[].tools.profile": "Agent Tool Profile",
|
"agents.list[].tools.profile": "Agent Tool Profile",
|
||||||
|
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
|
||||||
"tools.byProvider": "Tool Policy by Provider",
|
"tools.byProvider": "Tool Policy by Provider",
|
||||||
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
|
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
|
||||||
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
||||||
|
|||||||
@@ -140,12 +140,21 @@ export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
|
|||||||
|
|
||||||
export type ToolPolicyConfig = {
|
export type ToolPolicyConfig = {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
|
/**
|
||||||
|
* Additional allowlist entries merged into the effective allowlist.
|
||||||
|
*
|
||||||
|
* Intended for additive configuration (e.g., "also allow lobster") without forcing
|
||||||
|
* users to replace/duplicate an existing allowlist or profile.
|
||||||
|
*/
|
||||||
|
alsoAllow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
profile?: ToolProfileId;
|
profile?: ToolProfileId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupToolPolicyConfig = {
|
export type GroupToolPolicyConfig = {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
|
/** Additional allowlist entries merged into allow. */
|
||||||
|
alsoAllow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,6 +197,8 @@ export type AgentToolsConfig = {
|
|||||||
/** Base tool profile applied before allow/deny lists. */
|
/** Base tool profile applied before allow/deny lists. */
|
||||||
profile?: ToolProfileId;
|
profile?: ToolProfileId;
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
|
/** Additional allowlist entries merged into allow and/or profile allowlist. */
|
||||||
|
alsoAllow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
/** Optional tool policy overrides keyed by provider id or "provider/model". */
|
/** Optional tool policy overrides keyed by provider id or "provider/model". */
|
||||||
byProvider?: Record<string, ToolPolicyConfig>;
|
byProvider?: Record<string, ToolPolicyConfig>;
|
||||||
@@ -312,6 +323,8 @@ export type ToolsConfig = {
|
|||||||
/** Base tool profile applied before allow/deny lists. */
|
/** Base tool profile applied before allow/deny lists. */
|
||||||
profile?: ToolProfileId;
|
profile?: ToolProfileId;
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
|
/** Additional allowlist entries merged into allow and/or profile allowlist. */
|
||||||
|
alsoAllow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
/** Optional tool policy overrides keyed by provider id or "provider/model". */
|
/** Optional tool policy overrides keyed by provider id or "provider/model". */
|
||||||
byProvider?: Record<string, ToolPolicyConfig>;
|
byProvider?: Record<string, ToolPolicyConfig>;
|
||||||
|
|||||||
@@ -147,13 +147,22 @@ export const SandboxPruneSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
export const ToolPolicySchema = z
|
const ToolPolicyBaseSchema = z
|
||||||
.object({
|
.object({
|
||||||
allow: z.array(z.string()).optional(),
|
allow: z.array(z.string()).optional(),
|
||||||
|
alsoAllow: z.array(z.string()).optional(),
|
||||||
deny: z.array(z.string()).optional(),
|
deny: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict();
|
||||||
.optional();
|
|
||||||
|
export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => {
|
||||||
|
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).optional();
|
||||||
|
|
||||||
export const ToolsWebSearchSchema = z
|
export const ToolsWebSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -202,10 +211,20 @@ export const ToolProfileSchema = z
|
|||||||
export const ToolPolicyWithProfileSchema = z
|
export const ToolPolicyWithProfileSchema = z
|
||||||
.object({
|
.object({
|
||||||
allow: z.array(z.string()).optional(),
|
allow: z.array(z.string()).optional(),
|
||||||
|
alsoAllow: z.array(z.string()).optional(),
|
||||||
deny: z.array(z.string()).optional(),
|
deny: z.array(z.string()).optional(),
|
||||||
profile: ToolProfileSchema,
|
profile: ToolProfileSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict()
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
"tools.byProvider policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
|
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
|
||||||
export const ElevatedAllowFromSchema = z
|
export const ElevatedAllowFromSchema = z
|
||||||
@@ -231,6 +250,7 @@ export const AgentToolsSchema = z
|
|||||||
.object({
|
.object({
|
||||||
profile: ToolProfileSchema,
|
profile: ToolProfileSchema,
|
||||||
allow: z.array(z.string()).optional(),
|
allow: z.array(z.string()).optional(),
|
||||||
|
alsoAllow: z.array(z.string()).optional(),
|
||||||
deny: z.array(z.string()).optional(),
|
deny: z.array(z.string()).optional(),
|
||||||
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
|
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
|
||||||
elevated: z
|
elevated: z
|
||||||
@@ -271,6 +291,15 @@ export const AgentToolsSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
"agent tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
export const MemorySearchSchema = z
|
export const MemorySearchSchema = z
|
||||||
@@ -425,6 +454,7 @@ export const ToolsSchema = z
|
|||||||
.object({
|
.object({
|
||||||
profile: ToolProfileSchema,
|
profile: ToolProfileSchema,
|
||||||
allow: z.array(z.string()).optional(),
|
allow: z.array(z.string()).optional(),
|
||||||
|
alsoAllow: z.array(z.string()).optional(),
|
||||||
deny: z.array(z.string()).optional(),
|
deny: z.array(z.string()).optional(),
|
||||||
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
|
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
|
||||||
web: ToolsWebSchema,
|
web: ToolsWebSchema,
|
||||||
@@ -507,4 +537,13 @@ export const ToolsSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
"tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
.optional();
|
.optional();
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
|
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
|
||||||
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
|
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
|
||||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
|
|
||||||
installGatewayTestHooks({ scope: "suite" });
|
installGatewayTestHooks({ scope: "suite" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Ensure these tests are not affected by host env vars.
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
const resolveGatewayToken = (): string => {
|
const resolveGatewayToken = (): string => {
|
||||||
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
|
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
|
||||||
if (!token) throw new Error("test gateway token missing");
|
if (!token) throw new Error("test gateway token missing");
|
||||||
@@ -47,6 +54,63 @@ describe("POST /tools/invoke", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports tools.alsoAllow as additive allowlist (profile stage)", async () => {
|
||||||
|
// No explicit tool allowlist; rely on profile + alsoAllow.
|
||||||
|
testState.agentsConfig = {
|
||||||
|
list: [{ id: "main" }],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// minimal profile does NOT include sessions_list, but alsoAllow should.
|
||||||
|
const { writeConfigFile } = await import("../config/config.js");
|
||||||
|
await writeConfigFile({
|
||||||
|
tools: { profile: "minimal", alsoAllow: ["sessions_list"] },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||||
|
const token = resolveGatewayToken();
|
||||||
|
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports tools.alsoAllow without allow/profile (implicit allow-all)", async () => {
|
||||||
|
testState.agentsConfig = {
|
||||||
|
list: [{ id: "main" }],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
CONFIG_PATH_CLAWDBOT,
|
||||||
|
JSON.stringify({ tools: { alsoAllow: ["sessions_list"] } }, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||||
|
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts password auth when bearer token matches", async () => {
|
it("accepts password auth when bearer token matches", async () => {
|
||||||
testState.agentsConfig = {
|
testState.agentsConfig = {
|
||||||
list: [
|
list: [
|
||||||
|
|||||||
@@ -130,9 +130,22 @@ export async function handleToolsInvokeHttpRequest(
|
|||||||
agentProviderPolicy,
|
agentProviderPolicy,
|
||||||
profile,
|
profile,
|
||||||
providerProfile,
|
providerProfile,
|
||||||
|
profileAlsoAllow,
|
||||||
|
providerProfileAlsoAllow,
|
||||||
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
|
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
|
||||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||||
|
|
||||||
|
const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
|
||||||
|
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
|
||||||
|
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
|
||||||
|
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
|
||||||
|
providerProfilePolicy,
|
||||||
|
providerProfileAlsoAllow,
|
||||||
|
);
|
||||||
const groupPolicy = resolveGroupToolPolicy({
|
const groupPolicy = resolveGroupToolPolicy({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -176,18 +189,18 @@ export async function handleToolsInvokeHttpRequest(
|
|||||||
if (resolved.unknownAllowlist.length > 0) {
|
if (resolved.unknownAllowlist.length > 0) {
|
||||||
const entries = resolved.unknownAllowlist.join(", ");
|
const entries = resolved.unknownAllowlist.join(", ");
|
||||||
const suffix = resolved.strippedAllowlist
|
const suffix = resolved.strippedAllowlist
|
||||||
? "Ignoring allowlist so core tools remain available."
|
? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
|
||||||
: "These entries won't match any tool unless the plugin is enabled.";
|
: "These entries won't match any tool unless the plugin is enabled.";
|
||||||
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
|
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
|
||||||
}
|
}
|
||||||
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
|
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
|
||||||
};
|
};
|
||||||
const profilePolicyExpanded = resolvePolicy(
|
const profilePolicyExpanded = resolvePolicy(
|
||||||
profilePolicy,
|
profilePolicyWithAlsoAllow,
|
||||||
profile ? `tools.profile (${profile})` : "tools.profile",
|
profile ? `tools.profile (${profile})` : "tools.profile",
|
||||||
);
|
);
|
||||||
const providerProfileExpanded = resolvePolicy(
|
const providerProfileExpanded = resolvePolicy(
|
||||||
providerProfilePolicy,
|
providerProfilePolicyWithAlsoAllow,
|
||||||
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
|
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
|
||||||
);
|
);
|
||||||
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
|
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
|
||||||
|
|||||||
Reference in new issue
Block a user