Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e03ce99
Draft execplan for agent-context format flag (6.2.1)
leynos Jun 2, 2026
14dce2f
Add agent command summaries
leynos Jun 4, 2026
dc28cde
Accept agent-context format in cargo-orthohelp
leynos Jun 4, 2026
fe3f2e3
Emit agent-context JSON from cargo-orthohelp
leynos Jun 4, 2026
979acee
Test agent-context transformation
leynos Jun 4, 2026
e516ecd
Add agent-context behavioural coverage
leynos Jun 4, 2026
393ade5
Document agent-context output
leynos Jun 4, 2026
fe27a2d
Complete agent-context roadmap item
leynos Jun 4, 2026
1e0813f
Clarify agent-context summary localization
leynos Jun 6, 2026
87e00d7
Fix developers guide spacing
leynos Jun 6, 2026
cd55ba5
Type agent-context JSON field names
leynos Jun 6, 2026
5a0c172
Cover agent-context ordering properties
leynos Jun 7, 2026
c74fe02
Trace agent-context generation progress
leynos Jun 7, 2026
339232b
Improve agent-context manifest usefulness
leynos Jun 7, 2026
37ca8c0
Add agent-context failure diagnostics
leynos Jun 8, 2026
4b7f303
Write agent-context output atomically
leynos Jun 8, 2026
d9c850f
Harden agent-context temp write sync
leynos Jun 8, 2026
1f56407
Test concurrent agent-context writes
leynos Jun 8, 2026
7f92862
Expect non-Unix sync stub wrapper lint
leynos Jun 8, 2026
340e5bd
Fail hard on agent-context temp collisions
leynos Jun 8, 2026
1411b33
Trace agent-context write entry and test collision failure
leynos Jun 9, 2026
3c4eebd
Assert AlreadyExists in temp-collision test
leynos Jun 9, 2026
f5143e5
Add entry trace and external-collision test for agent-context write
leynos Jun 9, 2026
f13469a
Use in-scope Dir and ambient_authority in collision test
leynos Jun 9, 2026
cc28a18
Assert AlreadyExists in external-collision test
leynos Jun 9, 2026
092e1b8
Address agent-context review warnings
leynos Jun 11, 2026
12c19c9
Clarify agent-context format boundaries
leynos Jun 11, 2026
6ce92ad
Document agent-context APIs and must-use checks
leynos Jun 11, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cargo-orthohelp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ rstest-bdd.workspace = true
rstest-bdd-macros.workspace = true
test_helpers = { package = "ortho_config_test_helpers", path = "../test_helpers", version = "0.8.0" }
tempfile = "3"
trybuild = "1"
30 changes: 21 additions & 9 deletions cargo-orthohelp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,31 @@ intermediate representation (IR) produced by `#[derive(OrthoConfig)]`.

## How it works

1. It discovers your package and root config type from Cargo metadata or CLI
1. Discovers your package and root config type from Cargo metadata or CLI
flags.
2. It builds a tiny bridge binary that calls
2. Builds a tiny bridge binary that calls
`OrthoConfigDocs::get_doc_metadata()`.
3. It resolves Fluent message IDs for each requested locale.
4. It emits localized IR JSON, roff man pages, PowerShell external help, or
all formats.
3. Resolves Fluent message IDs for each requested locale.
4. Emits localized IR JSON, roff man pages, PowerShell external help, compact
agent-context JSON, or every localized format.

## Core features

- **Single source of truth:** Generate docs from OrthoConfig IR metadata.
- **Localized output:** Resolve Fluent IDs per locale from `locales/<locale>`.
- **Multiple output formats:** IR JSON (`ir`), UNIX man pages (`man`),
PowerShell help (`ps`), or all formats (`all`).
PowerShell help (`ps`), compact agent-context JSON (`agent-context`), or all
localized output formats (`all`). Note: `--format all` currently excludes
`agent-context` until schema versioning is locked.
- **Cache-aware pipeline:** `--cache` reuses bridge IR; `--no-build` enforces
cache-only execution.
- **Cargo-native workflow:** Run as `cargo orthohelp` from your workspace.

## Agent-native roadmap status

`cargo-orthohelp` is the planned reference CLI for OrthoConfig's agent-native
work. The current released surface generates human documentation artefacts. The
roadmap adds compact `agent-context` output, `--json` command summaries,
work. The current released surface generates human documentation artefacts and
compact `agent-context` output. The roadmap adds `--json` command summaries,
agent-native lint checks, enumerating errors, stable exit classes, and atomic
artefact writes. See
[Agent-native CLI assistance design](../docs/agent-native-cli-design.md) and
Expand Down Expand Up @@ -81,7 +83,7 @@ cargo orthohelp \
[--package <pkg>] [--bin <name> | --lib] \
[--root-type <path::Type>] \
[--locale <locale>] [--all-locales] \
[--format ir|man|ps|all] \
[--format ir|man|ps|agent-context|all] \
[--out-dir <path>] \
[--cache] [--no-build]
```
Expand Down Expand Up @@ -143,6 +145,15 @@ Generate every output format in one run:
cargo orthohelp --package my_app --all-locales --format all --out-dir target/docs
```

Generate compact agent-context JSON:

```bash
cargo orthohelp \
--package my_app \
--format agent-context \
--out-dir target/orthohelp
```

## Output layout

For `--out-dir target/docs`:
Expand All @@ -157,6 +168,7 @@ For `--out-dir target/docs`:
- `target/docs/powershell/<ModuleName>/<ModuleName>.psd1`
- `target/docs/powershell/<ModuleName>/<locale>/<ModuleName>-help.xml`
- `target/docs/powershell/<ModuleName>/<locale>/about_<ModuleName>.help.txt`
- Agent context: `target/docs/agent-context.json`

## Cargo metadata defaults

Expand Down
244 changes: 244 additions & 0 deletions cargo-orthohelp/src/agent_context/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
//! Transform bridge documentation IR into compact agent-context metadata.
//!
//! This module owns the `cargo-orthohelp` adapter from the documentation
//! oriented bridge IR to the reusable `ortho_config::agent_context` schema.

use ortho_config::{
AgentCommand, AgentContext, AgentInput, InteractionMode, Localizer, MutationEffect,
};

use crate::schema::{DocMetadata, FieldMetadata, ValueType};

const CANONICAL_VERBS: &[&str] = &[
"get", "list", "create", "update", "delete", "jobs", "profile", "feedback",
];

/// Builds an agent-context document from bridge documentation metadata.
///
/// The transform is deterministic: command paths and command inputs are sorted
/// before returning so callers get stable JSON after serialization.
///
/// # Examples
///
/// ```rust
/// use cargo_orthohelp::agent_context::bridge_ir_to_agent_context;
/// use cargo_orthohelp::schema::{DocMetadata, HeadingIds, SectionsMetadata};
///
/// let metadata = DocMetadata {
/// ir_version: "1.1".to_owned(),
/// app_name: "example".to_owned(),
/// bin_name: None,
/// about_id: "example.about".to_owned(),
/// synopsis_id: None,
/// sections: SectionsMetadata {
/// headings_ids: HeadingIds {
/// name: "heading.name".to_owned(),
/// synopsis: "heading.synopsis".to_owned(),
/// description: "heading.description".to_owned(),
/// options: "heading.options".to_owned(),
/// environment: "heading.environment".to_owned(),
/// files: "heading.files".to_owned(),
/// precedence: "heading.precedence".to_owned(),
/// exit_status: "heading.exit-status".to_owned(),
/// examples: "heading.examples".to_owned(),
/// see_also: "heading.see-also".to_owned(),
/// commands: None,
/// },
/// discovery: None,
/// precedence: None,
/// examples: Vec::new(),
/// links: Vec::new(),
/// notes: Vec::new(),
/// },
/// fields: Vec::new(),
/// subcommands: Vec::new(),
/// windows: None,
/// };
///
/// let context = bridge_ir_to_agent_context(&metadata, "example", None);
/// assert_eq!(context.kind, "example.agent_context");
/// assert_eq!(context.commands[0].path, ["example"]);
/// ```
#[must_use]
pub fn bridge_ir_to_agent_context(
meta: &DocMetadata,
package: &str,
localizer: Option<&dyn Localizer>,
) -> AgentContext {
tracing::debug!(
package = %package,
root = %meta.app_name,
"starting bridge IR to agent-context transformation",
);
let mut context = AgentContext::new(package);
walk(meta, &[], &mut context.commands, localizer);
context
.commands
.sort_by(|left, right| left.path.cmp(&right.path));
for command in &mut context.commands {
command
.inputs
.sort_by(|left, right| left.name.cmp(&right.name));
}
tracing::debug!(
package = %package,
command_count = context.commands.len(),
"bridge IR to agent-context transformation complete",
);
context
}

