Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ breaking entries are marked **BREAKING**.
`examples_section`, `operations_line`, `command_aliases_line`, and
`subcommand_roster`. `help` and `kaish-tools` both render from these, so a
second surface cannot drift from the first by omission.
- **Nested verbs for wrapped commands** — `Verb::verb()` declares child verbs,
so `git worktree list` is now declarable. Flags, positionals, tail, and
stdin stay leaf-only, refused at `build()` on a node. See
`docs/wrapped_command.md` for the full grammar.

### Fixed
- **`help <tool>` and `kaish-tools <name>` only rendered one level of
Expand Down
48 changes: 35 additions & 13 deletions crates/kaish-kernel/src/tools/wrapped.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,12 @@ pub struct PathCheck {
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderedCall {
/// The selected verb's declared name; `None` for the root verb.
/// The selected verb's own declared name; `None` for the root verb.
/// Never the full path — `git worktree list` still reports `list` here.
pub verb: Option<String>,
/// The full path to the selected verb: `git worktree list`, or the
/// command name alone for the root verb.
pub scope: String,
/// The child's argv, without the executable.
pub argv: Vec<String>,
/// What the child's standard input is connected to.
Expand Down Expand Up @@ -179,14 +183,16 @@ impl WrappedTool {
}
}

/// True when every leaf the tool can run declares `json_output()`. A
/// node is never callable, so its own `json_output` (if set) does not
/// count — only the leaves under it decide this.
fn every_verb_is_json(&self) -> bool {
let mut verbs = self
.declaration
.root
.iter()
.chain(self.declaration.verbs.iter())
.peekable();
verbs.peek().is_some() && verbs.all(|verb| verb.json_output)
let mut any = false;
let mut all = true;
for verb in self.declaration.root.iter().chain(self.declaration.verbs.iter()) {
walk_leaf_json(verb, &mut any, &mut all);
}
any && all
}

/// Plan a call: parse it, check every constraint, and render the argv.
Expand Down Expand Up @@ -228,6 +234,7 @@ impl WrappedTool {

Ok(RenderedCall {
verb: verb.name.clone(),
scope: self.declaration.scope_of_path(&call.verb_path),
argv,
stdin: verb.stdin,
json_output: verb.json_output,
Expand Down Expand Up @@ -351,10 +358,7 @@ impl WrappedTool {
Ok(call) => call,
Err(error) => return ExecResult::failure(error.exit_code(), error.to_string()),
};
let label = match &call.verb {
Some(verb) => format!("{} {verb}", self.declaration.name),
None => self.declaration.name.clone(),
};
let label = call.scope.clone();

// A virtual cwd has no location to spawn in. The same refusal an
// external command gets, named for this command.
Expand Down Expand Up @@ -507,7 +511,22 @@ fn issue(error: &WrappedError, uncertain: bool) -> ValidationIssue {
}
}

/// The schema for one named verb.
/// Visit every leaf under `verb` (a node's own `json_output`, if set, does
/// not count — only a leaf runs), tracking whether at least one leaf exists
/// and whether every leaf visited so far declares JSON output.
fn walk_leaf_json(verb: &Verb, any: &mut bool, all: &mut bool) {
if verb.verbs.is_empty() {
*any = true;
*all = *all && verb.json_output;
} else {
for child in &verb.verbs {
walk_leaf_json(child, any, all);
}
}
}

/// The schema for one verb, recursing into its children so a node's schema
/// carries its own leaves as nested subcommands.
fn verb_schema(verb: &Verb) -> ToolSchema {
let description = match verb.tail {
Tail::Forward => append_clause(&verb.about, "forwards undeclared flags"),
Expand All @@ -523,6 +542,9 @@ fn verb_schema(verb: &Verb) -> ToolSchema {
if verb.json_output {
schema = schema.with_typed_substitution();
}
for child in &verb.verbs {
schema = schema.subcommand(verb_schema(child));
}
schema
}

Expand Down
2 changes: 1 addition & 1 deletion crates/kaish-kernel/src/tools/wrapped/constraint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub(crate) fn check(
verb: &Verb,
call: &Call,
) -> Vec<WrappedError> {
let scope = declaration.scope_of(verb);
let scope = declaration.scope_of_path(&call.verb_path);
let mut errors = Vec::new();

for use_ in &call.flags {
Expand Down
150 changes: 131 additions & 19 deletions crates/kaish-kernel/src/tools/wrapped/declaration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ pub struct Verb {
/// Usage examples, published through the tool schema.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) examples: Vec<(String, String)>,
/// Child verbs. A verb with children is a node: it selects among them
/// and is never itself callable. Empty for a leaf, which is the verb
/// that actually runs.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) verbs: Vec<Verb>,
}

impl Verb {
Expand Down Expand Up @@ -350,10 +355,26 @@ impl Verb {
self
}

/// Declare a child verb, making this verb a node: `git worktree list`
/// declares `list` as a child of `worktree`. A node selects among its
/// children and is never itself callable — `build()` refuses one that
/// also declares flags, positionals, a tail, or a stdin posture, since
/// those belong on the leaf that actually runs.
pub fn verb(mut self, verb: Verb) -> Self {
self.verbs.push(verb);
self
}

/// The verb's name, or the empty string for the root.
pub(crate) fn name_or_root(&self) -> &str {
self.name.as_deref().unwrap_or("")
}

/// True when this verb has children — it selects among them and is
/// never itself callable.
pub(crate) fn is_node(&self) -> bool {
!self.verbs.is_empty()
}
}

/// One executable: pinned path, fixed lead argv, env pins, verbs.
Expand Down Expand Up @@ -462,43 +483,101 @@ impl WrappedCommand {
self.name
);
}
if let Some(root) = &self.root
&& root.name.is_some()
{
bail!(
"wrapped command '{}' passed a named verb to root(); use Verb::root()",
self.name
);
if let Some(root) = &self.root {
if root.name.is_some() {
bail!(
"wrapped command '{}' passed a named verb to root(); use Verb::root()",
self.name
);
}
if root.is_node() {
bail!(
"wrapped command '{}' declares children on the root verb; nest with \
named verb() calls instead of under root()",
self.name
);
}
}

self.check_verb_list(&self.name, &self.verbs)?;
if let Some(root) = &self.root {
self.check_verb(&self.name, root)?;
}
Ok(())
}

/// Check one sibling list under `scope` — the command name at the top
/// level, or a node's full scope (`git worktree`) one level down: every
/// verb is named and none share a name, then each verb's own grammar and
/// its own children, recursively.
fn check_verb_list(&self, scope: &str, verbs: &[Verb]) -> Result<()> {
let mut seen: Vec<&str> = Vec::new();
for verb in &self.verbs {
for verb in verbs {
let Some(name) = verb.name.as_deref() else {
if scope == self.name {
bail!(
"wrapped command '{}' passed an unnamed verb to verb(); use root()",
self.name
);
}
bail!(
"wrapped command '{}' passed an unnamed verb to verb(); use root()",
"wrapped command '{}' passed an unnamed verb to '{scope}''s verb(); a \
nested verb must be named",
self.name
);
};
if name.is_empty() {
bail!("wrapped command '{}' declares a verb with no name", self.name);
bail!("'{scope}' declares a verb with no name");
}
if seen.contains(&name) {
bail!(
"wrapped command '{}' declares the verb '{name}' twice",
self.name
);
bail!("'{scope}' declares the verb '{name}' twice");
}
seen.push(name);
}

for verb in self.root.iter().chain(self.verbs.iter()) {
self.check_verb(verb)?;
for verb in verbs {
self.check_verb(scope, verb)?;
}
Ok(())
}

fn check_verb(&self, verb: &Verb) -> Result<()> {
let scope = self.scope_of(verb);
fn check_verb(&self, parent_scope: &str, verb: &Verb) -> Result<()> {
let scope = match &verb.name {
Some(name) => format!("{parent_scope} {name}"),
None => parent_scope.to_string(),
};

// A node selects among its children and never runs itself, so a
// flag, positional, tail, or stdin posture on it would describe a
// call that never happens. Refuse loudly rather than binding it
// silently to whichever leaf the agent picks.
if verb.is_node() {
if let Some(flag) = verb.flags.first() {
bail!(
"'{scope}' declares the flag '{}' and child verbs; a node cannot run, \
so a flag belongs on the leaf verb that does",
flag.written_name()
);
}
if let Some(positional) = verb.positionals.first() {
bail!(
"'{scope}' declares the positional '{}' and child verbs; a node cannot \
run, so a positional belongs on the leaf verb that does",
positional.name
);
}
if verb.tail != Tail::default() {
bail!(
"'{scope}' declares a tail and child verbs; a node cannot run, so tail \
belongs on the leaf verb that does"
);
}
if verb.stdin != Stdin::default() {
bail!(
"'{scope}' declares a stdin posture and child verbs; a node cannot run, \
so stdin belongs on the leaf verb that does"
);
}
}

let mut spellings: Vec<String> = Vec::new();
for flag in &verb.flags {
Expand Down Expand Up @@ -567,6 +646,10 @@ impl WrappedCommand {
);
}
}

if verb.is_node() {
self.check_verb_list(&scope, &verb.verbs)?;
}
Ok(())
}

Expand Down Expand Up @@ -617,6 +700,35 @@ impl WrappedCommand {
None => self.name.clone(),
}
}

/// The full scope of a resolved verb path: `git worktree list` for a
/// two-deep call, `git` for the root or for an empty path. Every level
/// on the way down contributes its name, mirroring [`scope_of`] for a
/// path more than one verb deep.
pub(crate) fn scope_of_path(&self, path: &[usize]) -> String {
let mut scope = self.name.clone();
let Some((&first, rest)) = path.split_first() else {
return scope;
};
let Some(mut verb) = self.verbs.get(first) else {
return scope;
};
if let Some(name) = &verb.name {
scope.push(' ');
scope.push_str(name);
}
for &index in rest {
let Some(child) = verb.verbs.get(index) else {
break;
};
verb = child;
if let Some(name) = &verb.name {
scope.push(' ');
scope.push_str(name);
}
}
scope
}
}

/// The name as the child sees it: an explicitly dashed spelling verbatim,
Expand Down
27 changes: 27 additions & 0 deletions crates/kaish-kernel/src/tools/wrapped/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ pub enum WrappedError {
allowed: Vec<String>,
},

/// A node (a verb with children) was called with no leaf to run it. A
/// node only selects among its children; it is never itself callable.
#[error("{command}: '{scope}' needs a verb. Allowed: {}", allowed_list(allowed))]
BareNode {
/// The wrapped command's name.
command: String,
/// The node's full path, e.g. `git worktree`.
scope: String,
/// The node's own children, sorted.
allowed: Vec<String>,
},

/// The word after a node names none of its children.
#[error("{command}: unknown verb '{word}' for '{scope}'. Allowed: {}", allowed_list(allowed))]
UnknownChildVerb {
/// The wrapped command's name.
command: String,
/// The node's full path, e.g. `git worktree`.
scope: String,
/// The word that named no child.
word: String,
/// The node's own children, sorted — never the top level's.
allowed: Vec<String>,
},

/// A word in flag position names no declared flag or alias.
#[error("{command}: unknown flag '{word}' for '{scope}'. Allowed: {}", allowed_list(allowed))]
UnknownFlag {
Expand Down Expand Up @@ -233,6 +258,8 @@ impl WrappedError {
match self {
WrappedError::UnknownVerb { command, .. }
| WrappedError::MissingVerb { command, .. }
| WrappedError::BareNode { command, .. }
| WrappedError::UnknownChildVerb { command, .. }
| WrappedError::UnknownFlag { command, .. }
| WrappedError::ClusteredShort { command, .. }
| WrappedError::GluedShortValue { command, .. }
Expand Down
Loading