fix: address CodeRabbit review comments

- Use consistent regex for OPENCLAW_HOOKS_TOKEN detection and replacement
  in shell profile (prevents silent no-ops for non-exported lines)
- Broaden token detection regex to match lines with/without export prefix
  and leading whitespace
- Fix misleading --non-interactive help text (token is auto-generated)
- Fix doctor.ts catch block to say "Notifier checks failed" not "load config"
- Fix 204 mock in Discord notifier test (ok: true, not ok: false)
- Fix weak no-duplicate assertion in setup.test.ts (actually count list items)
- Add discord to notifier options comment in config-instruction.ts
- URL-encode threadId in Discord webhook URL construction
- Add aoCwd to required[] in openclaw.plugin.json configSchema
- Add HTTPS recommendation comment to agent-orchestrator.yaml.example
- Add rimraf for cross-platform clean script in notifier-discord
- Rename "Recommended Settings" to "Required: Disable Conflicting Built-in Skills"
  with explicit warning in docs
- Add /ao setup post-setup reminder to manually disable coding-agent skill
- Fix misleading README non-interactive example wording

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dhruv Sharma 2026-03-26 21:58:19 +05:30
parent 4495e11b7c
commit 570f3b588f
14 changed files with 981 additions and 250 deletions

View File

@ -102,7 +102,7 @@ projects:
#
# openclaw:
# plugin: openclaw
# url: http://127.0.0.1:18789/hooks/agent
# url: http://127.0.0.1:18789/hooks/agent # Use https:// for remote (non-localhost) deployments
# token: ${OPENCLAW_HOOKS_TOKEN}
# retries: 3
# retryDelayMs: 1000

View File

@ -52,6 +52,21 @@ openclaw config set tools.allow '["group:plugins"]'
openclaw config set plugins.allow '["agent-orchestrator"]'
```
### Required: Disable Conflicting Built-in Skills
**Without these, the bot may ignore AO and write code directly.** Run once after setup:
```bash
# Prevent the bot from writing code directly — it should delegate to AO instead
openclaw config set tools.deny '["exec", "write", "str_replace_based_edit_tool", "create_file", "str_replace_editor"]'
# Disable the built-in coding skill (it tells the bot to use Codex/Claude Code directly, overriding AO)
openclaw config set skills.entries.coding-agent.enabled false
# Disable the built-in GitHub issues skill (it spawns OpenClaw sub-agents, bypassing AO)
openclaw config set skills.entries.gh-issues.enabled false
```
### Optional Settings
```bash

View File

@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
extractConfiguredReposFromYaml,
fetchIssues,
mergeStringLists,
parseStringArraySetting,
} from "./index.ts";
function makeIssue(number: number, title: string, repo: string) {
return {
number,
title,
labels: [],
state: "open",
assignees: [],
createdAt: `2026-03-${String(number).padStart(2, "0")}T00:00:00Z`,
url: `https://github.com/${repo}/issues/${number}`,
};
}
test("extractConfiguredReposFromYaml reads every project repo", () => {
const rawYaml = `
port: 3000
projects:
app:
repo: acme/app
path: ~/code/app
docs:
repo: "acme/docs" # keep quoted repos working
path: ~/code/docs
notifiers:
openclaw:
plugin: openclaw
`;
assert.deepEqual(extractConfiguredReposFromYaml(rawYaml), ["acme/app", "acme/docs"]);
});
test("fetchIssues queries every configured repo when repo is omitted", () => {
const ghCalls: string[] = [];
const result = fetchIssues(
{ aoCwd: "/tmp/work" },
{},
{
getConfiguredRepos: () => ["acme/app", "acme/docs"],
runGh: (_config, args) => {
const repoIndex = args.indexOf("-R");
const repo = repoIndex >= 0 ? args[repoIndex + 1] : "default";
ghCalls.push(repo);
if (repo === "acme/app") {
return { ok: true, output: JSON.stringify([makeIssue(1, "App bug", repo)]) };
}
if (repo === "acme/docs") {
return { ok: true, output: JSON.stringify([makeIssue(2, "Docs bug", repo)]) };
}
return { ok: false, error: `unexpected repo: ${repo}` };
},
},
);
assert.deepEqual(ghCalls, ["acme/app", "acme/docs"]);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.deepEqual(result.scannedRepos, ["acme/app", "acme/docs"]);
assert.equal(result.warnings.length, 0);
assert.deepEqual(
result.issues.map((issue) => issue.repository),
["acme/docs", "acme/app"],
);
});
test("fetchIssues surfaces GitHub failures instead of reporting an empty board", () => {
const result = fetchIssues(
{ aoCwd: "/tmp/work" },
{},
{
getConfiguredRepos: () => ["acme/app"],
runGh: () => ({ ok: false, error: "gh auth token missing" }),
},
);
assert.equal(result.ok, false);
if (result.ok) return;
assert.match(result.error, /gh auth token missing/);
});
test("fetchIssues keeps partial failures visible when at least one repo succeeds", () => {
const result = fetchIssues(
{ aoCwd: "/tmp/work" },
{},
{
getConfiguredRepos: () => ["acme/app", "acme/docs"],
runGh: (_config, args) => {
const repoIndex = args.indexOf("-R");
const repo = repoIndex >= 0 ? args[repoIndex + 1] : "default";
if (repo === "acme/app") {
return { ok: true, output: JSON.stringify([makeIssue(3, "App bug", repo)]) };
}
return { ok: false, error: "gh not authenticated for docs repo" };
},
},
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.issues.length, 1);
assert.deepEqual(result.warnings, ["acme/docs: gh not authenticated for docs repo"]);
});
test("allowlist helpers preserve existing entries while adding AO requirements", () => {
assert.deepEqual(mergeStringLists(["custom:tools", "group:plugins"], ["group:plugins"]), [
"custom:tools",
"group:plugins",
]);
assert.deepEqual(parseStringArraySetting('["group:plugins","custom:tools"]'), [
"group:plugins",
"custom:tools",
]);
assert.deepEqual(parseStringArraySetting("null"), []);
});

View File