/// Recursively transforms a `DocMetadata` node into `AgentCommand` entries.
///
/// `meta` is appended to `out`, `parent_path` supplies the already-resolved
/// command prefix, and `localizer` optionally resolves a concise summary.
/// Every child in `meta.subcommands` is then visited with the current command
/// path as its parent.
fn walk(
meta: &DocMetadata,
parent_path: &[String],
out: &mut Vec<AgentCommand>,
localizer: Option<&dyn Localizer>,
) {
let path = command_path(meta, parent_path);
let last_segment = path.last().map(String::as_str);
out.push(AgentCommand {
path: path.clone(),
summary: resolve_summary(meta, localizer),
canonical_verb: last_segment.and_then(canonical_verb_for),
inputs: meta.fields.iter().filter_map(build_input).collect(),
output_modes: Vec::new(),
interaction_mode: InteractionMode::default(),
mutation_effect: MutationEffect::default(),
async_submission: None,
delivery_route: None,
pagination: None,
examples: Vec::new(),
});

for subcommand in &meta.subcommands {
walk(subcommand, &path, out, localizer);
}
}

/// Builds the full command path for one metadata node.
///
/// The root command prefers `bin_name` because it is the invocable binary.
/// Child commands append `app_name` to the inherited path; a missing root
/// `bin_name` naturally falls back to `app_name`.
fn command_path(meta: &DocMetadata, parent_path: &[String]) -> Vec<String> {
if parent_path.is_empty() {
return vec![meta.bin_name.as_ref().unwrap_or(&meta.app_name).to_owned()];
}
let mut path = parent_path.to_vec();
path.push(meta.app_name.clone());
path
}

fn resolve_summary(meta: &DocMetadata, localizer: Option<&dyn Localizer>) -> Option<String> {
let resolved = localizer?.lookup(&meta.about_id, None)?;
let trimmed = resolved.trim();
if trimmed.is_empty() || trimmed.starts_with("[missing:") {
None
} else {
Some(trimmed.to_owned())
}
}

fn canonical_verb_for(last_segment: &str) -> Option<String> {
CANONICAL_VERBS
.contains(&last_segment)
.then(|| last_segment.to_owned())
}

/// Maps CLI-visible field metadata into an agent input.
///
/// Returns `None` for fields without CLI metadata, fields hidden from help, and
/// non-positional fields with no long or short flag. Returned inputs populate
/// name, long flag, value type, required state, default display, and enum
/// values.
fn build_input(field: &FieldMetadata) -> Option<AgentInput> {
let cli = field.cli.as_ref()?;
if cli.hide_in_help {
return None;
}
if should_skip_non_flag_input(field) {
tracing::warn!(
field = field.name,
"skipping CLI input with no long or short flag that does not take a value"
);
return None;
}
Some(AgentInput {
name: field.name.clone(),
long: cli.long.clone(),
value_type: map_input_value_type(field),
required: field.required,
default: field
.default
.as_ref()
.map(|default| default.display.clone()),
enum_values: enum_values(field),
})
}

/// Returns whether a field has CLI metadata but no invocable CLI surface.
///
/// When `FieldMetadata.cli.long` and `FieldMetadata.cli.short` are both absent
/// and `takes_value` is false, the field is intended for other configuration
/// sources rather than command-line invocation.
const fn should_skip_non_flag_input(field: &FieldMetadata) -> bool {
let Some(cli) = field.cli.as_ref() else {
return false;
};
cli.long.is_none() && cli.short.is_none() && !cli.takes_value
}

fn map_input_value_type(field: &FieldMetadata) -> Option<String> {
if matches!(&field.value, Some(ValueType::Enum { .. })) {
return Some("enum".to_owned());
}
if field
.cli
.as_ref()
.is_some_and(|cli| !cli.possible_values.is_empty())
{
return Some("enum".to_owned());
}
field.value.as_ref().map(map_value_type)
}

fn map_value_type(value: &ValueType) -> String {
match value {
ValueType::String => "string".to_owned(),
ValueType::Integer { .. } => "integer".to_owned(),
ValueType::Float { .. } => "float".to_owned(),
ValueType::Bool => "bool".to_owned(),
ValueType::Duration => "duration".to_owned(),
ValueType::Path => "path".to_owned(),
ValueType::IpAddr => "ipaddr".to_owned(),
ValueType::Hostname => "hostname".to_owned(),
ValueType::Url => "url".to_owned(),
ValueType::Enum { .. } => "enum".to_owned(),
ValueType::List { .. } => "list".to_owned(),
ValueType::Map { .. } => "map".to_owned(),
ValueType::Custom { name } => name.clone(),
}
}

fn enum_values(field: &FieldMetadata) -> Vec<String> {
match &field.value {
Some(ValueType::Enum { variants }) => variants.clone(),
_ => field
.cli
.as_ref()
.map(|cli| cli.possible_values.clone())
.unwrap_or_default(),
}
}

#[cfg(test)]
mod proptests;

#[cfg(test)]
mod tests;
Loading
Loading