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:
parent
4495e11b7c
commit
570f3b588f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"), []);
|
||||
});
|
||||
|
|
@ -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)`);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["aoCwd"],
|
||||
"properties": {
|
||||
"aoPath": {
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue