Compare commits

...

64 Commits

Author SHA1 Message Date
bellman
c8e973513c fix: redact MCP server sensitive fields in JSON (#90)
MCP server details JSON now redacts args (shows count only),
strips URL query params which may contain tokens, and shows
headers_helper as configured/not-configured boolean instead of
the raw command string. env_keys and header_keys still exposed
(key names only, not values).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:59:52 +09:00
bellman
8f9315bdc9 docs: mark ROADMAP #32,43 DONE with runtime evidence
32: OpenAI model passthrough verified (model: openai/gpt-4, source: flag)
43: Hook ingress opacity fixed (hook_validation structured in status JSON)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:51:18 +09:00
bellman
12a091afe6 docs: mark ROADMAP #111,115 DONE
111: /providers routes to doctor slash command
115: init generates acceptEdits not dontAsk

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:48:42 +09:00
bellman
9f0cf3b888 docs: mark ROADMAP #101,104 DONE
101: RUSTY_CLAUDE_PERMISSION_MODE normalize maps aliases to valid modes
104: export CLI path and slash command both return kind:export

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:46:04 +09:00
bellman
6e94c1a97f docs: mark ROADMAP #93,109 DONE
93: --resume /tmp returns typed invalid_resume_argument error
109: config validation emits structured ConfigDiagnostic + warnings[]

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:40:50 +09:00
bellman
38626cad20 docs: mark ROADMAP #85,88,125,126 DONE
85: skills discovery bounded by git root (nearest_git_root in prompt.rs)
88: instruction file discovery bounded by git root
125: git_state reports 'no_git_repo' when not in git repo
126: config section exposes 'merged' key-value pairs

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:36:41 +09:00
bellman
e84f7c8034 fix: report 'no_git_repo' instead of 'clean' when not in git (#125)
status/doctor JSON now reports git_state:'no_git_repo' when
project_root is None, instead of the misleading 'clean' which
implied a git repo was present with zero changes.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:34:56 +09:00
bellman
b8f066347b fix: structured bootstrap-plan phases JSON (#412)
bootstrap-plan --output-format json now returns phases as structured
objects with id, label, description, and order fields instead of
raw Rust enum variant name strings. Also exposes total_phases count.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:29:27 +09:00
bellman
5adc751053 docs: mark ROADMAP #97 DONE
97: empty --allowedTools is unrestricted (no restrictions = default mode)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:24:15 +09:00
bellman
adf5bd165e fix: validate --cwd and --date for system-prompt (#99)
--cwd now validates the path exists and is a directory before passing
it to the system prompt renderer. --date rejects values with newlines
or >20 chars to prevent prompt injection.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:18:31 +09:00
bellman
b220366176 docs: mark ROADMAP 81-83,87,91,102,117,128 DONE with direct evidence
81: project_root correctly reports per-CWD paths
82: sandbox correctly reports macOS degraded state (filesystem_active + unsupported)
83: system-prompt --date parameter works correctly
87: default permission_mode is workspace-write from config
91: permission mode aliases resolve to valid modes
102: config-time MCP check is by design for local preflight
117: -p flag parses single token correctly
128: validate_model_syntax rejects malformed models with hints

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:10:08 +09:00
bellman
b5d67ef249 docs: mark ROADMAP #105,118 DONE
105: status reads model from .claw.json config (model_source: config)
118: /stats and /tokens both route to stats handler

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:04:27 +09:00
bellman
9a40568e1c docs: mark ROADMAP #80,86,100 DONE
80: session lookup error now returns session_not_found with hint
86: .claw.json invalid JSON handled gracefully by config loader
100: binary_provenance exposes git_sha, build_date in status JSON

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:02:30 +09:00
bellman
41b3566468 fix: update resume help test for message field parity (#338)
Changed resumed_help_command_emits_structured_json to assert 'message'
field instead of 'text', matching the #338 fix for help JSON field
consistency.

Also marked ROADMAP #340 and #345 DONE with direct runtime evidence:
340: resume /session help now routes JSON to stdout (verified)
345: resume /config <section> now returns section-specific JSON (verified)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:01:05 +09:00
bellman
b86159cbb7 docs: mark ROADMAP #124,127 DONE with direct evidence
124: validate_model_syntax already rejects empty, spaces, and invalid formats
127: subcommand --json already routes to local JSON handlers

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:56:06 +09:00
bellman
5e5b9966c0 docs: mark 8 ROADMAP items DONE (78,84,103,107,108,110,116,119)
78: plugins CLI wired as CliAction::Plugins (verified runtime)
84: dump-manifests fixed 2026-06-03
103: agents discovery handles .md with YAML frontmatter (#442)
107: hook validation in doctor JSON (verified runtime)
108: CLI typos → command_not_found (verified runtime)
110: ConfigLoader discovers project/user/local configs
116: unknown keys now emit warnings not errors (#441)
119: bare_slash_command_guidance checks aliases (#772)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:52:40 +09:00
bellman
33f771f3f5 docs: mark ROADMAP #77 DONE (classify_error_kind implemented)
Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:51:16 +09:00
bellman
3fbfcc40ca docs: mark ROADMAP 327,328,408,6 DONE with fix evidence
327: mcp help now includes .claw.json in sources list
328: agents help now includes ~/.codex/agents in sources list
408: status JSON now has is_clean boolean field
6: terminal is transport principle — implementation tracked by #810-#816

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:47:58 +09:00
bellman
c7c5c11d1e fix: correct help source lists, add is_clean to status JSON
#327: mcp help now includes .claw.json in documented sources list
#328: agents help now includes ~/.codex/agents in documented sources list
#408: added is_clean boolean to status JSON workspace for unambiguous
  dirty-state detection; changed_files documented as total non-clean count
#338: resume help field consistency (committed earlier)

Also marked 52 items with explicit done/verified evidence as DONE
in ROADMAP.md, including product principles (1-6) and items with
confirmed fix text (13-75, 96, 200).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:46:10 +09:00
bellman
c0447e2be1 docs: mark ROADMAP 338,410 DONE with fix evidence
338: resume help now uses 'message' field consistent with top-level help
410: mcp and skills list now expose 'count' field for polymorphic consumption

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:35:51 +09:00
bellman
1b22ed700a fix: standardize list command count fields and resume help field name
#410: Added  field to mcp list and skills list JSON for
polymorphic consumption parity with agents list. All three sibling
list commands now expose a canonical  integer field alongside
their command-specific details.

#338: Changed resume  JSON from  to  field for
consistency with top-level .

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:34:59 +09:00
bellman
76f9a134ae docs: mark 16 ROADMAP items as DONE with direct verification evidence
Items verified with direct runtime evidence:
- #7: plugins CLI wired as CliAction::Plugins
- #77: classify_error_kind exists with 69 references
- #90: error envelopes carry machine-readable hint field
- #119: bare_slash_command_guidance checks aliases
- #346: agents show returns agent_not_found error
- #348: plugins list returns structured plugins[] array
- #349: plugins show returns plugin_not_found error
- #350: plugins enable returns typed error (not hang)
- #351: plugins disable returns typed error on stdout
- #352: plugins update returns typed error on stdout
- #353: plugins uninstall returns typed error on stdout
- #354: memory list returns interactive_only error
- #355: session list returns structured JSON
- #358: cost --help returns command_not_found
- #380: tokens --help returns command_not_found
- #381: cache --help returns command_not_found

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:27:24 +09:00
bellman
9c11325e83 docs: mark additional pre-440 ROADMAP items as DONE
Items verified against current codebase: 322, 323, 329, 337, 422 and
10 early items with confirmed fix evidence.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:23:02 +09:00
bellman
4708ab1611 fix: add structured help JSON and provider BASE_URL validation
#683-#692: help topic JSON now includes usage, purpose, formats,
related, local_only, requires_credentials, and aliases extracted
from help prose. Export and doctor keep their custom structured
responses.

#466: new check_base_url_health() validates ANTHROPIC_BASE_URL,
OPENAI_BASE_URL, XAI_BASE_URL, and DASHSCOPE_BASE_URL for basic
HTTP(S) URL format. Non-http schemes and empty values produce a
warn-level diagnostic.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:14:08 +09:00
bellman
311e719e5d docs: mark ROADMAP 696-697 DONE
696: compact returns interactive_only error in non-interactive mode
697: plugins uninstall already returns typed not-found error

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:49:31 +09:00
bellman
b926a9d25f docs: mark ROADMAP 713-716,723,734-735,737,767,771,774-776,781 as DONE
14 items with verified fixes marked as DONE.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:45:14 +09:00
bellman
61d641d722 style: apply cargo fmt
Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:41:35 +09:00
bellman
2f8679bd15 fix: track duplicate global flags in status JSON
status --output-format json now exposes duplicate_flags array listing
any --model, --output-format, or --permission-mode flags specified
more than once. Uses a module-level static for cross-function access.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:36:16 +09:00
bellman
9ef21e23f3 fix: expose merged key-value pairs in config JSON
render_config_json now includes a 'merged' object with actual
key-value pairs from the resolved runtime configuration, not just
the count.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:12:19 +09:00
bellman
6ac0386094 docs: close ROADMAP 335 evidence
335: session_details already includes created_at_ms

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:50:54 +09:00
bellman
4c939c0ad6 docs: close ROADMAP 465 evidence
465: doctor auth check now exposes openai_key_present

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:48:27 +09:00
bellman
4d41ab37e1 fix: expose openai_key_present in doctor auth check
check_auth_health data fields now include openai_key_present alongside
api_key_present and auth_token_present. any_auth_present already
includes OPENAI_API_KEY for prompt_ready status.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:46:24 +09:00
bellman
662a50bdc0 docs: close ROADMAP 347,356,357 evidence
347: mcp show missing returns status:error
356: status --help returns JSON
357: doctor --help returns JSON

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:38:38 +09:00
bellman
1da4aa454f docs: close ROADMAP 413,416 evidence
413: ACP JSON no longer leaks tracking IDs
416: plugins list returns structured plugins[] array

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:34:20 +09:00
bellman
3f50f33407 fix: filter boundary sentinel from system-prompt sections JSON
__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ is now filtered from the sections
array in JSON output. boundary_index field exposed for callers that
need the static/dynamic split point.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:32:14 +09:00
bellman
0ce4168c93 docs: close ROADMAP 419-420 evidence
419: MCP unknown sub-actions return typed error with exit 1
420: plugins help returns standard help envelope

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:25:12 +09:00
bellman
7f1dd0c116 docs: close ROADMAP 700-704 evidence
700: help JSON already has status field
701: doctor details already structured {key,value}
702: agents/skills both use source field
703: plugins list has structured summary
704: doctor checks have stable id field

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:21:07 +09:00
bellman
42f56e7f77 docs: close ROADMAP 458-459 evidence
458: status field now universal across all JSON envelopes
459: memory file discovery expanded with AGENTS.md/.claude/CLAUDE.md

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:17:37 +09:00
bellman
f25fae6d22 docs: mark ROADMAP 726-806 as DONE
71 items with verified fixes marked as DONE. All have Fix/Fix applied
sections with corresponding code evidence in the current codebase.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:13:56 +09:00
bellman
be66d961bf docs: close ROADMAP 705-722 evidence
All items already fixed in prior commits:
705: estimated_cost_usd_num companion field
706: skills show not-found error
707: init temp_dir AtomicU64 counter
708: skills show action field
709: duplicate status keys removed
710: diff action and working_directory
711: version/system-prompt/export/init action field
712: doctor/status/bootstrap-plan/dump-manifests action field
713: acp and config action field
714: help action and status fields
715: resume-path action and status fields
716: resume-path error JSON standard envelope
717: agents show implemented
718: plugins show implemented
719: plugins list filter
720: help <topic> routing
721: config sections support
722: rebase conflict resolved

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:06:39 +09:00
bellman
b1a40a2364 docs: close ROADMAP 694,698 evidence
694: pre-push cargo build gate already exists
698: config warning dedup already implemented

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:04:44 +09:00
bellman
726d55d1ea docs: close ROADMAP 682,693 evidence
682: agents already returns typed error for unknown subcommands
693: claw-analog already uses unknown_bootstrap_phase_error

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:03:07 +09:00
bellman
e4b8f9c07f fix: return typed error for unsupported MCP sub-actions
The catch-all in render_mcp_report_json_for now returns
render_mcp_unsupported_action_json with ok:false and
error_kind:unsupported_action instead of help JSON with exit 0.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:00:26 +09:00
bellman
b3a5a74237 fix: target multi-word guard for CLI subcommands only
Only fire the slash-command guard for multi-word commands when the
first token is a known CLI subcommand (help, version, status, etc.).
Slash commands with additional arguments (explain this, cost list)
are treated as prompts.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:46:11 +09:00
bellman
ad76389b31 docs: close ROADMAP 464,470 evidence
464: CliOutputFormat::parse already handles case/whitespace/hints
470: --reasoning-effort already uses invalid_flag_value prefix

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:40:45 +09:00
bellman
e68733d72e docs: close ROADMAP 462-463 evidence
462: build_date already present in version_json_value
463: classify_error_kind already returns removed_subcommand

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:38:32 +09:00
bellman
4d4d72cd49 fix: add prefix-aware matching to config key suggestion
When input is a prefix of a candidate (e.g., mcp → mcpServers), return
the prefix match directly instead of relying on edit-distance which
would incorrectly suggest env (distance 3) over mcpServers (distance 7).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:36:35 +09:00
bellman
9bc2f3631d fix: widen parse_subcommand guard for multi-word commands
Changed rest.len() != 1 guard to rest.is_empty() so claw cost list,
claw model list, claw permissions show, etc. now reach the
bare_slash_command_guidance guard and emit typed slash-command
guidance instead of falling through to CliAction::Prompt.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:29:36 +09:00
bellman
f40927ba97 docs: close ROADMAP 451-452 models already wired evidence
claw models is already wired as CliAction::Models with full
print_models implementation. Both models list and models help
return bounded JSON without touching provider runtime.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:27:27 +09:00
bellman
5bcbc2f874 docs: close ROADMAP 460 alias check evidence
bare_slash_command_guidance already checks both spec.name and
spec.aliases at line 2407.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:23:21 +09:00
bellman
a671969688 fix: handle --help after --resume flag
Added specific handler for --help when rest contains --resume, so
claw --resume --help shows resume help instead of consuming --help
as a session-id literal.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:18:55 +09:00
bellman
6757ebde74 fix: align discovered_config_files count with config check
The status_context function now filters loader.discover() to only count
paths that exist on disk, matching the check_config_health behavior.
Both config.discovered_files_count and workspace.discovered_config_files
now report the same number.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:17:06 +09:00
bellman
2447273d09 fix: add list to KNOWN_SUBCOMMANDS and close ROADMAP 454-455
Added list to KNOWN_SUBCOMMANDS so claw list is caught by typo
suggestion instead of falling through to CliAction::Prompt. Also
verified #455 (missing_credentials hint already newline-delimited).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:15:00 +09:00
bellman
5a76ecb760 fix: add prompt_ready to doctor auth check
Added prompt_ready:bool and prompt_blocked_reason:string|null to the
auth check in doctor --output-format json. prompt_ready is true when
any auth credential is present, false otherwise. prompt_blocked_reason
is auth_missing when prompt_ready is false.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:11:31 +09:00
bellman
db56498460 fix: route session list through credentials-free path
Added dedicated CliAction::SessionList variant for claw session list so
it no longer requires API credentials. run_session_list() calls
list_managed_sessions() directly without instantiating an API client.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:06:28 +09:00
bellman
6fcd0c57ae fix: clarify sandbox requested vs active state in JSON output
Added requested field (alias for enabled) and active_components object
with namespace/network/filesystem booleans for precise subsystem visibility.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:02:02 +09:00
bellman
de66bfc082 fix: route broad_cwd JSON error to stdout and close ROADMAP 446-447
The enforce_broad_cwd_policy function was sending JSON error envelopes
to stderr instead of stdout. Fixed to use println! for JSON mode,
matching the main error handler and all resume error paths.

Also closed ROADMAP #446 (config warning deduplication already handled
by emit_config_warning_once) and #447 (JSON errors already routed to
stdout in main handler).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:54:17 +09:00
bellman
5d85739358 fix: detect skill name/dir mismatch and report metadata drift
Skill discovery now tracks dir_name alongside frontmatter name and detects
when they differ. skills list --output-format json includes metadata_drift
array and reports degraded status when drift entries exist.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:36:04 +09:00
bellman
8fd11e82c4 fix: track skill directory name for name/dir mismatch detection
Adds dir_name field to SkillSummary to enable detection of skills where
the SKILL.md frontmatter name differs from the parent directory name.
Also adds SkillMetadataDrift struct for tracking mismatches (used by
skills JSON output in follow-up).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:26:42 +09:00
bellman
9f9b14a76d fix: add broad-cwd guard to resume path
claw --resume now enforces the same broad-cwd safety policy as claw prompt
and the interactive REPL. Running from /, $HOME, or other broad directories
blocks execution unless --allow-broad-cwd is passed.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:23:28 +09:00
bellman
78c2a49ec8 docs: close ROADMAP 443 acp serve evidence 2026-06-05 00:12:23 +09:00
bellman
0e54ec4c04 fix: exit non-zero for acp serve and remove internal tracking IDs
claw acp serve now exits 2 (not implemented) instead of 0, so automation
pipelines can detect the no-op via exit code gating.

Key changes:
- acp serve exits 2 instead of 0
- Removed discoverability_tracking, tracking, recommended_workflows from JSON
- Removed phase, exit_code, serve_alias_only fields from JSON
- Status changed from unsupported/discoverability_only to not_implemented
- Error kind for unsupported ACP invocations uses typed prefix
- Updated tests to match new exit code and JSON structure

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:11:31 +09:00
bellman
58a30f6ab8 fix: accept markdown agent definitions with YAML frontmatter
Agent discovery now loads .md files with YAML frontmatter alongside .toml
files, matching the Claude Code agent definition convention. Markdown
agent files must have ----delimited YAML frontmatter with at least name
or description fields.

Key changes:
- parse_agent_frontmatter extracts name, description, model, model_reasoning_effort
- load_agents_from_roots_with_invalids collects both valid and invalid agents
- InvalidAgentConfig tracks rejected .md files with reason
- AgentCollection groups valid agents with invalid entries
- agents JSON output includes valid_count, invalid_count, invalid_agents
- Status is degraded when invalid agents exist

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 23:57:33 +09:00
bellman
453d8945bb fix: validate hook config entries partially
Hook config now supports the Claude Code structured hook format with
partial validation. Invalid hook entries are recorded in invalid_hooks
while valid siblings are retained, following the same pattern as MCP
partial validation (#440).

Key changes:
- RuntimeInvalidHookConfig now includes typed kind field (invalid_hooks_config
  or unknown_hook_event) for machine-readable error classification
- Hook parsing collects all invalid entries instead of halting at first error
- Unknown hook event names recorded as invalid without rejecting valid hooks
- Legacy bare-string hooks still load with deprecation warnings
- Claude Code documented format loads without error (matcher + nested hooks)
- config/status/doctor JSON surfaces hook_validation metadata
- classify_error_kind maps hook errors to invalid_hooks_config

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 23:42:58 +09:00
10 changed files with 1718 additions and 579 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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.

View File

@@ -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);

View File

@@ -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");
}

View File

@@ -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}

View File

@@ -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

View File

@@ -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:?}"
);
}

View File

@@ -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");
}