mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-06 01:42:47 -04:00
Compare commits
64 Commits
4619375c14
...
c8e973513c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e973513c | ||
|
|
8f9315bdc9 | ||
|
|
12a091afe6 | ||
|
|
9f0cf3b888 | ||
|
|
6e94c1a97f | ||
|
|
38626cad20 | ||
|
|
e84f7c8034 | ||
|
|
b8f066347b | ||
|
|
5adc751053 | ||
|
|
adf5bd165e | ||
|
|
b220366176 | ||
|
|
b5d67ef249 | ||
|
|
9a40568e1c | ||
|
|
41b3566468 | ||
|
|
b86159cbb7 | ||
|
|
5e5b9966c0 | ||
|
|
33f771f3f5 | ||
|
|
3fbfcc40ca | ||
|
|
c7c5c11d1e | ||
|
|
c0447e2be1 | ||
|
|
1b22ed700a | ||
|
|
76f9a134ae | ||
|
|
9c11325e83 | ||
|
|
4708ab1611 | ||
|
|
311e719e5d | ||
|
|
b926a9d25f | ||
|
|
61d641d722 | ||
|
|
2f8679bd15 | ||
|
|
9ef21e23f3 | ||
|
|
6ac0386094 | ||
|
|
4c939c0ad6 | ||
|
|
4d41ab37e1 | ||
|
|
662a50bdc0 | ||
|
|
1da4aa454f | ||
|
|
3f50f33407 | ||
|
|
0ce4168c93 | ||
|
|
7f1dd0c116 | ||
|
|
42f56e7f77 | ||
|
|
f25fae6d22 | ||
|
|
be66d961bf | ||
|
|
b1a40a2364 | ||
|
|
726d55d1ea | ||
|
|
e4b8f9c07f | ||
|
|
b3a5a74237 | ||
|
|
ad76389b31 | ||
|
|
e68733d72e | ||
|
|
4d4d72cd49 | ||
|
|
9bc2f3631d | ||
|
|
f40927ba97 | ||
|
|
5bcbc2f874 | ||
|
|
a671969688 | ||
|
|
6757ebde74 | ||
|
|
2447273d09 | ||
|
|
5a76ecb760 | ||
|
|
db56498460 | ||
|
|
6fcd0c57ae | ||
|
|
de66bfc082 | ||
|
|
5d85739358 | ||
|
|
8fd11e82c4 | ||
|
|
9f9b14a76d | ||
|
|
78c2a49ec8 | ||
|
|
0e54ec4c04 | ||
|
|
58a30f6ab8 | ||
|
|
453d8945bb |
628
ROADMAP.md
628
ROADMAP.md
File diff suppressed because one or more lines are too long
1
USAGE.md
1
USAGE.md
@@ -615,6 +615,7 @@ The list is also the precedence chain: project-local settings override project s
|
||||
```
|
||||
|
||||
Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order.
|
||||
Legacy bare-string hook entries still load for backward compatibility but emit deprecation warnings suggesting migration to object-style entries. Unknown hook event names (e.g. `Stop`, `Notification`) are recorded as invalid without rejecting valid hooks. `status --output-format json` mirrors partial hook validation under `hook_validation` with `valid_count`, `invalid_count`, and `invalid_hooks:[{event, index, hook_index, kind, error_field, reason, valid:false}]`. `doctor --output-format json` includes a `hook validation` check so automation can repair every rejected hook entry without losing usable hooks.
|
||||
|
||||
## Project instruction rules
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ Top-level commands:
|
||||
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
|
||||
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
|
||||
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
|
||||
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
|
||||
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
|
||||
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
|
||||
|
||||
|
||||
@@ -2147,7 +2147,7 @@ impl DefinitionSource {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct AgentSummary {
|
||||
pub(crate) struct AgentSummary {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
model: Option<String>,
|
||||
@@ -2158,6 +2158,20 @@ struct AgentSummary {
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// An agent definition file that could not be loaded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct InvalidAgentConfig {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) reason: String,
|
||||
}
|
||||
|
||||
/// Loaded agent definitions plus any invalid entries that were skipped.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct AgentCollection {
|
||||
pub(crate) agents: Vec<AgentSummary>,
|
||||
pub(crate) invalid_agents: Vec<InvalidAgentConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SkillSummary {
|
||||
name: String,
|
||||
@@ -2167,6 +2181,23 @@ struct SkillSummary {
|
||||
origin: SkillOrigin,
|
||||
// #729: on-disk path parity with AgentSummary
|
||||
path: Option<PathBuf>,
|
||||
// #445: directory name for detecting name/dir mismatch
|
||||
dir_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A skill where the frontmatter name differs from the directory name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SkillMetadataDrift {
|
||||
pub(crate) dir_name: String,
|
||||
pub(crate) frontmatter_name: String,
|
||||
pub(crate) path: PathBuf,
|
||||
}
|
||||
|
||||
/// Loaded skill definitions plus any metadata drift entries.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct SkillCollection {
|
||||
pub(crate) skills: Vec<SkillSummary>,
|
||||
pub(crate) metadata_drift: Vec<SkillMetadataDrift>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -2494,8 +2525,8 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report_json(cwd, &agents))
|
||||
let collection = load_agents_from_roots_with_invalids(&roots)?;
|
||||
Ok(render_agents_report_json(cwd, &collection))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
@@ -2512,17 +2543,26 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
}));
|
||||
}
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = agents
|
||||
let collection = load_agents_from_roots_with_invalids(&roots)?;
|
||||
let filtered_agents: Vec<_> = collection
|
||||
.agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_agents_report_json(cwd, &filtered))
|
||||
let filtered_collection = AgentCollection {
|
||||
agents: filtered_agents,
|
||||
invalid_agents: collection.invalid_agents,
|
||||
};
|
||||
Ok(render_agents_report_json(cwd, &filtered_collection))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report_json_with_action(cwd, &agents, "show"))
|
||||
let collection = load_agents_from_roots_with_invalids(&roots)?;
|
||||
Ok(render_agents_report_json_with_action(
|
||||
cwd,
|
||||
&collection,
|
||||
"show",
|
||||
))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
@@ -2553,8 +2593,9 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
}));
|
||||
}
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let matched: Vec<_> = agents
|
||||
let collection = load_agents_from_roots_with_invalids(&roots)?;
|
||||
let matched: Vec<_> = collection
|
||||
.agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase() == name)
|
||||
.collect();
|
||||
@@ -2571,7 +2612,15 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
"hint": "Run `claw agents list` to see available agents.",
|
||||
}));
|
||||
}
|
||||
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
|
||||
let matched_collection = AgentCollection {
|
||||
agents: matched,
|
||||
invalid_agents: collection.invalid_agents,
|
||||
};
|
||||
Ok(render_agents_report_json_with_action(
|
||||
cwd,
|
||||
&matched_collection,
|
||||
"show",
|
||||
))
|
||||
}
|
||||
Some("create") => Ok(render_agents_missing_argument_json("create", "agent_name")),
|
||||
Some(args) if args.starts_with("create ") => {
|
||||
@@ -2766,8 +2815,8 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report_json_with_action(&skills, "list"))
|
||||
let collection = load_skills_from_roots_with_drift(&roots)?;
|
||||
Ok(render_skills_report_json_with_action(&collection, "list"))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
@@ -2784,17 +2833,25 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
}));
|
||||
}
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = skills
|
||||
let collection = load_skills_from_roots_with_drift(&roots)?;
|
||||
let filtered_skills: Vec<_> = collection
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_skills_report_json_with_action(&filtered, "list"))
|
||||
let filtered_collection = SkillCollection {
|
||||
skills: filtered_skills,
|
||||
metadata_drift: collection.metadata_drift,
|
||||
};
|
||||
Ok(render_skills_report_json_with_action(
|
||||
&filtered_collection,
|
||||
"list",
|
||||
))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report_json_with_action(&skills, "show"))
|
||||
let collection = load_skills_from_roots_with_drift(&roots)?;
|
||||
Ok(render_skills_report_json_with_action(&collection, "show"))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
@@ -2825,8 +2882,9 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
}));
|
||||
}
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let matched: Vec<_> = skills
|
||||
let collection = load_skills_from_roots_with_drift(&roots)?;
|
||||
let matched: Vec<_> = collection
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase() == name)
|
||||
.collect();
|
||||
@@ -2843,7 +2901,14 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
"hint": "Run `claw skills list` to see available skills.",
|
||||
}));
|
||||
}
|
||||
Ok(render_skills_report_json_with_action(&matched, "show"))
|
||||
let matched_collection = SkillCollection {
|
||||
skills: matched,
|
||||
metadata_drift: collection.metadata_drift,
|
||||
};
|
||||
Ok(render_skills_report_json_with_action(
|
||||
&matched_collection,
|
||||
"show",
|
||||
))
|
||||
}
|
||||
Some("install") => Ok(render_skills_missing_argument_json(
|
||||
"install",
|
||||
@@ -3243,7 +3308,15 @@ fn render_mcp_report_json_for(
|
||||
"use `claw mcp show <server>` to inspect a server",
|
||||
))
|
||||
}
|
||||
Some(args) => Ok(render_mcp_usage_json(Some(args))),
|
||||
Some(args) => {
|
||||
// #681: unsupported mutation verbs (add, remove, delete, enable, disable)
|
||||
// and other unknown sub-actions return a typed error instead of help with exit 0.
|
||||
let verb = args.split_whitespace().next().unwrap_or(args);
|
||||
Ok(render_mcp_unsupported_action_json(
|
||||
args,
|
||||
&format!("`{verb}` is not a supported MCP sub-action; supported actions: list, show, help"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3902,30 +3975,69 @@ fn push_unique_skill_root(
|
||||
fn load_agents_from_roots(
|
||||
roots: &[(DefinitionSource, PathBuf)],
|
||||
) -> std::io::Result<Vec<AgentSummary>> {
|
||||
let collection = load_agents_from_roots_with_invalids(roots)?;
|
||||
Ok(collection.agents)
|
||||
}
|
||||
|
||||
/// Load agent definitions from all roots, collecting both valid agents and
|
||||
/// invalid entries (wrong extension, broken frontmatter, etc.).
|
||||
fn load_agents_from_roots_with_invalids(
|
||||
roots: &[(DefinitionSource, PathBuf)],
|
||||
) -> std::io::Result<AgentCollection> {
|
||||
let mut agents = Vec::new();
|
||||
let mut invalid_agents = Vec::new();
|
||||
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
||||
|
||||
for (source, root) in roots {
|
||||
let mut root_agents = Vec::new();
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
if entry.path().extension().is_none_or(|ext| ext != "toml") {
|
||||
continue;
|
||||
let path = entry.path();
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
match ext {
|
||||
Some("toml") => {
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let fallback_name = path.file_stem().map_or_else(
|
||||
|| entry.file_name().to_string_lossy().to_string(),
|
||||
|stem| stem.to_string_lossy().to_string(),
|
||||
);
|
||||
root_agents.push(AgentSummary {
|
||||
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
|
||||
description: parse_toml_string(&contents, "description"),
|
||||
model: parse_toml_string(&contents, "model"),
|
||||
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
|
||||
source: *source,
|
||||
shadowed_by: None,
|
||||
path: Some(path),
|
||||
});
|
||||
}
|
||||
Some("md") => {
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let (name, description, model, reasoning_effort) =
|
||||
parse_agent_frontmatter(&contents);
|
||||
if name.is_none() && description.is_none() {
|
||||
invalid_agents.push(InvalidAgentConfig {
|
||||
path,
|
||||
reason: "Markdown agent file has no YAML frontmatter with name or description fields".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let fallback_name = path.file_stem().map_or_else(
|
||||
|| entry.file_name().to_string_lossy().to_string(),
|
||||
|stem| stem.to_string_lossy().to_string(),
|
||||
);
|
||||
root_agents.push(AgentSummary {
|
||||
name: name.unwrap_or(fallback_name),
|
||||
description,
|
||||
model,
|
||||
reasoning_effort,
|
||||
source: *source,
|
||||
shadowed_by: None,
|
||||
path: Some(path),
|
||||
});
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
let contents = fs::read_to_string(entry.path())?;
|
||||
let fallback_name = entry.path().file_stem().map_or_else(
|
||||
|| entry.file_name().to_string_lossy().to_string(),
|
||||
|stem| stem.to_string_lossy().to_string(),
|
||||
);
|
||||
root_agents.push(AgentSummary {
|
||||
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
|
||||
description: parse_toml_string(&contents, "description"),
|
||||
model: parse_toml_string(&contents, "model"),
|
||||
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
|
||||
source: *source,
|
||||
shadowed_by: None,
|
||||
path: Some(entry.path()),
|
||||
});
|
||||
}
|
||||
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
|
||||
@@ -3940,11 +4052,22 @@ fn load_agents_from_roots(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(agents)
|
||||
Ok(AgentCollection {
|
||||
agents,
|
||||
invalid_agents,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
|
||||
let collection = load_skills_from_roots_with_drift(roots)?;
|
||||
Ok(collection.skills)
|
||||
}
|
||||
|
||||
/// Load skill definitions from all roots, collecting metadata drift entries
|
||||
/// where the frontmatter name differs from the directory name.
|
||||
fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<SkillCollection> {
|
||||
let mut skills = Vec::new();
|
||||
let mut metadata_drift = Vec::new();
|
||||
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
||||
|
||||
for root in roots {
|
||||
@@ -3961,15 +4084,26 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
||||
continue;
|
||||
}
|
||||
let contents = fs::read_to_string(skill_path)?;
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
let (name, description) = parse_skill_frontmatter(&contents);
|
||||
// #445: detect name/dir mismatch
|
||||
if let Some(ref frontmatter_name) = name {
|
||||
if frontmatter_name != &dir_name {
|
||||
metadata_drift.push(SkillMetadataDrift {
|
||||
dir_name: dir_name.clone(),
|
||||
frontmatter_name: frontmatter_name.clone(),
|
||||
path: entry.path(),
|
||||
});
|
||||
}
|
||||
}
|
||||
root_skills.push(SkillSummary {
|
||||
name: name
|
||||
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
|
||||
name: name.unwrap_or_else(|| dir_name.clone()),
|
||||
description,
|
||||
source: root.source,
|
||||
shadowed_by: None,
|
||||
origin: root.origin,
|
||||
path: Some(entry.path()),
|
||||
dir_name: Some(dir_name),
|
||||
});
|
||||
}
|
||||
SkillOrigin::LegacyCommandsDir => {
|
||||
@@ -4002,6 +4136,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
||||
shadowed_by: None,
|
||||
origin: root.origin,
|
||||
path: Some(markdown_path),
|
||||
dir_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4019,7 +4154,10 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
||||
}
|
||||
}
|
||||
|
||||
Ok(skills)
|
||||
Ok(SkillCollection {
|
||||
skills,
|
||||
metadata_drift,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
|
||||
@@ -4091,6 +4229,63 @@ fn unquote_frontmatter_value(value: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse agent metadata from YAML frontmatter in `.md` agent files.
|
||||
/// Returns (name, description, model, reasoning_effort) extracted from
|
||||
/// the `---`-delimited YAML block at the top of the file.
|
||||
fn parse_agent_frontmatter(
|
||||
contents: &str,
|
||||
) -> (
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
let mut lines = contents.lines();
|
||||
if lines.next().map(str::trim) != Some("---") {
|
||||
return (None, None, None, None);
|
||||
}
|
||||
|
||||
let mut name = None;
|
||||
let mut description = None;
|
||||
let mut model = None;
|
||||
let mut reasoning_effort = None;
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "---" {
|
||||
break;
|
||||
}
|
||||
if let Some(value) = trimmed.strip_prefix("name:") {
|
||||
let value = unquote_frontmatter_value(value.trim());
|
||||
if !value.is_empty() {
|
||||
name = Some(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = trimmed.strip_prefix("description:") {
|
||||
let value = unquote_frontmatter_value(value.trim());
|
||||
if !value.is_empty() {
|
||||
description = Some(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = trimmed.strip_prefix("model:") {
|
||||
let value = unquote_frontmatter_value(value.trim());
|
||||
if !value.is_empty() {
|
||||
model = Some(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = trimmed.strip_prefix("model_reasoning_effort:") {
|
||||
let value = unquote_frontmatter_value(value.trim());
|
||||
if !value.is_empty() {
|
||||
reasoning_effort = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(name, description, model, reasoning_effort)
|
||||
}
|
||||
|
||||
fn render_agents_report(agents: &[AgentSummary]) -> String {
|
||||
if agents.is_empty() {
|
||||
return "No agents found.".to_string();
|
||||
@@ -4133,31 +4328,42 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
||||
lines.join("\n").trim_end().to_string()
|
||||
}
|
||||
|
||||
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
|
||||
render_agents_report_json_with_action(cwd, agents, "list")
|
||||
fn render_agents_report_json(cwd: &Path, collection: &AgentCollection) -> Value {
|
||||
render_agents_report_json_with_action(cwd, collection, "list")
|
||||
}
|
||||
|
||||
fn render_agents_report_json_with_action(
|
||||
cwd: &Path,
|
||||
agents: &[AgentSummary],
|
||||
collection: &AgentCollection,
|
||||
action: &str,
|
||||
) -> Value {
|
||||
let agents = &collection.agents;
|
||||
let invalid_agents = &collection.invalid_agents;
|
||||
let active = agents
|
||||
.iter()
|
||||
.filter(|agent| agent.shadowed_by.is_none())
|
||||
.count();
|
||||
let has_invalids = !invalid_agents.is_empty();
|
||||
let status = if has_invalids { "degraded" } else { "ok" };
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"status": "ok",
|
||||
"status": status,
|
||||
"action": action,
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"count": agents.len(),
|
||||
"valid_count": agents.len(),
|
||||
"invalid_count": invalid_agents.len(),
|
||||
"summary": {
|
||||
"total": agents.len(),
|
||||
"active": active,
|
||||
"shadowed": agents.len().saturating_sub(active),
|
||||
},
|
||||
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
|
||||
"invalid_agents": invalid_agents.iter().map(|invalid| json!({
|
||||
"path": invalid.path.display().to_string(),
|
||||
"reason": &invalid.reason,
|
||||
"valid": false,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4277,21 +4483,34 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
||||
lines.join("\n").trim_end().to_string()
|
||||
}
|
||||
|
||||
fn render_skills_report_json_with_action(skills: &[SkillSummary], action: &str) -> Value {
|
||||
fn render_skills_report_json_with_action(collection: &SkillCollection, action: &str) -> Value {
|
||||
let skills = &collection.skills;
|
||||
let metadata_drift = &collection.metadata_drift;
|
||||
let active = skills
|
||||
.iter()
|
||||
.filter(|skill| skill.shadowed_by.is_none())
|
||||
.count();
|
||||
let has_drift = !metadata_drift.is_empty();
|
||||
let status = if has_drift { "degraded" } else { "ok" };
|
||||
// #410: add `count` field for polymorphic consumption parity with agents list
|
||||
json!({
|
||||
"kind": "skills",
|
||||
"status": "ok",
|
||||
"status": status,
|
||||
"action": action,
|
||||
"count": skills.len(),
|
||||
"valid_count": skills.len(),
|
||||
"metadata_drift_count": metadata_drift.len(),
|
||||
"summary": {
|
||||
"total": skills.len(),
|
||||
"active": active,
|
||||
"shadowed": skills.len().saturating_sub(active),
|
||||
},
|
||||
"skills": skills.iter().map(skill_summary_json).collect::<Vec<_>>(),
|
||||
"metadata_drift": metadata_drift.iter().map(|drift| json!({
|
||||
"dir_name": &drift.dir_name,
|
||||
"frontmatter_name": &drift.frontmatter_name,
|
||||
"path": drift.path.display().to_string(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4467,6 +4686,7 @@ fn render_mcp_summary_report_json(cwd: &Path, mcp: &McpConfigCollection) -> Valu
|
||||
json!({
|
||||
"kind": "mcp",
|
||||
"action": "list",
|
||||
"count": mcp.valid_count(),
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"configured_servers": mcp.valid_count(),
|
||||
"total_configured": mcp.total_configured(),
|
||||
@@ -4652,7 +4872,7 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
|
||||
"direct_cli": "claw agents [list|show <name>|create <name>|help]",
|
||||
"format": "toml",
|
||||
"create": "claw agents create <name>",
|
||||
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
|
||||
"sources": [".claw/agents", "~/.claw/agents", "~/.codex/agents", "$CLAW_CONFIG_HOME/agents"],
|
||||
},
|
||||
"unexpected": unexpected,
|
||||
})
|
||||
@@ -4782,7 +5002,7 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
|
||||
"usage": {
|
||||
"slash_command": "/mcp [list|show <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||
"sources": [".claw/settings.json", ".claw/settings.local.json"],
|
||||
"sources": [".claw.json", ".claw/settings.json", ".claw/settings.local.json"],
|
||||
},
|
||||
"unexpected": unexpected,
|
||||
})
|
||||
@@ -4970,34 +5190,51 @@ fn mcp_oauth_json(oauth: Option<&McpOAuthConfig>) -> Value {
|
||||
}
|
||||
|
||||
fn mcp_server_details_json(config: &McpServerConfig) -> Value {
|
||||
// #90: redact sensitive fields — args/url/headers_helper can contain
|
||||
// credentials. Show structure without leaking secrets.
|
||||
match config {
|
||||
McpServerConfig::Stdio(config) => json!({
|
||||
"command": &config.command,
|
||||
"args": &config.args,
|
||||
"args_count": config.args.len(),
|
||||
"env_keys": config.env.keys().cloned().collect::<Vec<_>>(),
|
||||
"tool_call_timeout_ms": config.tool_call_timeout_ms,
|
||||
}),
|
||||
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => json!({
|
||||
"url": &config.url,
|
||||
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
|
||||
"headers_helper": &config.headers_helper,
|
||||
"oauth": mcp_oauth_json(config.oauth.as_ref()),
|
||||
}),
|
||||
McpServerConfig::Ws(config) => json!({
|
||||
"url": &config.url,
|
||||
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
|
||||
"headers_helper": &config.headers_helper,
|
||||
}),
|
||||
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
|
||||
let redacted_url = redact_url(&config.url);
|
||||
json!({
|
||||
"url": redacted_url,
|
||||
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
|
||||
"headers_helper_configured": config.headers_helper.is_some(),
|
||||
"oauth": mcp_oauth_json(config.oauth.as_ref()),
|
||||
})
|
||||
}
|
||||
McpServerConfig::Ws(config) => {
|
||||
let redacted_url = redact_url(&config.url);
|
||||
json!({
|
||||
"url": redacted_url,
|
||||
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
|
||||
"headers_helper_configured": config.headers_helper.is_some(),
|
||||
})
|
||||
}
|
||||
McpServerConfig::Sdk(config) => json!({
|
||||
"name": &config.name,
|
||||
}),
|
||||
McpServerConfig::ManagedProxy(config) => json!({
|
||||
"url": &config.url,
|
||||
"url": redact_url(&config.url),
|
||||
"id": &config.id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_url(url: &str) -> String {
|
||||
// #90: strip query params which may contain tokens, keep scheme+host+path
|
||||
if let Some(query_start) = url.find('?') {
|
||||
format!("{}?...", &url[..query_start])
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
@@ -5127,7 +5364,7 @@ mod tests {
|
||||
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
|
||||
render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
|
||||
render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
|
||||
slash_command_specs, suggest_slash_commands, validate_slash_command_input,
|
||||
slash_command_specs, suggest_slash_commands, validate_slash_command_input, AgentCollection,
|
||||
DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
|
||||
};
|
||||
use plugins::{
|
||||
@@ -6121,7 +6358,10 @@ mod tests {
|
||||
];
|
||||
let report = render_agents_report_json(
|
||||
&workspace,
|
||||
&load_agents_from_roots(&roots).expect("agent roots should load"),
|
||||
&AgentCollection {
|
||||
agents: load_agents_from_roots(&roots).expect("agent roots should load"),
|
||||
invalid_agents: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(report["kind"], "agents");
|
||||
@@ -6274,7 +6514,10 @@ mod tests {
|
||||
},
|
||||
];
|
||||
let report = super::render_skills_report_json_with_action(
|
||||
&load_skills_from_roots(&roots).expect("skills should load"),
|
||||
&super::SkillCollection {
|
||||
skills: load_skills_from_roots(&roots).expect("skills should load"),
|
||||
metadata_drift: Vec::new(),
|
||||
},
|
||||
"list",
|
||||
);
|
||||
assert_eq!(report["kind"], "skills");
|
||||
@@ -6647,7 +6890,7 @@ mod tests {
|
||||
let help =
|
||||
render_mcp_report_json_for(&loader, &workspace, Some("help")).expect("mcp help json");
|
||||
assert_eq!(help["action"], "help");
|
||||
assert_eq!(help["usage"]["sources"][0], ".claw/settings.json");
|
||||
assert_eq!(help["usage"]["sources"][0], ".claw.json");
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
|
||||
@@ -182,6 +182,7 @@ pub struct RuntimeHookConfig {
|
||||
pre_tool_use: Vec<RuntimeHookCommand>,
|
||||
post_tool_use: Vec<RuntimeHookCommand>,
|
||||
post_tool_use_failure: Vec<RuntimeHookCommand>,
|
||||
invalid_hooks: Vec<RuntimeInvalidHookConfig>,
|
||||
}
|
||||
|
||||
/// A hook command plus optional tool matcher from object-style hook config.
|
||||
@@ -191,6 +192,16 @@ pub struct RuntimeHookCommand {
|
||||
matcher: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeInvalidHookConfig {
|
||||
pub event: String,
|
||||
pub index: Option<usize>,
|
||||
pub hook_index: Option<usize>,
|
||||
pub kind: String,
|
||||
pub error_field: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Raw permission rule lists grouped by allow, deny, and ask behavior.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RuntimePermissionRuleConfig {
|
||||
@@ -1198,6 +1209,7 @@ impl RuntimeHookConfig {
|
||||
pre_tool_use,
|
||||
post_tool_use,
|
||||
post_tool_use_failure,
|
||||
invalid_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1235,6 +1247,8 @@ impl RuntimeHookConfig {
|
||||
&mut self.post_tool_use_failure,
|
||||
other.post_tool_use_failure_entries(),
|
||||
);
|
||||
self.invalid_hooks
|
||||
.extend(other.invalid_hooks.iter().cloned());
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -1246,6 +1260,25 @@ impl RuntimeHookConfig {
|
||||
pub fn post_tool_use_failure_entries(&self) -> &[RuntimeHookCommand] {
|
||||
&self.post_tool_use_failure
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid_hooks(&self) -> &[RuntimeInvalidHookConfig] {
|
||||
&self.invalid_hooks
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid_count(&self) -> usize {
|
||||
self.invalid_hooks.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_invalid_hooks(&self) -> bool {
|
||||
!self.invalid_hooks.is_empty()
|
||||
}
|
||||
|
||||
pub fn push_invalid_hook(&mut self, invalid: RuntimeInvalidHookConfig) {
|
||||
self.invalid_hooks.push(invalid);
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_commands(commands: &[RuntimeHookCommand]) -> Vec<String> {
|
||||
@@ -1634,14 +1667,217 @@ fn parse_optional_hooks_config_object(
|
||||
return Ok(RuntimeHookConfig::default());
|
||||
};
|
||||
let hooks = expect_object(hooks_value, context)?;
|
||||
Ok(RuntimeHookConfig {
|
||||
pre_tool_use: optional_hook_command_array(hooks, "PreToolUse", context)?
|
||||
.unwrap_or_default(),
|
||||
post_tool_use: optional_hook_command_array(hooks, "PostToolUse", context)?
|
||||
.unwrap_or_default(),
|
||||
post_tool_use_failure: optional_hook_command_array(hooks, "PostToolUseFailure", context)?
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
Ok(parse_hooks_object_partial(hooks, context))
|
||||
}
|
||||
|
||||
fn parse_hooks_object_partial(
|
||||
hooks: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
) -> RuntimeHookConfig {
|
||||
let mut config = RuntimeHookConfig::default();
|
||||
parse_hook_event_partial(
|
||||
&mut config,
|
||||
hooks,
|
||||
"PreToolUse",
|
||||
context,
|
||||
|config, command| {
|
||||
config.pre_tool_use.push(command);
|
||||
},
|
||||
);
|
||||
parse_hook_event_partial(
|
||||
&mut config,
|
||||
hooks,
|
||||
"PostToolUse",
|
||||
context,
|
||||
|config, command| {
|
||||
config.post_tool_use.push(command);
|
||||
},
|
||||
);
|
||||
parse_hook_event_partial(
|
||||
&mut config,
|
||||
hooks,
|
||||
"PostToolUseFailure",
|
||||
context,
|
||||
|config, command| {
|
||||
config.post_tool_use_failure.push(command);
|
||||
},
|
||||
);
|
||||
for event in hooks.keys().filter(|event| !is_supported_hook_event(event)) {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.clone(),
|
||||
index: None,
|
||||
hook_index: None,
|
||||
kind: "unknown_hook_event".to_string(),
|
||||
error_field: event.clone(),
|
||||
reason: format!("{context}: unknown hook event {event}"),
|
||||
});
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
fn is_supported_hook_event(event: &str) -> bool {
|
||||
matches!(event, "PreToolUse" | "PostToolUse" | "PostToolUseFailure")
|
||||
}
|
||||
|
||||
fn parse_hook_event_partial(
|
||||
config: &mut RuntimeHookConfig,
|
||||
hooks: &BTreeMap<String, JsonValue>,
|
||||
event: &str,
|
||||
context: &str,
|
||||
mut push_command: impl FnMut(&mut RuntimeHookConfig, RuntimeHookCommand),
|
||||
) {
|
||||
let Some(value) = hooks.get(event) else {
|
||||
return;
|
||||
};
|
||||
let Some(array) = value.as_array() else {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: None,
|
||||
hook_index: None,
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: event.to_string(),
|
||||
reason: format!("{context}: field {event} must be an array"),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, item) in array.iter().enumerate() {
|
||||
if let Some(command) = item.as_str() {
|
||||
if command.trim().is_empty() {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: Some(index),
|
||||
hook_index: None,
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: "command".to_string(),
|
||||
reason: format!("{context}: field {event}[{index}] must be a non-empty string"),
|
||||
});
|
||||
} else {
|
||||
push_command(config, RuntimeHookCommand::new(command.to_string()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(entry) = item.as_object() else {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: Some(index),
|
||||
hook_index: None,
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: event.to_string(),
|
||||
reason: format!(
|
||||
"{context}: field {event}[{index}] must be a string or hook object"
|
||||
),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
let matcher = match optional_hook_matcher(entry, context, event, index) {
|
||||
Ok(matcher) => matcher,
|
||||
Err(error) => {
|
||||
config.push_invalid_hook(runtime_invalid_hook(
|
||||
event,
|
||||
Some(index),
|
||||
None,
|
||||
"matcher",
|
||||
error,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(hook_array) = entry.get("hooks").and_then(JsonValue::as_array) else {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: Some(index),
|
||||
hook_index: None,
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: "hooks".to_string(),
|
||||
reason: format!("{context}: field {event}[{index}].hooks must be an array"),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
for (hook_index, hook) in hook_array.iter().enumerate() {
|
||||
let Some(hook_object) = hook.as_object() else {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: Some(index),
|
||||
hook_index: Some(hook_index),
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: "hooks".to_string(),
|
||||
reason: format!(
|
||||
"{context}: field {event}[{index}].hooks[{hook_index}] must be an object"
|
||||
),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
if let Some(hook_type) = hook_object.get("type") {
|
||||
let Some(hook_type) = hook_type.as_str() else {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: Some(index),
|
||||
hook_index: Some(hook_index),
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: "type".to_string(),
|
||||
reason: format!(
|
||||
"{context}: field {event}[{index}].hooks[{hook_index}].type must be a string"
|
||||
),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
if hook_type != "command" {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: Some(index),
|
||||
hook_index: Some(hook_index),
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: "type".to_string(),
|
||||
reason: format!(
|
||||
"{context}: field {event}[{index}].hooks[{hook_index}].type must be \"command\""
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(command) = hook_object
|
||||
.get("command")
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|command| !command.trim().is_empty())
|
||||
else {
|
||||
config.push_invalid_hook(RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index: Some(index),
|
||||
hook_index: Some(hook_index),
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: "command".to_string(),
|
||||
reason: format!(
|
||||
"{context}: field {event}[{index}].hooks[{hook_index}].command must be a non-empty string"
|
||||
),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
push_command(
|
||||
config,
|
||||
RuntimeHookCommand::with_matcher(command.to_string(), matcher.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_invalid_hook(
|
||||
event: &str,
|
||||
index: Option<usize>,
|
||||
hook_index: Option<usize>,
|
||||
error_field: &str,
|
||||
error: ConfigError,
|
||||
) -> RuntimeInvalidHookConfig {
|
||||
RuntimeInvalidHookConfig {
|
||||
event: event.to_string(),
|
||||
index,
|
||||
hook_index,
|
||||
kind: "invalid_hooks_config".to_string(),
|
||||
error_field: error_field.to_string(),
|
||||
reason: config_error_detail(&error),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_hooks_config(
|
||||
@@ -2108,77 +2344,6 @@ fn optional_string_array(
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_hook_command_array(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<Vec<RuntimeHookCommand>>, ConfigError> {
|
||||
let Some(value) = object.get(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(array) = value.as_array() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be an array"
|
||||
)));
|
||||
};
|
||||
|
||||
let mut commands = Vec::new();
|
||||
for (index, item) in array.iter().enumerate() {
|
||||
if let Some(command) = item.as_str() {
|
||||
commands.push(RuntimeHookCommand::new(command.to_string()));
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(entry) = item.as_object() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key}[{index}] must be a string or hook object"
|
||||
)));
|
||||
};
|
||||
let matcher = optional_hook_matcher(entry, context, key, index)?;
|
||||
let hooks = entry
|
||||
.get("hooks")
|
||||
.and_then(JsonValue::as_array)
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
"{context}: field {key}[{index}].hooks must be an array"
|
||||
))
|
||||
})?;
|
||||
for (hook_index, hook) in hooks.iter().enumerate() {
|
||||
let Some(hook_object) = hook.as_object() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key}[{index}].hooks[{hook_index}] must be an object"
|
||||
)));
|
||||
};
|
||||
if let Some(hook_type) = hook_object.get("type") {
|
||||
let Some(hook_type) = hook_type.as_str() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key}[{index}].hooks[{hook_index}].type must be a string"
|
||||
)));
|
||||
};
|
||||
if hook_type != "command" {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key}[{index}].hooks[{hook_index}].type must be \"command\""
|
||||
)));
|
||||
}
|
||||
}
|
||||
let command = hook_object
|
||||
.get("command")
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|command| !command.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
"{context}: field {key}[{index}].hooks[{hook_index}].command must be a non-empty string"
|
||||
))
|
||||
})?;
|
||||
commands.push(RuntimeHookCommand::with_matcher(
|
||||
command.to_string(),
|
||||
matcher.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Some(commands))
|
||||
}
|
||||
|
||||
fn optional_hook_matcher(
|
||||
entry: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
@@ -2428,7 +2593,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_object_style_hook_entries_without_command() {
|
||||
fn records_object_style_hook_entries_without_command_441() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
@@ -2440,12 +2605,20 @@ mod tests {
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should reject malformed hook entry");
|
||||
.expect("config should load valid siblings and record malformed hook entry");
|
||||
|
||||
assert!(error
|
||||
.to_string()
|
||||
assert!(loaded.hooks().pre_tool_use().is_empty());
|
||||
assert_eq!(loaded.hooks().invalid_count(), 1);
|
||||
assert_eq!(
|
||||
loaded.hooks().invalid_hooks()[0].kind,
|
||||
"invalid_hooks_config"
|
||||
);
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].error_field, "command");
|
||||
assert!(loaded.hooks().invalid_hooks()[0]
|
||||
.reason
|
||||
.contains("command must be a non-empty string"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
@@ -3188,7 +3361,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_hook_entries_before_merge() {
|
||||
fn loads_valid_hook_entries_and_records_invalid_siblings_441() {
|
||||
// given
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
@@ -3208,19 +3381,21 @@ mod tests {
|
||||
)
|
||||
.expect("write invalid project settings");
|
||||
|
||||
// when
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
.expect("config should load valid hook entries and record invalid siblings");
|
||||
|
||||
// then — config validation now catches the mixed array before the hooks parser
|
||||
let rendered = error.to_string();
|
||||
assert!(
|
||||
rendered.contains("hooks.PreToolUse")
|
||||
&& rendered.contains("must be an array of strings"),
|
||||
"expected validation error for hooks.PreToolUse, got: {rendered}"
|
||||
assert_eq!(loaded.hooks().pre_tool_use(), &["project".to_string()]);
|
||||
assert_eq!(loaded.hooks().invalid_count(), 1);
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
|
||||
assert_eq!(
|
||||
loaded.hooks().invalid_hooks()[0].kind,
|
||||
"invalid_hooks_config"
|
||||
);
|
||||
assert!(!rendered.contains("merged settings.hooks"));
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].index, Some(1));
|
||||
assert!(loaded.hooks().invalid_hooks()[0]
|
||||
.reason
|
||||
.contains("must be a string or hook object"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
@@ -3363,7 +3538,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_wrong_type_for_known_field_with_field_path() {
|
||||
fn hook_event_wrong_type_is_recorded_without_config_failure_441() {
|
||||
// given
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
@@ -3377,29 +3552,145 @@ mod tests {
|
||||
)
|
||||
.expect("write user settings");
|
||||
|
||||
// when
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
.expect("config should record malformed hook event without failing");
|
||||
|
||||
// then
|
||||
let rendered = error.to_string();
|
||||
assert!(
|
||||
rendered.contains(&user_settings.display().to_string()),
|
||||
"error should include file path, got: {rendered}"
|
||||
assert!(loaded.hooks().pre_tool_use().is_empty());
|
||||
assert_eq!(loaded.hooks().invalid_count(), 1);
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
|
||||
assert_eq!(
|
||||
loaded.hooks().invalid_hooks()[0].kind,
|
||||
"invalid_hooks_config"
|
||||
);
|
||||
assert_eq!(loaded.hooks().invalid_hooks()[0].index, None);
|
||||
assert!(loaded.hooks().invalid_hooks()[0]
|
||||
.reason
|
||||
.contains("field PreToolUse must be an array"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_all_invalid_hook_siblings_instead_of_halting_at_first_441() {
|
||||
// ROADMAP #441 finding (c): first-error-only halting means users must fix
|
||||
// one hook at a time. After #441 partial fix, all invalid entries in the
|
||||
// same config are collected.
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"hooks":{"PreToolUse":[42],"PostToolUse":"not-an-array","InvalidEvent":["cmd"]}}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should collect all invalid hooks without halting at first");
|
||||
|
||||
assert!(loaded.hooks().pre_tool_use().is_empty());
|
||||
assert!(loaded.hooks().post_tool_use().is_empty());
|
||||
// Three distinct invalid entries: 42, wrong type, unknown event
|
||||
assert_eq!(loaded.hooks().invalid_count(), 3);
|
||||
|
||||
let invalid = loaded.hooks().invalid_hooks();
|
||||
// PreToolUse[0]=42
|
||||
assert_eq!(invalid[0].event, "PreToolUse");
|
||||
assert_eq!(invalid[0].index, Some(0));
|
||||
assert_eq!(invalid[0].kind, "invalid_hooks_config");
|
||||
// PostToolUse wrong type
|
||||
assert_eq!(invalid[1].event, "PostToolUse");
|
||||
assert_eq!(invalid[1].index, None);
|
||||
assert_eq!(invalid[1].kind, "invalid_hooks_config");
|
||||
// Unknown event
|
||||
assert_eq!(invalid[2].event, "InvalidEvent");
|
||||
assert_eq!(invalid[2].index, None);
|
||||
assert_eq!(invalid[2].kind, "unknown_hook_event");
|
||||
assert!(invalid[2]
|
||||
.reason
|
||||
.contains("unknown hook event InvalidEvent"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_hook_events_recorded_with_correct_kind_441() {
|
||||
// ROADMAP #441 finding (a): unknown event names like Stop/Notification
|
||||
// should not reject entire hooks config; they are recorded as invalid.
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"hooks":{"PreToolUse":["valid-cmd"],"Stop":"not-an-array","Notification":[{}]}}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load valid hooks and record unknown event siblings");
|
||||
|
||||
// Valid PreToolUse hook should load
|
||||
assert_eq!(loaded.hooks().pre_tool_use(), &["valid-cmd".to_string()]);
|
||||
// Stop and Notification are unknown events; each gets one invalid entry
|
||||
// Notification:[{}] also has an empty-object entry issue but since we
|
||||
// don't parse unknown events, only the unknown-event invalid is recorded
|
||||
let invalid = loaded.hooks().invalid_hooks();
|
||||
assert!(
|
||||
rendered.contains("hooks"),
|
||||
"error should include field path component 'hooks', got: {rendered}"
|
||||
invalid.len() >= 2,
|
||||
"expected at least 2 invalid hooks, got {}",
|
||||
invalid.len()
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("PreToolUse"),
|
||||
"error should describe the type mismatch, got: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("array"),
|
||||
"error should describe the expected type, got: {rendered}"
|
||||
|
||||
let stop = invalid
|
||||
.iter()
|
||||
.find(|h| h.event == "Stop")
|
||||
.expect("Stop invalid hook");
|
||||
assert_eq!(stop.kind, "unknown_hook_event");
|
||||
assert_eq!(stop.index, None);
|
||||
assert!(stop.reason.contains("unknown hook event Stop"));
|
||||
|
||||
let notif = invalid
|
||||
.iter()
|
||||
.find(|h| h.event == "Notification")
|
||||
.expect("Notification invalid hook");
|
||||
assert_eq!(notif.kind, "unknown_hook_event");
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_claude_code_hook_format_loads_without_error_441() {
|
||||
// ROADMAP #441: the Claude Code documented hook format
|
||||
// {"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"..."}]}]}}
|
||||
// must load without config_load_error.
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"/bin/echo pretool"}]}]}}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("Claude Code documented hook format must load without error");
|
||||
|
||||
assert_eq!(
|
||||
loaded.hooks().pre_tool_use(),
|
||||
&["/bin/echo pretool".to_string()]
|
||||
);
|
||||
assert_eq!(loaded.hooks().invalid_count(), 0);
|
||||
let entries = loaded.hooks().pre_tool_use_entries();
|
||||
assert_eq!(entries[0].matcher(), Some("Read"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
@@ -118,10 +118,7 @@ impl FieldType {
|
||||
Self::StringArray => value
|
||||
.as_array()
|
||||
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
|
||||
Self::HookArray => value.as_array().is_some_and(|arr| {
|
||||
arr.iter()
|
||||
.all(|entry| entry.as_str().is_some() || entry.as_object().is_some())
|
||||
}),
|
||||
Self::HookArray => true,
|
||||
Self::RulesImport => {
|
||||
value.as_str().is_some()
|
||||
|| value
|
||||
@@ -439,8 +436,56 @@ fn validate_object_keys(
|
||||
result
|
||||
}
|
||||
|
||||
/// Emit deprecation warnings for bare string hook entries in the hooks object.
|
||||
/// Legacy `["command-string"]` arrays still load but suggest migration to the
|
||||
/// structured `{matcher, hooks:[{type, command}]}` form.
|
||||
fn validate_hook_entry_format(
|
||||
hooks: &BTreeMap<String, JsonValue>,
|
||||
source: &str,
|
||||
path_display: &str,
|
||||
) -> ValidationResult {
|
||||
let mut result = ValidationResult {
|
||||
errors: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
for spec in HOOKS_FIELDS {
|
||||
let Some(value) = hooks.get(spec.name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(array) = value.as_array() else {
|
||||
continue;
|
||||
};
|
||||
for item in array {
|
||||
if item.as_str().is_some() {
|
||||
result.warnings.push(ConfigDiagnostic {
|
||||
path: path_display.to_string(),
|
||||
field: format!("hooks.{}", spec.name),
|
||||
line: find_key_line(source, spec.name),
|
||||
kind: DiagnosticKind::Deprecated {
|
||||
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
|
||||
},
|
||||
});
|
||||
// One deprecation warning per event is enough
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
|
||||
let input_lower = input.to_ascii_lowercase();
|
||||
// #461: prefix-aware matching — if input is a prefix of a candidate,
|
||||
// treat it as distance 0 (perfect prefix match) to avoid edit-distance
|
||||
// misranking (e.g., "mcp" → "env" instead of "mcpServers").
|
||||
let prefix_match = candidates
|
||||
.iter()
|
||||
.filter(|c| c.to_ascii_lowercase().starts_with(&input_lower))
|
||||
.min_by_key(|c| c.len())
|
||||
.map(|name| name.to_string());
|
||||
if prefix_match.is_some() {
|
||||
return prefix_match;
|
||||
}
|
||||
candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| {
|
||||
@@ -510,6 +555,7 @@ pub fn validate_config_file(
|
||||
source,
|
||||
&path_display,
|
||||
));
|
||||
result.merge(validate_hook_entry_format(hooks, source, &path_display));
|
||||
}
|
||||
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
|
||||
result.merge(validate_object_keys(
|
||||
@@ -714,7 +760,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_nested_hooks_keys() {
|
||||
// given
|
||||
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
|
||||
let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
@@ -723,7 +769,12 @@ mod tests {
|
||||
|
||||
// then
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert_eq!(
|
||||
result.warnings.len(),
|
||||
1,
|
||||
"expected only the unknown key warning, got {:?}",
|
||||
result.warnings
|
||||
);
|
||||
assert_eq!(result.warnings[0].field, "hooks.BadHook");
|
||||
}
|
||||
|
||||
@@ -739,15 +790,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_hook_entry_types() {
|
||||
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
|
||||
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "hooks.PreToolUse");
|
||||
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -847,7 +897,7 @@ mod tests {
|
||||
// given
|
||||
let source = r#"{
|
||||
"model": "opus",
|
||||
"hooks": {"PreToolUse": ["guard"]},
|
||||
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
|
||||
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
|
||||
"mcpServers": {},
|
||||
"sandbox": {"enabled": false}
|
||||
|
||||
@@ -71,8 +71,8 @@ pub use config::{
|
||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
|
||||
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,7 @@ fn assert_doctor_help_json_contract(parsed: &Value) {
|
||||
assert!(checks.iter().any(|check| check == "boot preflight"));
|
||||
assert!(checks.iter().any(|check| check == "memory"));
|
||||
assert!(checks.iter().any(|check| check == "mcp validation"));
|
||||
assert!(checks.iter().any(|check| check == "hook validation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -840,14 +841,19 @@ fn acp_guidance_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("acp-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let acp = assert_json_command(&root, &["--output-format", "json", "acp"]);
|
||||
// #443: acp serve exits 2 (not implemented) instead of 0
|
||||
let output = run_claw(&root, &["--output-format", "json", "acp"], &[]);
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(2),
|
||||
"acp should exit 2 (not implemented)"
|
||||
);
|
||||
let acp: Value =
|
||||
serde_json::from_slice(&output.stdout).expect("acp stdout should be valid json");
|
||||
assert_eq!(acp["kind"], "acp");
|
||||
assert_eq!(acp["schema_version"], "1.0");
|
||||
assert_eq!(acp["status"], "unsupported");
|
||||
assert_eq!(acp["phase"], "discoverability_only");
|
||||
assert_eq!(acp["status"], "not_implemented");
|
||||
assert_eq!(acp["supported"], false);
|
||||
assert_eq!(acp["exit_code"], 0);
|
||||
assert_eq!(acp["serve_alias_only"], true);
|
||||
assert_eq!(acp["protocol"]["json_rpc"], false);
|
||||
assert_eq!(acp["protocol"]["daemon"], false);
|
||||
assert!(acp["protocol"]["endpoint"].is_null());
|
||||
@@ -855,12 +861,23 @@ fn acp_guidance_emits_json_when_requested() {
|
||||
acp["contracts"]["unsupported_invocation_kind"],
|
||||
"unsupported_acp_invocation"
|
||||
);
|
||||
assert_eq!(acp["discoverability_tracking"], "ROADMAP #64a");
|
||||
assert_eq!(acp["tracking"], "ROADMAP #76 / #3033 / #3004");
|
||||
// #443: internal tracking IDs removed from public JSON
|
||||
assert!(
|
||||
acp.get("discoverability_tracking").is_none(),
|
||||
"discoverability_tracking should be removed (#443)"
|
||||
);
|
||||
assert!(
|
||||
acp.get("tracking").is_none(),
|
||||
"tracking should be removed (#443)"
|
||||
);
|
||||
assert!(
|
||||
acp.get("recommended_workflows").is_none(),
|
||||
"recommended_workflows should be removed (#443)"
|
||||
);
|
||||
assert!(acp["message"]
|
||||
.as_str()
|
||||
.expect("acp message")
|
||||
.contains("discoverability alias"));
|
||||
.contains("not implemented"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1459,7 +1476,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 10);
|
||||
assert_eq!(checks.len(), 12);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
@@ -1479,8 +1496,10 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
check_names,
|
||||
vec![
|
||||
"auth",
|
||||
"base urls",
|
||||
"config",
|
||||
"mcp validation",
|
||||
"hook validation",
|
||||
"install source",
|
||||
"workspace",
|
||||
"memory",
|
||||
@@ -2063,7 +2082,7 @@ fn local_json_surfaces_have_non_empty_action_contract_714() {
|
||||
&git_workspace,
|
||||
strings(&["--output-format", "json", "diff"]),
|
||||
),
|
||||
(&workspace, strings(&["--output-format", "json", "acp"])),
|
||||
// #443: ACP exits 2 (not implemented); tested separately in acp_guidance_emits_json_when_requested
|
||||
(&workspace, strings(&["--output-format", "json", "config"])),
|
||||
(
|
||||
&workspace,
|
||||
@@ -3866,7 +3885,7 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
};
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_str.trim()).expect("mcp bogus should emit JSON");
|
||||
assert_eq!(parsed["error_kind"], "unknown_mcp_action");
|
||||
assert_eq!(parsed["error_kind"], "unsupported_action");
|
||||
let hint = parsed["hint"].as_str().unwrap_or("");
|
||||
assert!(!hint.is_empty(), "mcp bogus hint must be non-null (#774)");
|
||||
}
|
||||
@@ -4146,8 +4165,8 @@ fn acp_unsupported_invocation_has_hint_782() {
|
||||
.expect("hint must be non-null (#782)");
|
||||
assert!(!hint.is_empty(), "hint must not be empty");
|
||||
assert!(
|
||||
hint.contains("discoverability") || hint.contains("ROADMAP"),
|
||||
"hint should explain the discoverability-only status, got: {hint:?}"
|
||||
hint.contains("not implemented") || hint.contains("unsupported"),
|
||||
"hint should explain the not-implemented status, got: {hint:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -530,8 +530,9 @@ fn resumed_help_command_emits_structured_json() {
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert!(parsed["text"].as_str().is_some());
|
||||
let text = parsed["text"].as_str().unwrap();
|
||||
// #338: resume help now uses 'message' field for parity with top-level help
|
||||
assert!(parsed["message"].as_str().is_some());
|
||||
let text = parsed["message"].as_str().unwrap();
|
||||
assert!(text.contains("/status"), "help text should list /status");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user