@ -15,6 +15,9 @@
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
// ---------------------------------------------------------------------------
// Types
@ -79,32 +82,336 @@ interface GitHubIssue {
assignees: Array<{ login: string }>;
createdAt: string;
url: string;
repository?: string;
}
function fetchIssues(config: PluginConfig, repo?: string): GitHubIssue[] {
const args = ["issue", "list"];
if (repo) args.push("-R", repo);
args.push("--state", "open", "--json", "number,title,labels,state,assignees,createdAt,url", "--limit", "30");
const result = tryRunGh(
config,
args,
15_000,
);
if (!result.ok) return [];
interface FetchIssuesSuccess {
ok: true;
issues: GitHubIssue[];
scannedRepos: string[];
warnings: string[];
}
interface FetchIssuesFailure {
ok: false;
error: string;
}
type FetchIssuesResult = FetchIssuesSuccess | FetchIssuesFailure;
interface FetchIssuesOptions {
repo?: string;
labels?: string;
}
interface FetchIssuesDeps {
getConfiguredRepos: (config: PluginConfig) => string[];
runGh: typeof tryRunGh;
}
function resolveAoConfigPath(config: PluginConfig): string | null {
const candidates: string[] = [];
const envPath = process.env.AO_CONFIG_PATH;
if (envPath) candidates.push(resolve(envPath));
let currentDir = resolve(config.aoCwd || process.cwd());
while (true) {
candidates.push(
join(currentDir, "agent-orchestrator.yaml"),
join(currentDir, "agent-orchestrator.yml"),
);
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
}
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null;
}
function stripYamlInlineComment(value: string): string {
let inSingleQuote = false;
let inDoubleQuote = false;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (char === "#" && !inSingleQuote && !inDoubleQuote) {
return value.slice(0, i).trim();
}
}
return value.trim();
}
function normalizeYamlScalar(value: string): string {
const stripped = stripYamlInlineComment(value);
if (!stripped) return "";
if (
(stripped.startsWith('"') && stripped.endsWith('"')) ||
(stripped.startsWith("'") && stripped.endsWith("'"))
) {
return stripped.slice(1, -1).trim();
}
return stripped;
}
export function extractConfiguredReposFromYaml(rawYaml: string): string[] {
const repos = new Set<string>();
const lines = rawYaml.split(/\r?\n/);
let inProjects = false;
// Detected at runtime from the first project entry line — not hardcoded.
let projectKeyIndent: number | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const indent = line.match(/^ */)?.[0].length ?? 0;
if (!inProjects) {
if (trimmed === "projects:" && indent === 0) {
inProjects = true;
}
continue;
}
// Any top-level key after projects: ends the block.
if (indent === 0) break;
// Detect the indentation level of project name keys from the first entry.
if (projectKeyIndent === null) {
if (trimmed.endsWith(":")) projectKeyIndent = indent;
continue;
}
// Lines at the project-key indent are project names — skip them.
if (indent === projectKeyIndent) continue;
// Lines indented deeper than the project key are project properties.
if (indent > projectKeyIndent) {
const match = trimmed.match(/^repo:\s*(.+)$/);
if (!match) continue;
const repo = normalizeYamlScalar(match[1]);
if (repo) repos.add(repo);
}
}
return [...repos];
}
function getConfiguredRepos(config: PluginConfig): string[] {
const configPath = resolveAoConfigPath(config);
if (!configPath) return [];
try {
return JSON.parse(result.output) as GitHubIssue[];
const rawYaml = readFileSync(configPath, "utf-8");
return extractConfiguredReposFromYaml(rawYaml);
} catch {
return [];
}
}
function getIssueRepository(issue: GitHubIssue): string | null {
if (issue.repository) return issue.repository;
const match = issue.url.match(/github\.com\/([^/]+\/[^/]+)\/issues\//);
return match?.[1] ?? null;
}
function getIssueIdentity(issue: GitHubIssue): string {
return issue.url || `${getIssueRepository(issue) ?? "default"}#${issue.number}`;
}
function formatIssueWarnings(warnings: string[]): string {
return warnings.map((warning) => `- ${warning}`).join("\n");
}
export function mergeStringLists(existing: string[], required: string[]): string[] {
const merged = [...existing];
for (const value of required) {
if (!merged.includes(value)) merged.push(value);
}
return merged;
}
export function parseStringArraySetting(output: string): string[] | null {
const trimmed = output.trim();
if (!trimmed) return [];
if (trimmed === "null" || trimmed === "undefined") return [];
try {
const parsed = JSON.parse(trimmed);
if (parsed == null) return [];
if (Array.isArray(parsed)) {
return parsed.filter((value): value is string => typeof value === "string");
}
if (typeof parsed === "string") {
return parsed ? [parsed] : [];
}
} catch {
// Fall through to plain-text parsing
}
if (trimmed.includes("\n")) {
return trimmed
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
if (trimmed.includes(",")) {
return trimmed
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
return [trimmed];
}
function getNestedValue(root: unknown, path: string[]): unknown {
let current = root;
for (const segment of path) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function readOpenClawConfig(): Record<string, unknown> | null {
try {
const configPath = join(homedir(), ".openclaw", "openclaw.json");
if (!existsSync(configPath)) return {};
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
return null;
}
}
function readOpenClawStringArraySetting(setting: string, path: string[]): string[] {
const cliResult = tryRun("openclaw", ["config", "get", setting], 5_000);
if (cliResult.ok) {
const parsed = parseStringArraySetting(cliResult.output);
if (parsed) return parsed;
}
const openClawConfig = readOpenClawConfig();
if (!openClawConfig) return [];
const nestedValue = getNestedValue(openClawConfig, path);
if (Array.isArray(nestedValue)) {
return nestedValue.filter((value): value is string => typeof value === "string");
}
if (typeof nestedValue === "string" && nestedValue) {
return [nestedValue];
}
return [];
}
export function fetchIssues(
config: PluginConfig,
options: FetchIssuesOptions = {},
deps: FetchIssuesDeps = {
getConfiguredRepos,
runGh: tryRunGh,
},
): FetchIssuesResult {
const repos = options.repo ? [options.repo] : deps.getConfiguredRepos(config);
const targets = repos.length > 0 ? repos : [undefined];
const issues: GitHubIssue[] = [];
const warnings: string[] = [];
const scannedRepos: string[] = [];
for (const targetRepo of targets) {
const args = ["issue", "list"];
const repoLabel = targetRepo ?? "default repo";
if (targetRepo) args.push("-R", targetRepo);
if (options.labels) args.push("--label", options.labels);
args.push(
"--state",
"open",
"--json",
"number,title,labels,state,assignees,createdAt,url",
"--limit",
"30",
);
const result = deps.runGh(config, args, 15_000);
if (!result.ok) {
warnings.push(`${repoLabel}: ${result.error}`);
continue;
}
try {
const parsed = JSON.parse(result.output) as GitHubIssue[];
for (const issue of parsed) {
issue.repository = targetRepo ?? getIssueRepository(issue) ?? undefined;
issues.push(issue);
}
if (targetRepo) scannedRepos.push(targetRepo);
} catch {
warnings.push(`${repoLabel}: failed to parse GitHub CLI output`);
}
}
const dedupedIssues = issues
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.filter(
(issue, index, allIssues) =>
allIssues.findIndex(
(candidate) => getIssueIdentity(candidate) === getIssueIdentity(issue),
) === index,
);
const inferredRepos = dedupedIssues
.map((issue) => getIssueRepository(issue))
.filter((repo): repo is string => Boolean(repo));
if (warnings.length > 0 && dedupedIssues.length === 0) {
return {
ok: false,
error: `GitHub issue query failed:\n${formatIssueWarnings(warnings)}`,
};
}
return {
ok: true,
issues: dedupedIssues,
scannedRepos: [
...new Set(
scannedRepos.length > 0 ? scannedRepos : inferredRepos.length > 0 ? inferredRepos : repos,
),
],
warnings,
};
}
function formatIssueList(issues: GitHubIssue[]): string {
if (issues.length === 0) return "No open issues found.";
const repoLabels = new Set(issues.map((issue) => getIssueRepository(issue)).filter(Boolean));
const includeRepository = repoLabels.size > 1;
return issues
.map((issue, i) => {
const labels = issue.labels.map((l) => l.name).join(", ");
const labelStr = labels ? ` [${labels}]` : "";
return `${i + 1}. #${issue.number}${issue.title}${labelStr}`;
const repoPrefix = includeRepository
? `${getIssueRepository(issue) ?? issue.repository ?? "unknown"}#${issue.number}`
: `#${issue.number}`;
return `${i + 1}. ${repoPrefix}${issue.title}${labelStr}`;
})
.join("\n");
}
@ -142,17 +449,40 @@ async function spawnWithRetry(
// ---------------------------------------------------------------------------
const WORK_TRIGGERS = [
"what needs", "what should i", "what do i need",
"start working", "morning", "let's go", "lets go",
"what's going on", "whats going on", "status update",
"check my repos", "check my issues", "check issues",
"any issues", "what's on the board", "whats on the board",
"what can i work on", "what to work on", "work on today",
"what's open", "whats open", "open issues",
"scan my repos", "scan repos", "scan issues",
"engineering update", "dev update", "project update",
"anything to do", "what's pending", "whats pending",
"ready to work", "what's the plan", "whats the plan",
"what needs",
"what should i",
"what do i need",
"start working",
"morning",
"let's go",
"lets go",
"what's going on",
"whats going on",
"status update",
"check my repos",
"check my issues",
"check issues",
"any issues",
"what's on the board",
"whats on the board",
"what can i work on",
"what to work on",
"work on today",
"what's open",
"whats open",
"open issues",
"scan my repos",
"scan repos",
"scan issues",
"engineering update",
"dev update",
"project update",
"anything to do",
"what's pending",
"whats pending",
"ready to work",
"what's the plan",
"whats the plan",
];
function isWorkRelated(message: string): boolean {
@ -179,13 +509,22 @@ export default function (api: any) {
/** Build a live-data context block from AO + GitHub */
function buildLiveContext(): string | null {
try {
const issues = fetchIssues(config);
const issuesResult = fetchIssues(config);
const sessionsResult = tryRunAo(config, ["status"], 10_000);
const issuesSummary =
issues.length > 0
? `Open issues (${issues.length}):\n${formatIssueList(issues)}`
: "No open issues across your repos.";
const issuesSummary = !issuesResult.ok
? issuesResult.error
: issuesResult.issues.length > 0
? [
`Open issues (${issuesResult.issues.length}${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}):`,
formatIssueList(issuesResult.issues),
issuesResult.warnings.length > 0
? `GitHub warnings:\n${formatIssueWarnings(issuesResult.warnings)}`
: null,
]
.filter(Boolean)
.join("\n")
: `No open issues found${issuesResult.scannedRepos.length > 1 ? ` across ${issuesResult.scannedRepos.length} repos` : ""}.`;
const sessionsSummary = sessionsResult.ok
? `Active sessions:\n${sessionsResult.output}`
@ -228,11 +567,7 @@ export default function (api: any) {
// Hook 1: message_received — detect work-related inbound messages
const onMessageReceived = async (event: any) => {
const message =
event?.message?.text ||
event?.message?.content ||
event?.text ||
event?.content ||
"";
event?.message?.text || event?.message?.content || event?.text || event?.content || "";
if (isWorkRelated(message)) {
cleanStalePending();
@ -249,9 +584,9 @@ export default function (api: any) {
// Not a command — just context so the model can make an informed choice.
const routingContext = [
"[Agent Orchestrator] This project has AO installed — an open-source orchestrator " +
"for durable coding agents (Claude Code, Codex, OpenCode). ao_spawn creates an " +
"isolated git worktree, starts an agent, and wires up feedback loops so PR reviews " +
"and CI failures automatically route to the right agent.",
"for durable coding agents (Claude Code, Codex, OpenCode). ao_spawn creates an " +
"isolated git worktree, starts an agent, and wires up feedback loops so PR reviews " +
"and CI failures automatically route to the right agent.",
];
// If this is a work-related message, also inject live repo data
@ -336,21 +671,34 @@ export default function (api: any) {
case "spawn": {
if (!rest) return { text: "Usage: /ao spawn <issue-number>" };
const issueArg = sanitizeArg(rest.split(/\s+/)[0]);
if (!isValidIssueId(issueArg)) return { text: `Invalid issue identifier: ${issueArg}. Expected a number like 42 or #42.` };
if (!isValidIssueId(issueArg))
return {
text: `Invalid issue identifier: ${issueArg}. Expected a number like 42 or #42.`,
};
const result = await spawnWithRetry(config, ["spawn", issueArg]);
if (!result.ok) return { text: `Failed to spawn:\n${result.error}` };
return { text: result.output };
}
case "issues": {
const issues = fetchIssues(config, rest || undefined);
return { text: formatIssueList(issues) };
const issuesResult = fetchIssues(config, { repo: rest || undefined });
if (!issuesResult.ok) return { text: issuesResult.error };
const lines = [formatIssueList(issuesResult.issues)];
if (issuesResult.warnings.length > 0) {
lines.push("");
lines.push("GitHub warnings:");
lines.push(formatIssueWarnings(issuesResult.warnings));
}
return { text: lines.join("\n") };
}
case "batch-spawn": {
if (!rest) return { text: "Usage: /ao batch-spawn <issue1> <issue2> ..." };
const issueArgs = rest.split(/\s+/).map(sanitizeArg);
if (!issueArgs.every(isValidIssueId)) return { text: `Invalid issue identifiers. Expected numbers like: 42 43 44` };
if (!issueArgs.every(isValidIssueId))
return { text: `Invalid issue identifiers. Expected numbers like: 42 43 44` };
const result = tryRunAo(config, ["batch-spawn", ...issueArgs], 60_000);
if (!result.ok) return { text: `Failed to batch-spawn:\n${result.error}` };
return { text: result.output };
@ -359,7 +707,8 @@ export default function (api: any) {
case "retry": {
if (!rest) return { text: "Usage: /ao retry <session-id>" };
const sessionId = sanitizeArg(rest.trim());
if (!isValidSessionId(sessionId)) return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` };
if (!isValidSessionId(sessionId))
return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` };
const result = tryRunAo(config, ["send", sessionId, "Please retry the failed task."]);
if (!result.ok) return { text: `Failed to send retry:\n${result.error}` };
return { text: `Retry sent to session ${sessionId}.` };
@ -368,7 +717,8 @@ export default function (api: any) {
case "kill": {
if (!rest) return { text: "Usage: /ao kill <session-id>" };
const sessionId = sanitizeArg(rest.trim());
if (!isValidSessionId(sessionId)) return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` };
if (!isValidSessionId(sessionId))
return { text: `Invalid session ID: ${rest}. Expected format like ao-42.` };
const result = tryRunAo(config, ["session", "kill", sessionId]);
if (!result.ok) return { text: `Failed to kill session:\n${result.error}` };
return { text: `Session ${sessionId} killed.` };
@ -398,14 +748,31 @@ export default function (api: any) {
else steps.push("❌ Failed to set tools.profile");
// 2. Allow plugin tools
if (runSetup("openclaw", ["config", "set", "tools.allow", '["group:plugins"]']))
steps.push("✅ tools.allow → group:plugins");
else steps.push("❌ Failed to set tools.allow");
const mergedToolsAllow = mergeStringLists(
readOpenClawStringArraySetting("tools.allow", ["tools", "allow"]),
["group:plugins"],
);
if (
runSetup("openclaw", ["config", "set", "tools.allow", JSON.stringify(mergedToolsAllow)])
) {
steps.push(`✅ tools.allow → ${mergedToolsAllow.join(", ")}`);
} else steps.push("❌ Failed to set tools.allow");
// 3. Trust the plugin
if (runSetup("openclaw", ["config", "set", "plugins.allow", '["agent-orchestrator"]']))
steps.push("✅ plugins.allow → agent-orchestrator");
else steps.push("❌ Failed to set plugins.allow");
const mergedPluginsAllow = mergeStringLists(
readOpenClawStringArraySetting("plugins.allow", ["plugins", "allow"]),
["agent-orchestrator"],
);
if (
runSetup("openclaw", [
"config",
"set",
"plugins.allow",
JSON.stringify(mergedPluginsAllow),
])
) {
steps.push(`✅ plugins.allow → ${mergedPluginsAllow.join(", ")}`);
} else steps.push("❌ Failed to set plugins.allow");
// 4. Group chat settings
if (runSetup("openclaw", ["config", "set", "messages.groupChat.historyLimit", "100"]))
@ -415,6 +782,12 @@ export default function (api: any) {
steps.push("");
steps.push("⚡ Restart the gateway to apply: pm2 restart openclaw-gateway");
steps.push("Then verify with: /ao doctor");
steps.push("");
steps.push("⚠️ Action required — run these once to avoid conflicts:");
steps.push(" openclaw config set skills.entries.coding-agent.enabled false");
steps.push(" openclaw config set skills.entries.gh-issues.enabled false");
steps.push(' openclaw config set tools.deny \'["exec","write","str_replace_based_edit_tool","create_file","str_replace_editor"]\'');
steps.push("Without these, the bot may code directly instead of delegating to AO.");
return { text: `AO Plugin Setup\n\n${steps.join("\n")}` };
}
@ -482,22 +855,22 @@ export default function (api: any) {
required: [],
},
async execute(_toolCallId: string, params: { repo?: string; labels?: string }) {
const ghArgs = ["issue", "list"];
if (params.repo) ghArgs.push("-R", params.repo);
if (params.labels) ghArgs.push("--label", params.labels);
ghArgs.push("--state", "open", "--json", "number,title,labels,state,assignees,createdAt,url", "--limit", "30");
const result = tryRunGh(
config,
ghArgs,
15_000,
);
const result = fetchIssues(config, params);
if (!result.ok) {
return {
content: [{ type: "text", text: `Failed to fetch issues: ${result.error}` }],
content: [{ type: "text", text: result.error }],
isError: true,
};
}
return { content: [{ type: "text", text: result.output }] };
const lines = [formatIssueList(result.issues)];
if (result.warnings.length > 0) {
lines.push("");
lines.push("GitHub warnings:");
lines.push(formatIssueWarnings(result.warnings));
}
return { content: [{ type: "text", text: lines.join("\n") }] };
},
});
@ -511,10 +884,20 @@ export default function (api: any) {
parameters: {
type: "object",
properties: {
issue: { type: "string", description: "Issue identifier (e.g. #42). Optional — omit for freeform tasks, then use ao_send to describe the work." },
issue: {
type: "string",
description:
"Issue identifier (e.g. #42). Optional — omit for freeform tasks, then use ao_send to describe the work.",
},
agent: { type: "string", description: "Override agent plugin (e.g. codex, claude-code)" },
claimPr: { type: "string", description: "Immediately claim an existing PR number for the session" },
decompose: { type: "boolean", description: "Decompose issue into subtasks before spawning" },
claimPr: {
type: "string",
description: "Immediately claim an existing PR number for the session",
},
decompose: {
type: "boolean",
description: "Decompose issue into subtasks before spawning",
},
},
},
async execute(
@ -541,7 +924,8 @@ export default function (api: any) {
}
const spawnOutput = params.issue
? result.output
: result.output + "\n\nNote: This is a freeform session (no issue). Use ao_send to describe the task to the agent.";
: result.output +
"\n\nNote: This is a freeform session (no issue). Use ao_send to describe the task to the agent.";
return { content: [{ type: "text", text: spawnOutput }] };
},
});
@ -586,8 +970,12 @@ export default function (api: any) {
}
};
batchSpawnFollowUpTimeouts.push(setTimeout(() => checkStatus("Progress check (3 min)"), 3 * 60_000));
batchSpawnFollowUpTimeouts.push(setTimeout(() => checkStatus("Status update (8 min)"), 8 * 60_000));
batchSpawnFollowUpTimeouts.push(
setTimeout(() => checkStatus("Progress check (3 min)"), 3 * 60_000),
);
batchSpawnFollowUpTimeouts.push(
setTimeout(() => checkStatus("Status update (8 min)"), 8 * 60_000),
);
api.logger.info("[ao-batch] Scheduled auto follow-ups at 3min and 8min");
@ -697,7 +1085,9 @@ export default function (api: any) {
isError: true,
};
}
return { content: [{ type: "text", text: result.output || "No review comments to address." }] };
return {
content: [{ type: "text", text: result.output || "No review comments to address." }],
};
},
});
@ -720,7 +1110,13 @@ export default function (api: any) {
},
async execute(
_toolCallId: string,
params: { issue?: string; project?: string; fail?: boolean; comment?: string; list?: boolean },
params: {
issue?: string;
project?: string;
fail?: boolean;
comment?: string;
list?: boolean;
},
) {
const args = ["verify"];
if (params.list) {
@ -729,7 +1125,12 @@ export default function (api: any) {
} else {
if (!params.issue) {
return {
content: [{ type: "text", text: "Need an issue number. Use list: true to see unverified issues." }],
content: [
{
type: "text",
text: "Need an issue number. Use list: true to see unverified issues.",
},
],
isError: true,
};
}
@ -811,7 +1212,10 @@ export default function (api: any) {
properties: {
pr: { type: "string", description: "Pull request number or URL" },
sessionId: { type: "string", description: "Session name (optional)" },
assignOnGithub: { type: "boolean", description: "Assign the PR to the authenticated GitHub user" },
assignOnGithub: {
type: "boolean",
description: "Assign the PR to the authenticated GitHub user",
},
},
required: ["pr"],
},
@ -895,7 +1299,7 @@ export default function (api: any) {
let boardScanInterval: ReturnType<typeof setInterval> | null = null;
let boardScanInitialTimeout: ReturnType<typeof setTimeout> | null = null;
const batchSpawnFollowUpTimeouts: ReturnType<typeof setTimeout>[] = [];
let lastKnownIssueIds: Set<number> = new Set();
let lastKnownIssueIds: Set<string> = new Set();
let isFirstBoardScan = true;
// --- Health monitor ---
@ -936,8 +1340,19 @@ export default function (api: any) {
const scan = () => {
try {
const issues = fetchIssues(config);
const currentIds = new Set(issues.map((i) => i.number));
const issuesResult = fetchIssues(config);
if (!issuesResult.ok) {
api.logger.warn(`[ao-board-scanner] ${issuesResult.error}`);
return;
}
if (issuesResult.warnings.length > 0) {
api.logger.warn(
`[ao-board-scanner] Partial GitHub failures:\n${formatIssueWarnings(issuesResult.warnings)}`,
);
}
const currentIds = new Set(issuesResult.issues.map((issue) => getIssueIdentity(issue)));
if (isFirstBoardScan) {
lastKnownIssueIds = currentIds;
@ -946,15 +1361,12 @@ export default function (api: any) {
return;
}
const newIssues = issues.filter((i) => !lastKnownIssueIds.has(i.number));
const newIssues = issuesResult.issues.filter(
(issue) => !lastKnownIssueIds.has(getIssueIdentity(issue)),
);
if (newIssues.length > 0) {
const summary = newIssues
.map((i) => {
const labels = i.labels.map((l) => l.name).join(", ");
return `#${i.number}${i.title}${labels ? ` [${labels}]` : ""}`;
})
.join("\n");
const summary = formatIssueList(newIssues);
api.logger.info(`[ao-board-scanner] ${newIssues.length} new issue(s)`);

View File

@ -6,6 +6,7 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"required": ["aoCwd"],
"properties": {
"aoPath": {
"type": "string",

View File

@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Command } from "commander";
import { homedir } from "node:os";
import { join } from "node:path";
// ---------------------------------------------------------------------------
// Hoisted mocks — must be defined before any imports that use them
@ -9,9 +11,11 @@ const { mockFindConfigFile } = vi.hoisted(() => ({
mockFindConfigFile: vi.fn(),
}));
const { mockReadFileSync, mockWriteFileSync } = vi.hoisted(() => ({
const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({
mockReadFileSync: vi.fn(),
mockWriteFileSync: vi.fn(),
mockExistsSync: vi.fn(),
mockMkdirSync: vi.fn(),
}));
const { mockProbeGateway, mockValidateToken } = vi.hoisted(() => ({
@ -29,7 +33,8 @@ vi.mock("node:fs", async (importOriginal) => {
...actual,
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
existsSync: () => true,
existsSync: (...args: unknown[]) => mockExistsSync(...args),
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
};
});
@ -93,6 +98,8 @@ describe("setup openclaw command", () => {
mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml");
mockReadFileSync.mockReturnValue(MINIMAL_CONFIG);
mockWriteFileSync.mockImplementation(() => {});
mockExistsSync.mockReturnValue(false);
mockMkdirSync.mockImplementation(() => undefined);
mockValidateToken.mockResolvedValue({ valid: true });
mockProbeGateway.mockResolvedValue({ reachable: false });
@ -109,9 +116,14 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "test-token",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"test-token",
"--non-interactive",
]);
@ -128,8 +140,12 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--non-interactive",
]);
@ -143,8 +159,12 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--token",
"tok",
"--non-interactive",
]);
@ -153,13 +173,37 @@ describe("setup openclaw command", () => {
expect(mockWriteFileSync).toHaveBeenCalled();
});
it("normalizes OPENCLAW_GATEWAY_URL without double-appending hooks path", async () => {
process.env["OPENCLAW_GATEWAY_URL"] = "http://remote:18789/hooks/agent";
const program = createProgram();
await program.parseAsync([
"node",
"test",
"setup",
"openclaw",
"--token",
"tok",
"--non-interactive",
]);
const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
expect(writtenYaml).toContain("url: http://remote:18789/hooks/agent");
expect(writtenYaml).not.toContain("/hooks/agent/hooks/agent");
});
it("skips token validation and writes config in non-interactive mode", async () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "good-token",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"good-token",
"--non-interactive",
]);
@ -175,9 +219,14 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"tok",
"--non-interactive",
]);
@ -192,28 +241,37 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"tok",
"--non-interactive",
]);
const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
const matches = writtenYaml.match(/openclaw/g);
// "openclaw" appears in: defaults.notifiers list, notifiers.openclaw key,
// plugin: openclaw, and the token ref. Should not have extra duplicates.
// Count just in defaults section
const defaultsSection = writtenYaml.split("notifiers:")[0] + writtenYaml.split("notifiers:")[1];
expect(defaultsSection).toBeDefined();
// plugin: openclaw, and the token ref. Verify it appears as a list item exactly once
// (i.e. no duplication in defaults.notifiers).
const listItemMatches = writtenYaml.match(/^\s+- openclaw$/gm);
expect(listItemMatches).toHaveLength(1);
});
it("writes correct notifier block structure", async () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://custom:9999/hooks/agent",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://custom:9999/hooks/agent",
"--token",
"tok",
"--non-interactive",
]);
@ -226,13 +284,76 @@ describe("setup openclaw command", () => {
expect(writtenYaml).toContain("wakeMode: now");
});
it("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => {
const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json");
mockExistsSync.mockImplementation((path: string) => path === openclawConfigPath);
mockReadFileSync.mockImplementation((path: string) => {
if (path === "/tmp/agent-orchestrator.yaml") {
return MINIMAL_CONFIG;
}
if (path === openclawConfigPath) {
return JSON.stringify({
hooks: {
enabled: false,
token: "old-token",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["legacy:", "hook:"],
},
otherConfig: true,
});
}
return "";
});
const program = createProgram();
await program.parseAsync([
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"new-token",
"--non-interactive",
]);
const openclawWrite = mockWriteFileSync.mock.calls.find(
([path]) => path === openclawConfigPath,
);
expect(openclawWrite).toBeDefined();
const writtenJson = JSON.parse(openclawWrite![1] as string) as {
hooks: {
token: string;
enabled: boolean;
allowRequestSessionKey: boolean;
allowedSessionKeyPrefixes: string[];
};
otherConfig: boolean;
};
expect(writtenJson.otherConfig).toBe(true);
expect(writtenJson.hooks.token).toBe("new-token");
expect(writtenJson.hooks.enabled).toBe(true);
expect(writtenJson.hooks.allowRequestSessionKey).toBe(true);
expect(writtenJson.hooks.allowedSessionKeyPrefixes).toEqual(["legacy:", "hook:"]);
});
it("preserves existing projects in config", async () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"tok",
"--non-interactive",
]);
@ -246,9 +367,14 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"tok",
"--non-interactive",
]);
@ -267,9 +393,14 @@ describe("setup openclaw command", () => {
await expect(
program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"tok",
"--non-interactive",
]),
).rejects.toThrow("process.exit");
@ -283,9 +414,14 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"--token", "bad-token",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--token",
"bad-token",
"--non-interactive",
]);
@ -302,8 +438,12 @@ describe("setup openclaw command", () => {
await expect(
program.parseAsync([
"node", "test", "setup", "openclaw",
"--token", "tok",
"node",
"test",
"setup",
"openclaw",
"--token",
"tok",
"--non-interactive",
]),
).rejects.toThrow("process.exit");
@ -316,8 +456,12 @@ describe("setup openclaw command", () => {
const program = createProgram();
await program.parseAsync([
"node", "test", "setup", "openclaw",
"--url", "http://127.0.0.1:18789/hooks/agent",
"node",
"test",
"setup",
"openclaw",
"--url",
"http://127.0.0.1:18789/hooks/agent",
"--non-interactive",
]);

View File

@ -199,7 +199,7 @@ export function registerDoctor(program: Command): void {
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
warn(`Could not load config for notifier checks: ${message}`);
warn(`Notifier checks failed: ${message}`);
}
} else if (opts.testNotify) {
fail("No config file found. Cannot test notifiers without agent-orchestrator.yaml");

View File

@ -27,7 +27,10 @@ import {
// ---------------------------------------------------------------------------
export class SetupAbortedError extends Error {
constructor(message: string, public readonly exitCode: number = 1) {
constructor(
message: string,
public readonly exitCode: number = 1,
) {
super(message);
this.name = "SetupAbortedError";
}
@ -44,6 +47,11 @@ interface ResolvedConfig {
token: string;
}
function normalizeOpenClawHooksUrl(url: string): string {
const normalized = url.trim().replace(/\/+$/, "");
return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`;
}
// ---------------------------------------------------------------------------
// Interactive prompts (dynamic import to keep @clack/prompts optional)
// ---------------------------------------------------------------------------
@ -85,10 +93,7 @@ async function interactiveSetup(existingUrl?: string): Promise<ResolvedConfig> {
}
// Normalize: ensure URL ends with /hooks/agent
let url = urlInput as string;
if (!url.endsWith(HOOKS_PATH)) {
url = url.replace(/\/+$/, "") + HOOKS_PATH;
}
const url = normalizeOpenClawHooksUrl(urlInput as string);
// --- Step 2: Token ---------------------------------------------------------
const envToken = process.env["OPENCLAW_HOOKS_TOKEN"];
@ -177,30 +182,23 @@ async function interactiveSetup(existingUrl?: string): Promise<ResolvedConfig> {
// ---------------------------------------------------------------------------
async function nonInteractiveSetup(opts: SetupOptions): Promise<ResolvedConfig> {
let url =
opts.url ??
(process.env["OPENCLAW_GATEWAY_URL"]
? `${process.env["OPENCLAW_GATEWAY_URL"]}${HOOKS_PATH}`
: undefined);
const rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"];
const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"];
if (!url) {
if (!rawUrl) {
throw new SetupAbortedError(
"Error: --url is required in non-interactive mode.\n" +
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive",
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive",
);
}
let url = rawUrl;
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new SetupAbortedError(
"Error: --url must start with http:// or https://",
);
throw new SetupAbortedError("Error: --url must start with http:// or https://");
}
// Normalize: ensure URL ends with /hooks/agent
if (!url.endsWith(HOOKS_PATH)) {
url = url.replace(/\/+$/, "") + HOOKS_PATH;
}
url = normalizeOpenClawHooksUrl(url);
const resolvedToken = token ?? randomBytes(32).toString("base64url");
if (!token) {
@ -230,9 +228,10 @@ function writeOpenClawConfig(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawConfig = (doc.toJS() as Record<string, any>) ?? {};
// Add notifiers.openclaw block — write the env-var placeholder so the raw
// token is never committed to version control. ao setup openclaw exports the
// real value to the shell profile; runtime reads it via the env var.
// Write the env-var placeholder so the raw token is never committed to
// version control. ao setup openclaw exports the real value to the shell
// profile; the notifier plugin resolves it at runtime (env var → openclaw.json
// fallback for daemon contexts where the shell profile isn't sourced).
if (!rawConfig.notifiers) rawConfig.notifiers = {};
rawConfig.notifiers.openclaw = {
plugin: "openclaw",
@ -303,14 +302,22 @@ function writeOpenClawJsonConfig(token: string): boolean {
}
// Merge the hooks block (preserve other existing keys in hooks if any)
const existingHooks =
(config.hooks as Record<string, unknown> | undefined) ?? {};
const existingHooks = (config.hooks as Record<string, unknown> | undefined) ?? {};
const existingPrefixes = Array.isArray(existingHooks.allowedSessionKeyPrefixes)
? existingHooks.allowedSessionKeyPrefixes.filter(
(prefix): prefix is string => typeof prefix === "string",
)
: [];
const allowedSessionKeyPrefixes = existingPrefixes.includes("hook:")
? existingPrefixes
: [...existingPrefixes, "hook:"];
config.hooks = {
...existingHooks,
enabled: true,
token,
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:"],
allowedSessionKeyPrefixes,
};
writeFileSync(openclawJsonPath, JSON.stringify(config, null, 2) + "\n");
@ -337,15 +344,15 @@ function writeShellExport(token: string): string | undefined {
const safeToken = token.replace(/'/g, "'\\''");
const exportLine = `export OPENCLAW_HOOKS_TOKEN='${safeToken}'`;
// Check if it already exists
// Check if it already exists (use the same regex for detection and replacement
// to avoid silent no-ops when the line is commented, lacks the export prefix,
// or has leading whitespace)
const existingExportRegex = /^\s*(?:export\s+)?OPENCLAW_HOOKS_TOKEN=.*$/m;
if (existsSync(profilePath)) {
const content = readFileSync(profilePath, "utf-8");
if (content.includes("OPENCLAW_HOOKS_TOKEN=")) {
if (existingExportRegex.test(content)) {
// Replace the existing line
const updated = content.replace(
/^export OPENCLAW_HOOKS_TOKEN=.*$/m,
exportLine,
);
const updated = content.replace(existingExportRegex, exportLine);
writeFileSync(profilePath, updated);
return profilePath;
}
@ -353,11 +360,9 @@ function writeShellExport(token: string): string | undefined {
// Append
const prefix = existsSync(profilePath) ? "\n" : "";
writeFileSync(
profilePath,
`${prefix}# Added by ao setup openclaw\n${exportLine}\n`,
{ flag: "a" },
);
writeFileSync(profilePath, `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, {
flag: "a",
});
return profilePath;
} catch {
return undefined;
@ -372,7 +377,9 @@ function printOpenClawInstructions(
if (openclawConfigWritten) {
// Both configs written automatically
if (nonInteractive) {
console.log(chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)"));
console.log(
chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)"),
);
if (shellProfilePath) {
console.log(chalk.green(`✓ OPENCLAW_HOOKS_TOKEN exported in ${shellProfilePath}`));
}
@ -391,7 +398,7 @@ function printOpenClawInstructions(
const instructions = `
${chalk.bold("OpenClaw-side config required")}
Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}):
AO config was written successfully. Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}):
${chalk.cyan(`{
"hooks": {
@ -401,21 +408,15 @@ Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}):
"allowedSessionKeyPrefixes": ["hook:"]
}
}`)}
Then set the token as an environment variable for AO:
${chalk.cyan('export OPENCLAW_HOOKS_TOKEN="<your-token>"')}
Add it to your shell profile (~/.zshrc or ~/.bashrc) to persist.`;
`;
if (nonInteractive) {
console.log("\nOpenClaw-side config required:");
console.log('Add to ~/.openclaw/openclaw.json:');
console.log(' hooks.enabled: true');
console.log("AO config was written successfully. Add to ~/.openclaw/openclaw.json:");
console.log(" hooks.enabled: true");
console.log(' hooks.token: "<your-token>"');
console.log(' hooks.allowRequestSessionKey: true');
console.log(" hooks.allowRequestSessionKey: true");
console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]');
console.log('\nSet env var: export OPENCLAW_HOOKS_TOKEN="<your-token>"');
} else {
console.log(instructions);
}
@ -427,16 +428,14 @@ Add it to your shell profile (~/.zshrc or ~/.bashrc) to persist.`;
// ---------------------------------------------------------------------------
export function registerSetup(program: Command): void {
const setup = program
.command("setup")
.description("Set up integrations with external services");
const setup = program.command("setup").description("Set up integrations with external services");
setup
.command("openclaw")
.description("Connect AO notifications to an OpenClaw gateway")
.option("--url <url>", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)")
.option("--token <token>", "OpenClaw hooks auth token")
.option("--non-interactive", "Skip prompts — requires --url and --token (or env vars)")
.option("--non-interactive", "Skip prompts — requires --url (token auto-generated if not provided)")
.action(async (opts: SetupOptions) => {
try {
await runSetupAction(opts);
@ -451,79 +450,79 @@ export function registerSetup(program: Command): void {
}
async function runSetupAction(opts: SetupOptions): Promise<void> {
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
// --- Find existing config ------------------------------------------------
let configPath: string | undefined;
try {
const found = findConfigFile();
configPath = found ?? undefined;
} catch {
// no config found
}
// --- Find existing config ------------------------------------------------
let configPath: string | undefined;
try {
const found = findConfigFile();
configPath = found ?? undefined;
} catch {
// no config found
}
if (!configPath) {
throw new SetupAbortedError(
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
);
}
if (!configPath) {
throw new SetupAbortedError(
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
);
}
// --- Check for existing openclaw config ----------------------------------
const rawYaml = readFileSync(configPath, "utf-8");
const rawConfig = yamlParse(rawYaml) ?? {};
const existingOpenClaw = rawConfig?.notifiers?.openclaw;
const existingUrl = existingOpenClaw?.url as string | undefined;
// --- Check for existing openclaw config ----------------------------------
const rawYaml = readFileSync(configPath, "utf-8");
const rawConfig = yamlParse(rawYaml) ?? {};
const existingOpenClaw = rawConfig?.notifiers?.openclaw;
const existingUrl = existingOpenClaw?.url as string | undefined;
if (existingOpenClaw && !nonInteractive) {
const clack = await import("@clack/prompts");
const reconfigure = await clack.confirm({
message: "OpenClaw is already configured. Reconfigure?",
initialValue: false,
});
if (existingOpenClaw && !nonInteractive) {
const clack = await import("@clack/prompts");
const reconfigure = await clack.confirm({
message: "OpenClaw is already configured. Reconfigure?",
initialValue: false,
});
if (clack.isCancel(reconfigure) || !reconfigure) {
console.log(chalk.dim("Keeping existing config."));
return;
}
}
if (clack.isCancel(reconfigure) || !reconfigure) {
console.log(chalk.dim("Keeping existing config."));
return;
}
}
// --- Run setup -----------------------------------------------------------
let resolved: ResolvedConfig;
// --- Run setup -----------------------------------------------------------
let resolved: ResolvedConfig;
if (nonInteractive) {
resolved = await nonInteractiveSetup(opts);
} else {
resolved = await interactiveSetup(existingUrl);
}
if (nonInteractive) {
resolved = await nonInteractiveSetup(opts);
} else {
resolved = await interactiveSetup(existingUrl);
}
// --- Write AO config -----------------------------------------------------
writeOpenClawConfig(configPath, resolved, nonInteractive);
// --- Write AO config -----------------------------------------------------
writeOpenClawConfig(configPath, resolved, nonInteractive);
// --- Write OpenClaw config -----------------------------------------------
const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token);
if (openclawConfigWritten && nonInteractive) {
console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json"));
}
// --- Write OpenClaw config -----------------------------------------------
const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token);
if (openclawConfigWritten && nonInteractive) {
console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json"));
}
// --- Write shell export --------------------------------------------------
const shellProfilePath = writeShellExport(resolved.token);
if (shellProfilePath && nonInteractive) {
console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`));
}
// --- Write shell export --------------------------------------------------
const shellProfilePath = writeShellExport(resolved.token);
if (shellProfilePath && nonInteractive) {
console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`));
}
// --- Print instructions --------------------------------------------------
printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath);
// --- Print instructions --------------------------------------------------
printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath);
// --- Done ----------------------------------------------------------------
if (!nonInteractive) {
const clack = await import("@clack/prompts");
clack.outro(
`${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` +
chalk.dim(" Run 'ao doctor' to verify the full setup.\n") +
chalk.dim(" Restart AO with 'ao stop && ao start' to activate."),
);
} else {
console.log(chalk.green("\n✓ OpenClaw setup complete."));
console.log(chalk.dim("Restart AO to activate: ao stop && ao start"));
}
// --- Done ----------------------------------------------------------------
if (!nonInteractive) {
const clack = await import("@clack/prompts");
clack.outro(
`${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` +
chalk.dim(" Run 'ao doctor' to verify the full setup.\n") +
chalk.dim(" Restart AO with 'ao stop && ao start' to activate."),
);
} else {
console.log(chalk.green("\n✓ OpenClaw setup complete."));
console.log(chalk.dim("Restart AO to activate: ao stop && ao start"));
}
}

View File

@ -22,7 +22,7 @@ defaults:
agent: claude-code # claude-code | aider | codex | opencode
workspace: worktree # worktree | clone
notifiers: # List of active notifier plugins
- desktop # desktop | slack | webhook | composio | openclaw
- desktop # desktop | discord | slack | webhook | composio | openclaw
orchestrator:
agent: claude-code # Agent for orchestrator sessions (optional override)
worker:

View File

@ -31,13 +31,14 @@
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
"clean": "rimraf dist"
},
"dependencies": {
"@composio/ao-core": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.2.3",
"rimraf": "^6.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}

View File

@ -195,7 +195,7 @@ describe("notifier-discord", () => {
});
it("handles 204 No Content as success", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 204 });
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" });

View File

@ -179,7 +179,7 @@ export function create(config?: Record<string, unknown>): Notifier {
// Discord requires thread_id as a URL query param, not in the JSON body
const effectiveUrl = webhookUrl && threadId
? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${threadId}`
? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${encodeURIComponent(threadId)}`
: webhookUrl;
function buildPayload(embeds: DiscordEmbed[]): Record<string, unknown> {

View File

@ -8,7 +8,7 @@ OpenClaw notifier plugin for AO escalation events.
ao setup openclaw
```
This interactive wizard auto-detects your OpenClaw gateway, validates the connection, and writes the config. For non-interactive use (e.g., from an OpenClaw agent):
This interactive wizard auto-detects your OpenClaw gateway, validates the connection, and writes the config. For non-interactive use (e.g., in CI/CD pipelines or automation scripts):
```bash
ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive

View File

@ -1,3 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
type EventPriority,
type Notifier,
@ -8,6 +11,25 @@ import {
} from "@composio/ao-core";
import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@composio/ao-core/utils";
/**
* Read the hooks token from ~/.openclaw/openclaw.json as a fallback for
* daemon contexts where the shell profile (and OPENCLAW_HOOKS_TOKEN) isn't
* sourced. This file is written by `ao setup openclaw` and lives outside
* the project directory so it's never committed to version control.
*/
function readTokenFromOpenClawConfig(): string | undefined {
try {
const configPath = join(homedir(), ".openclaw", "openclaw.json");
if (!existsSync(configPath)) return undefined;
const raw = readFileSync(configPath, "utf-8");
const config = JSON.parse(raw) as Record<string, unknown>;
const token = (config.hooks as Record<string, unknown> | undefined)?.token;
return typeof token === "string" && token ? token : undefined;
} catch {
return undefined;
}
}
export const manifest = {
name: "openclaw",
slot: "notifier" as const,
@ -55,8 +77,8 @@ async function postWithRetry(
if (response.status === 401 || response.status === 403) {
lastError = new Error(
`OpenClaw rejected the auth token (HTTP ${response.status}).\n` +
` Check that hooks.token in your OpenClaw config matches OPENCLAW_HOOKS_TOKEN.\n` +
` Reconfigure: ao setup openclaw`,
` Check that hooks.token in your OpenClaw config matches the token configured for AO.\n` +
` Reconfigure: ao setup openclaw`,
);
throw lastError;
}
@ -79,8 +101,8 @@ async function postWithRetry(
if (lastError.message.includes("ECONNREFUSED")) {
throw new Error(
`Can't reach OpenClaw gateway at ${url}.\n` +
` Is OpenClaw running? Check: openclaw status\n` +
` Wrong URL? Run: ao setup openclaw`,
` Is OpenClaw running? Check: openclaw status\n` +
` Wrong URL? Run: ao setup openclaw`,
);
}
@ -133,13 +155,26 @@ function formatActionsLine(actions: NotifyAction[]): string {
return `Actions available: ${labels}`;
}
/**
* Resolve a token value that may be a `${ENV_VAR}` placeholder (as written
* into agent-orchestrator.yaml by `ao setup openclaw`) or a literal string.
* Returns undefined for empty/unresolvable values so callers can chain `??`.
*/
function resolveEnvVarToken(raw: unknown): string | undefined {
if (typeof raw !== "string" || !raw) return undefined;
const match = raw.match(/^\$\{([^}]+)\}$/);
if (match) return process.env[match[1]] || undefined;
return raw;
}
export function create(config?: Record<string, unknown>): Notifier {
const url =
(typeof config?.url === "string" ? config.url : undefined) ??
"http://127.0.0.1:18789/hooks/agent";
const token =
(typeof config?.token === "string" ? config.token : undefined) ??
process.env.OPENCLAW_HOOKS_TOKEN;
resolveEnvVarToken(config?.token) ??
process.env.OPENCLAW_HOOKS_TOKEN ??
readTokenFromOpenClawConfig();
const senderName = typeof config?.name === "string" ? config.name : "AO";
const sessionKeyPrefix =
typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:";
@ -153,8 +188,8 @@ export function create(config?: Record<string, unknown>): Notifier {
if (!token) {
console.warn(
"[notifier-openclaw] No token configured.\n" +
" Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" +
" Run: ao setup openclaw",
" Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" +
" Run: ao setup openclaw",
);
}