feat: add OpenClaw inventory planning
This commit is contained in:
parent
50ed866da1
commit
48740dab74
@ -230,10 +230,15 @@ Agents should use JSON plans and reports:
|
||||
|
||||
```sh
|
||||
node bin/kova.mjs plan --json
|
||||
node bin/kova.mjs inventory plan --openclaw-bin openclaw --openclaw-repo /path/to/openclaw --json
|
||||
node bin/kova.mjs matrix plan --profile smoke --target runtime:stable --json
|
||||
node bin/kova.mjs matrix run --profile smoke --target runtime:stable --execute --json
|
||||
```
|
||||
|
||||
`inventory plan` is planner-only. It compares discovered OpenClaw CLI commands,
|
||||
package scripts, and manifests with Kova surfaces and reports unmodeled
|
||||
capabilities as warnings.
|
||||
|
||||
Kova ships repo-local agent skills:
|
||||
|
||||
- `.agents/skills/kova-operator`
|
||||
|
||||
@ -35,11 +35,16 @@ node bin/kova.mjs self-check --json
|
||||
|
||||
```sh
|
||||
node bin/kova.mjs plan --json
|
||||
node bin/kova.mjs inventory plan --openclaw-bin openclaw --openclaw-repo /path/to/openclaw --json
|
||||
node bin/kova.mjs plan --scenario fresh-install --state missing-plugin-index --json
|
||||
node bin/kova.mjs matrix plan --profile smoke --target runtime:stable --json
|
||||
node bin/kova.mjs matrix plan --profile release --target runtime:stable --include tag:plugins --exclude state:broken-plugin-deps --json
|
||||
```
|
||||
|
||||
Use `inventory plan` when checking whether Kova is missing OpenClaw command,
|
||||
script, plugin, or extension surfaces. Treat unmodeled inventory entries as
|
||||
planning warnings until they are intentionally promoted to gate policy.
|
||||
|
||||
3. Dry-run the intended scenario:
|
||||
|
||||
```sh
|
||||
|
||||
@ -367,6 +367,20 @@ required state traits, required target kinds, and required metrics. Invalid
|
||||
obligations, such as a scenario proving an unknown requirement or a selected
|
||||
state that cannot satisfy the requirement, fail planning before execution.
|
||||
|
||||
`kova inventory plan --json` is planner-only and does not write a run report. It
|
||||
uses schema `kova.inventory.plan.v1` and includes:
|
||||
|
||||
- `sources`: whether OpenClaw help, package scripts, and manifests were scanned
|
||||
- `modeledSurfaces`: current Kova surfaces
|
||||
- `capabilities`: discovered CLI commands, package scripts, plugin manifests,
|
||||
and extension manifests with matched Kova surface ids when known
|
||||
- `coverage.warnings`: unmodeled or ambiguous discovered capabilities
|
||||
- `coverage.blockers`: selected missing or unmodeled capabilities when
|
||||
`--require-modeled <capability>` is used
|
||||
|
||||
Inventory warnings are discovery signal first. They do not block release gates
|
||||
until a later policy deliberately promotes them.
|
||||
|
||||
## Summary Output
|
||||
|
||||
`kova report summarize <report.json> --json` returns a compact agent-facing
|
||||
|
||||
@ -39,6 +39,7 @@ Usage:
|
||||
kova setup auth [--provider <id>] [--method <mock|api-key|env-only|external-cli|oauth|skip>] [--env-var <name>] [--value <secret>] [--fallback-policy <mock|external-cli|none>] [--json]
|
||||
kova self-check [--json]
|
||||
kova plan [--scenario <id>] [--json]
|
||||
kova inventory plan [--openclaw-bin <path>] [--openclaw-repo <path>] [--subcommands <a,b>] [--require-modeled <capability[,capability]>] [--max-subcommands <n>] [--timeout-ms <n>] [--json]
|
||||
kova run --target <selector> [--from <selector>] [--scenario <id>] [--state <id>] [--auth <mock|live|skip>] [--repeat <n>] [--baseline [path]] [--save-baseline [path] --reviewed-good] [--regression-thresholds <json>] [--report-dir <path>] [--health-samples <n>] [--readiness-interval-ms <n>] [--resource-sample-interval-ms <n>] [--deep-profile] [--node-profile] [--heap-snapshot] [--profile-on-failure] [--execute] [--keep-env] [--retain-on-failure] [--json]
|
||||
kova matrix plan --profile <id> --target <selector> [--from <selector>] [--include <filter>] [--exclude <filter>] [--parallel <n>] [--json]
|
||||
kova matrix run --profile <id> --target <selector> [--from <selector>] [--include <filter>] [--exclude <filter>] [--auth <mock|live|skip>] [--parallel <n>] [--repeat <n>] [--baseline [path]] [--save-baseline [path] --reviewed-good] [--regression-thresholds <json>] [--fail-fast] [--gate] [--report-dir <path>] [--health-samples <n>] [--readiness-interval-ms <n>] [--resource-sample-interval-ms <n>] [--deep-profile] [--node-profile] [--heap-snapshot] [--profile-on-failure] [--execute] [--allow-exhaustive] [--keep-env] [--retain-on-failure] [--json]
|
||||
@ -62,6 +63,7 @@ Notes:
|
||||
Kova uses OCM to create isolated OpenClaw envs and runtimes.
|
||||
Kova reports on OpenClaw behavior, not OCM behavior.
|
||||
run is dry-run/report-only unless --execute is passed.
|
||||
inventory is planner-only and reports discovered OpenClaw capabilities that are not mapped to Kova surfaces.
|
||||
Executed exhaustive matrix runs require --allow-exhaustive.
|
||||
cleanup artifacts is dry-run by default and only targets Kova-owned run artifact dirs.
|
||||
--repeat records independent samples and computes aggregate performance stats.
|
||||
|
||||
29
src/commands/inventory.mjs
Normal file
29
src/commands/inventory.mjs
Normal file
@ -0,0 +1,29 @@
|
||||
import { buildOpenClawInventoryPlan } from "../inventory/openclaw.mjs";
|
||||
|
||||
export async function runInventoryCommand(flags) {
|
||||
const [subcommand = "plan"] = flags._;
|
||||
if (subcommand !== "plan") {
|
||||
throw new Error(`unknown inventory command: ${subcommand}`);
|
||||
}
|
||||
|
||||
const plan = await buildOpenClawInventoryPlan(flags);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("OpenClaw inventory plan");
|
||||
console.log(`Discovered: ${plan.coverage.discoveredCount}`);
|
||||
console.log(`Matched: ${plan.coverage.matchedCount}`);
|
||||
console.log(`Unmodeled: ${plan.coverage.unmodeledCount}`);
|
||||
for (const source of plan.sources) {
|
||||
const count = source.commandCount ?? source.scriptCount ?? source.capabilityCount ?? 0;
|
||||
console.log(`- ${source.id}: ${source.status}${count ? ` (${count})` : ""}`);
|
||||
}
|
||||
if (plan.coverage.warnings.length > 0) {
|
||||
console.log("Warnings:");
|
||||
for (const warning of plan.coverage.warnings) {
|
||||
console.log(`- ${warning.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
484
src/inventory/openclaw.mjs
Normal file
484
src/inventory/openclaw.mjs
Normal file
@ -0,0 +1,484 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { basename, join, relative } from "node:path";
|
||||
import { quoteShell, runCommand } from "../commands.mjs";
|
||||
import { positiveIntegerFlag } from "../commands/run-support.mjs";
|
||||
import { resolveFromCwd } from "../cli.mjs";
|
||||
import { loadRegistryContext } from "../registries/context.mjs";
|
||||
|
||||
const inventorySchemaVersion = "kova.inventory.plan.v1";
|
||||
const manifestSearchDirs = ["apps", "extensions", "packages", "plugins", "src"];
|
||||
const ignoredDirs = new Set([
|
||||
".git",
|
||||
".next",
|
||||
".turbo",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
"target",
|
||||
"tmp"
|
||||
]);
|
||||
|
||||
const commandSurfaceMap = new Map([
|
||||
["agent", ["agent-cli-local-turn", "agent-gateway-rpc-turn"]],
|
||||
["browser", ["browser-automation"]],
|
||||
["dashboard", ["dashboard", "dashboard-session-send-turn"]],
|
||||
["mcp", ["mcp-runtime"]],
|
||||
["media", ["media-understanding"]],
|
||||
["model", ["provider-models"]],
|
||||
["models", ["provider-models"]],
|
||||
["plugin", ["plugin-lifecycle"]],
|
||||
["plugins", ["plugin-lifecycle", "official-plugin-install"]],
|
||||
["provider", ["provider-models"]],
|
||||
["providers", ["provider-models"]],
|
||||
["start", ["release-runtime-startup", "gateway-performance"]],
|
||||
["status", ["release-runtime-startup"]],
|
||||
["stop", ["release-runtime-startup"]],
|
||||
["tui", ["tui", "tui-message-turn"]],
|
||||
["update", ["upgrade-existing-user"]],
|
||||
["upgrade", ["upgrade-existing-user"]],
|
||||
["workspace", ["workspace-scan"]]
|
||||
]);
|
||||
|
||||
export async function buildOpenClawInventoryPlan(flags = {}) {
|
||||
const registry = await loadRegistryContext();
|
||||
const timeoutMs = positiveIntegerFlag(flags, "timeout_ms", 10000);
|
||||
const maxSubcommands = positiveIntegerFlag(flags, "max_subcommands", 40);
|
||||
const openclawBin = normalizeOptionalCommand(flags.openclaw_bin);
|
||||
const repoPath = normalizeOptionalPath(flags.openclaw_repo);
|
||||
const requestedSubcommands = parseList(flags.subcommands);
|
||||
const requiredModeled = parseList(flags.require_modeled);
|
||||
const sources = [];
|
||||
const capabilities = [];
|
||||
|
||||
const helpInventory = await discoverCliHelp({
|
||||
openclawBin,
|
||||
requestedSubcommands,
|
||||
maxSubcommands,
|
||||
timeoutMs
|
||||
});
|
||||
sources.push(helpInventory.source);
|
||||
capabilities.push(...helpInventory.capabilities);
|
||||
|
||||
const repoInventory = await discoverRepoInventory({ repoPath });
|
||||
sources.push(...repoInventory.sources);
|
||||
capabilities.push(...repoInventory.capabilities);
|
||||
|
||||
const modeledSurfaces = registry.surfaces.map((surface) => ({
|
||||
id: surface.id,
|
||||
title: surface.title,
|
||||
ownerArea: surface.ownerArea,
|
||||
purposes: surface.purposes ?? []
|
||||
}));
|
||||
const classifiedCapabilities = capabilities.map((capability) =>
|
||||
classifyCapability(capability, registry.surfaces)
|
||||
);
|
||||
|
||||
return {
|
||||
schemaVersion: inventorySchemaVersion,
|
||||
generatedAt: new Date().toISOString(),
|
||||
openclaw: {
|
||||
bin: openclawBin,
|
||||
repoPath
|
||||
},
|
||||
sources,
|
||||
modeledSurfaces,
|
||||
capabilities: classifiedCapabilities,
|
||||
coverage: summarizeCoverage(classifiedCapabilities, modeledSurfaces, {
|
||||
requiredModeled
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverCliHelp({ openclawBin, requestedSubcommands, maxSubcommands, timeoutMs }) {
|
||||
if (!openclawBin) {
|
||||
return {
|
||||
source: {
|
||||
id: "openclaw-help",
|
||||
kind: "cli-help",
|
||||
status: "skipped",
|
||||
reason: "--openclaw-bin was not provided"
|
||||
},
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
|
||||
const helpCommand = `${quoteShell(openclawBin)} --help`;
|
||||
const topLevel = await runCommand(helpCommand, {
|
||||
timeoutMs,
|
||||
maxOutputChars: 50000
|
||||
});
|
||||
if (topLevel.status !== 0) {
|
||||
return {
|
||||
source: {
|
||||
id: "openclaw-help",
|
||||
kind: "cli-help",
|
||||
status: "failed",
|
||||
command: topLevel.command,
|
||||
statusCode: topLevel.status,
|
||||
timedOut: topLevel.timedOut,
|
||||
error: topLevel.stderr.trim() || topLevel.stdout.trim() || "openclaw --help failed"
|
||||
},
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
|
||||
const parsedCommands = requestedSubcommands.length > 0
|
||||
? requestedSubcommands
|
||||
: parseHelpCommands(topLevel.stdout);
|
||||
const allUniqueCommands = [...new Set(parsedCommands)].sort();
|
||||
const uniqueCommands = allUniqueCommands.slice(0, maxSubcommands);
|
||||
const capabilities = uniqueCommands.map((command) => ({
|
||||
id: `cli:${command}`,
|
||||
kind: "cli-command",
|
||||
name: command,
|
||||
source: "openclaw-help",
|
||||
path: null,
|
||||
summary: null,
|
||||
evidence: {
|
||||
command: helpCommand
|
||||
}
|
||||
}));
|
||||
|
||||
for (const capability of capabilities) {
|
||||
const result = await runCommand(`${quoteShell(openclawBin)} ${quoteShell(capability.name)} --help`, {
|
||||
timeoutMs,
|
||||
maxOutputChars: 30000
|
||||
});
|
||||
capability.evidence.subcommandHelp = {
|
||||
command: result.command,
|
||||
status: result.status,
|
||||
timedOut: result.timedOut
|
||||
};
|
||||
if (result.status === 0) {
|
||||
capability.summary = firstUsefulHelpLine(result.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
source: {
|
||||
id: "openclaw-help",
|
||||
kind: "cli-help",
|
||||
status: "scanned",
|
||||
command: topLevel.command,
|
||||
commandCount: capabilities.length,
|
||||
discoveredCommandCount: allUniqueCommands.length,
|
||||
truncated: allUniqueCommands.length > uniqueCommands.length,
|
||||
requestedSubcommands
|
||||
},
|
||||
capabilities
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverRepoInventory({ repoPath }) {
|
||||
if (!repoPath) {
|
||||
return {
|
||||
sources: [
|
||||
{
|
||||
id: "package-scripts",
|
||||
kind: "package-json",
|
||||
status: "skipped",
|
||||
reason: "--openclaw-repo was not provided"
|
||||
},
|
||||
{
|
||||
id: "manifests",
|
||||
kind: "manifest-scan",
|
||||
status: "skipped",
|
||||
reason: "--openclaw-repo was not provided"
|
||||
}
|
||||
],
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
|
||||
const sources = [];
|
||||
const capabilities = [];
|
||||
const packagePath = join(repoPath, "package.json");
|
||||
try {
|
||||
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
const scripts = Object.keys(packageJson.scripts ?? {}).sort();
|
||||
for (const script of scripts) {
|
||||
capabilities.push({
|
||||
id: `script:${script}`,
|
||||
kind: "package-script",
|
||||
name: script,
|
||||
source: "package-scripts",
|
||||
path: relative(repoPath, packagePath),
|
||||
summary: packageJson.scripts[script],
|
||||
evidence: {
|
||||
packageName: packageJson.name ?? null
|
||||
}
|
||||
});
|
||||
}
|
||||
sources.push({
|
||||
id: "package-scripts",
|
||||
kind: "package-json",
|
||||
status: "scanned",
|
||||
path: packagePath,
|
||||
scriptCount: scripts.length
|
||||
});
|
||||
} catch (error) {
|
||||
sources.push({
|
||||
id: "package-scripts",
|
||||
kind: "package-json",
|
||||
status: error.code === "ENOENT" ? "missing" : "failed",
|
||||
path: packagePath,
|
||||
error: error.code === "ENOENT" ? null : error.message
|
||||
});
|
||||
}
|
||||
|
||||
const manifestResult = await discoverManifests(repoPath);
|
||||
sources.push(manifestResult.source);
|
||||
capabilities.push(...manifestResult.capabilities);
|
||||
return { sources, capabilities };
|
||||
}
|
||||
|
||||
async function discoverManifests(repoPath) {
|
||||
const candidates = [];
|
||||
const roots = manifestSearchDirs.map((dir) => join(repoPath, dir));
|
||||
for (const root of roots) {
|
||||
await collectManifestCandidates(root, repoPath, candidates);
|
||||
}
|
||||
|
||||
const capabilities = [];
|
||||
for (const path of candidates.slice(0, 300)) {
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(path, "utf8"));
|
||||
const kind = classifyManifest(path, manifest);
|
||||
if (!kind) {
|
||||
continue;
|
||||
}
|
||||
const name = manifest.name ?? manifest.id ?? manifest.displayName ?? basename(path);
|
||||
capabilities.push({
|
||||
id: `${kind}:${normalizeToken(name) || normalizeToken(relative(repoPath, path))}`,
|
||||
kind,
|
||||
name,
|
||||
source: "manifests",
|
||||
path: relative(repoPath, path),
|
||||
summary: manifest.description ?? manifest.title ?? null,
|
||||
evidence: {
|
||||
manifestId: manifest.id ?? null,
|
||||
packageName: manifest.name ?? null
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Non-JSON manifest-looking files are ignored; registry validation catches Kova contracts.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
source: {
|
||||
id: "manifests",
|
||||
kind: "manifest-scan",
|
||||
status: "scanned",
|
||||
roots: roots.map((root) => relative(repoPath, root)),
|
||||
candidateCount: candidates.length,
|
||||
capabilityCount: capabilities.length,
|
||||
truncated: candidates.length > 300
|
||||
},
|
||||
capabilities
|
||||
};
|
||||
}
|
||||
|
||||
async function collectManifestCandidates(root, repoPath, candidates, depth = 0) {
|
||||
if (depth > 8 || candidates.length > 300) {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirs.has(entry.name)) {
|
||||
await collectManifestCandidates(join(root, entry.name), repoPath, candidates, depth + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const name = entry.name.toLowerCase();
|
||||
if (name === "plugin.json" || name === "manifest.json" || name.endsWith(".manifest.json")) {
|
||||
candidates.push(join(root, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function classifyManifest(path, manifest) {
|
||||
const lowerPath = path.toLowerCase();
|
||||
if (manifest.openclawPlugin === true || manifest.plugin === true || lowerPath.endsWith("/plugin.json")) {
|
||||
return "plugin-manifest";
|
||||
}
|
||||
if (manifest.openclawExtension === true || manifest.extension === true || lowerPath.includes("/extensions/")) {
|
||||
return "extension-manifest";
|
||||
}
|
||||
if (Array.isArray(manifest.contributes) || manifest.activationEvents || manifest.main) {
|
||||
return lowerPath.includes("plugin") ? "plugin-manifest" : "extension-manifest";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyCapability(capability, surfaces) {
|
||||
const matchedSurfaceIds = matchSurfaceIds(capability, surfaces);
|
||||
return {
|
||||
...capability,
|
||||
modeled: matchedSurfaceIds.length > 0,
|
||||
matchedSurfaceIds,
|
||||
matchStatus: matchedSurfaceIds.length === 0
|
||||
? "unmodeled"
|
||||
: matchedSurfaceIds.length === 1 ? "matched" : "ambiguous"
|
||||
};
|
||||
}
|
||||
|
||||
function matchSurfaceIds(capability, surfaces) {
|
||||
const normalizedName = normalizeToken(capability.name);
|
||||
const mapped = commandSurfaceMap.get(normalizedName) ?? [];
|
||||
const surfaceIds = new Set(surfaces.map((surface) => surface.id));
|
||||
const matches = new Set(mapped.filter((id) => surfaceIds.has(id)));
|
||||
|
||||
for (const surface of surfaces) {
|
||||
const haystack = [
|
||||
surface.id,
|
||||
surface.title,
|
||||
surface.ownerArea,
|
||||
...(surface.purposes ?? [])
|
||||
].map(normalizeToken);
|
||||
if (haystack.includes(normalizedName)) {
|
||||
matches.add(surface.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...matches].sort();
|
||||
}
|
||||
|
||||
function summarizeCoverage(capabilities, modeledSurfaces, options = {}) {
|
||||
const unmodeled = capabilities.filter((capability) => !capability.modeled);
|
||||
const matched = capabilities.filter((capability) => capability.modeled);
|
||||
const ambiguous = matched.filter((capability) => capability.matchStatus === "ambiguous");
|
||||
const requiredModeled = options.requiredModeled ?? [];
|
||||
const capabilitiesById = new Map(capabilities.map((capability) => [capability.id, capability]));
|
||||
const blockers = requiredModeled.flatMap((id) => {
|
||||
const capability = capabilitiesById.get(id);
|
||||
if (!capability) {
|
||||
return [{
|
||||
kind: "required-capability-missing",
|
||||
capability: id,
|
||||
message: `required inventory capability ${id} was not discovered`
|
||||
}];
|
||||
}
|
||||
if (!capability.modeled) {
|
||||
return [{
|
||||
kind: "required-capability-unmodeled",
|
||||
capability: id,
|
||||
name: capability.name,
|
||||
source: capability.source,
|
||||
message: `required inventory capability ${id} is not mapped to a Kova surface`
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return {
|
||||
discoveredCount: capabilities.length,
|
||||
modeledSurfaceCount: modeledSurfaces.length,
|
||||
matchedCount: matched.length,
|
||||
ambiguousCount: ambiguous.length,
|
||||
unmodeledCount: unmodeled.length,
|
||||
requiredModeled,
|
||||
ok: blockers.length === 0,
|
||||
blockers,
|
||||
warnings: [
|
||||
...unmodeled.map((capability) => ({
|
||||
kind: "unmodeled-capability",
|
||||
capability: capability.id,
|
||||
name: capability.name,
|
||||
source: capability.source,
|
||||
message: `${capability.kind} ${capability.name} is not mapped to a Kova surface`
|
||||
})),
|
||||
...ambiguous.map((capability) => ({
|
||||
kind: "ambiguous-capability",
|
||||
capability: capability.id,
|
||||
name: capability.name,
|
||||
source: capability.source,
|
||||
matchedSurfaceIds: capability.matchedSurfaceIds,
|
||||
message: `${capability.kind} ${capability.name} maps to multiple Kova surfaces`
|
||||
}))
|
||||
],
|
||||
unmodeled: unmodeled.map((capability) => ({
|
||||
id: capability.id,
|
||||
kind: capability.kind,
|
||||
name: capability.name,
|
||||
source: capability.source,
|
||||
path: capability.path
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function parseHelpCommands(text) {
|
||||
const commands = [];
|
||||
let inCommands = false;
|
||||
for (const rawLine of String(text ?? "").split(/\r?\n/)) {
|
||||
const line = rawLine.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
if (/^\s*(commands|available commands):\s*$/i.test(line)) {
|
||||
inCommands = true;
|
||||
continue;
|
||||
}
|
||||
if (inCommands && /^\S/.test(line) && !/^\s/.test(rawLine)) {
|
||||
inCommands = false;
|
||||
}
|
||||
if (!inCommands) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^\s{2,}([a-z][a-z0-9:-]*)\b/i);
|
||||
if (match) {
|
||||
commands.push(match[1]);
|
||||
}
|
||||
}
|
||||
return commands.filter((command) => !["help", "completion"].includes(command));
|
||||
}
|
||||
|
||||
function firstUsefulHelpLine(text) {
|
||||
return String(text ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line && !/^usage:/i.test(line) && !/^commands:/i.test(line)) ?? null;
|
||||
}
|
||||
|
||||
function parseList(raw) {
|
||||
if (!raw || raw === true) {
|
||||
return [];
|
||||
}
|
||||
const values = Array.isArray(raw) ? raw : String(raw).split(",");
|
||||
return values.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeOptionalPath(value) {
|
||||
if (!value || value === true) {
|
||||
return null;
|
||||
}
|
||||
return resolveFromCwd(String(value));
|
||||
}
|
||||
|
||||
function normalizeOptionalCommand(value) {
|
||||
if (!value || value === true) {
|
||||
return null;
|
||||
}
|
||||
const command = String(value);
|
||||
return command.includes("/") || command.startsWith(".") ? resolveFromCwd(command) : command;
|
||||
}
|
||||
|
||||
function normalizeToken(value) {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/openclaw/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { parseFlags, printHelp } from "./cli.mjs";
|
||||
import { runCleanupCliCommand } from "./commands/cleanup.mjs";
|
||||
import { runInventoryCommand } from "./commands/inventory.mjs";
|
||||
import { runMatrixCommand } from "./commands/matrix.mjs";
|
||||
import { runPlanCommand } from "./commands/plan.mjs";
|
||||
import { runReportCommand } from "./commands/report.mjs";
|
||||
@ -42,6 +43,11 @@ export async function main(argv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "inventory") {
|
||||
await runInventoryCommand(flags);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "run") {
|
||||
await runScenarioCommand(flags);
|
||||
return;
|
||||
|
||||
@ -143,6 +143,7 @@ export async function runSelfCheck(flags = {}) {
|
||||
throw new Error("every scenario must declare the surface requirement ids it proves");
|
||||
}
|
||||
}));
|
||||
checks.push(await inventoryPlanCheck(tmp));
|
||||
checks.push(await jsonCommandCheck("matrix-plan-json", "node bin/kova.mjs matrix plan --profile smoke --target runtime:stable --include scenario:fresh-install --parallel 2 --json", (data) => {
|
||||
assertEqual(data.schemaVersion, "kova.matrix.plan.v1", "matrix plan schema");
|
||||
assertEqual(data.profile?.id, "smoke", "matrix profile id");
|
||||
@ -3794,6 +3795,79 @@ async function cleanupArtifactsCheck(tmp) {
|
||||
};
|
||||
}
|
||||
|
||||
async function inventoryPlanCheck(tmp) {
|
||||
const binDir = join(tmp, "inventory-bin");
|
||||
const repoDir = join(tmp, "inventory-openclaw");
|
||||
const openclawBin = join(binDir, "openclaw");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await mkdir(join(repoDir, "plugins", "bundled"), { recursive: true });
|
||||
await mkdir(join(repoDir, "extensions", "dashboard"), { recursive: true });
|
||||
await writeFile(openclawBin, `#!/bin/sh
|
||||
case "$1" in
|
||||
--help)
|
||||
cat <<'HELP'
|
||||
Usage: openclaw <command>
|
||||
|
||||
Commands:
|
||||
dashboard Start dashboard
|
||||
plugins Manage plugins
|
||||
unknownx Experimental command
|
||||
HELP
|
||||
;;
|
||||
dashboard)
|
||||
echo "OpenClaw dashboard help"
|
||||
;;
|
||||
plugins)
|
||||
echo "OpenClaw plugins help"
|
||||
;;
|
||||
unknownx)
|
||||
echo "OpenClaw unknownx help"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected args: $*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
`, "utf8");
|
||||
await chmod(openclawBin, 0o755);
|
||||
await writeFile(join(repoDir, "package.json"), `${JSON.stringify({
|
||||
name: "openclaw",
|
||||
scripts: {
|
||||
build: "pnpm build",
|
||||
"package-release": "node scripts/package-release.mjs"
|
||||
}
|
||||
}, null, 2)}\n`, "utf8");
|
||||
await writeFile(join(repoDir, "plugins", "bundled", "plugin.json"), `${JSON.stringify({
|
||||
name: "plugins",
|
||||
description: "Bundled plugin manifest",
|
||||
openclawPlugin: true
|
||||
}, null, 2)}\n`, "utf8");
|
||||
await writeFile(join(repoDir, "extensions", "dashboard", "manifest.json"), `${JSON.stringify({
|
||||
name: "dashboard",
|
||||
description: "Dashboard extension",
|
||||
openclawExtension: true
|
||||
}, null, 2)}\n`, "utf8");
|
||||
|
||||
return jsonCommandCheck(
|
||||
"inventory-plan-json",
|
||||
`node bin/kova.mjs inventory plan --openclaw-bin ${quoteShell(openclawBin)} --openclaw-repo ${quoteShell(repoDir)} --require-modeled cli:unknownx --json`,
|
||||
(data) => {
|
||||
assertEqual(data.schemaVersion, "kova.inventory.plan.v1", "inventory schema");
|
||||
assertEqual(data.sources?.find((source) => source.id === "openclaw-help")?.status, "scanned", "inventory help source");
|
||||
assertEqual(data.sources?.find((source) => source.id === "package-scripts")?.status, "scanned", "inventory package source");
|
||||
assertEqual(data.sources?.find((source) => source.id === "manifests")?.status, "scanned", "inventory manifests source");
|
||||
assertEqual(data.capabilities?.some((capability) => capability.id === "cli:dashboard" && capability.matchedSurfaceIds?.includes("dashboard")), true, "dashboard command mapped");
|
||||
assertEqual(data.capabilities?.some((capability) => capability.id === "cli:unknownx" && capability.matchStatus === "unmodeled"), true, "unknown command warning");
|
||||
assertEqual(data.capabilities?.some((capability) => capability.kind === "package-script"), true, "package scripts discovered");
|
||||
assertEqual(data.capabilities?.some((capability) => capability.kind === "plugin-manifest"), true, "plugin manifest discovered");
|
||||
assertEqual(data.capabilities?.some((capability) => capability.kind === "extension-manifest"), true, "extension manifest discovered");
|
||||
assertEqual((data.coverage?.warnings ?? []).some((warning) => warning.capability === "cli:unknownx"), true, "unmodeled warning emitted");
|
||||
assertEqual(data.coverage?.ok, false, "required unmodeled capability blocks inventory coverage");
|
||||
assertEqual((data.coverage?.blockers ?? []).some((blocker) => blocker.capability === "cli:unknownx"), true, "required unmodeled blocker emitted");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readinessClassificationCheck() {
|
||||
try {
|
||||
const record = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user