Skip to content
Closed
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Committy is a Rust CLI for conventional commits, branch policy, grouped commits,

- Discover active policy with `cargo run -- schema --output json`.
- Prefer `--non-interactive`, `--repo-path`, `--dry-run`, and `--output json`.
- Set `COMMITTY_NONINTERACTIVE=1` in the environment instead of relying on flag placement. `--non-interactive` and `-q`/`--quiet` are global and work on either side of the subcommand; every other flag (`--repo-path`, `--output`, `--dry-run`, and `--verbose`) must follow it.
- Inspect `api_version`, `ok`, `errors`, and the command-specific plan before applying it.
- Use the `committy-branch`, `committy-commit`, `committy-release`, and `committy-enforce` plugin skills when available.
- Never infer permission for a push, publish, destructive Git action, or unrelated staging.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Committy is a Rust CLI for conventional commits, branch policy, grouped commits,

- Discover active policy with `cargo run -- schema --output json`.
- Prefer `--non-interactive`, `--repo-path`, `--dry-run`, and `--output json`.
- Set `COMMITTY_NONINTERACTIVE=1` in the environment instead of relying on flag placement. `--non-interactive` and `-q`/`--quiet` are global and work on either side of the subcommand; every other flag (`--repo-path`, `--output`, `--dry-run`, and `--verbose`) must follow it.
- Inspect `api_version`, `ok`, `errors`, and the command-specific plan before applying it.
- Use `/committy:committy-branch`, `/committy:committy-commit`, `/committy:committy-release`, and `/committy:committy-enforce` when the plugin is installed.
- Never infer permission for a push, publish, destructive Git action, or unrelated staging.
Expand All @@ -30,4 +31,4 @@ Tests are in `tests/`; CLI integration tests use `assert_cmd` and temporary repo
- `src/workflow/`, `src/versioning/`, `src/packages/`: release side effects
- `plugins/committy/`: shared Codex and Claude skills

PRs target `develop`. Commits follow Conventional Commits. The canonical agent reference is `docs/src/content/docs/reference/agent-workflows.mdx`.
PRs target `develop`. Commits follow Conventional Commits. Never add AI attribution trailers — no `Co-Authored-By:`, no `Claude-Session:`, no equivalent — to commit messages or PR descriptions, even when tooling suggests them. The canonical agent reference is `docs/src/content/docs/reference/agent-workflows.mdx`.
278 changes: 278 additions & 0 deletions docs/audits/agent-compatibility-2026-09.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions docs/src/content/docs/reference/agent-workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ Preferred sequence:
5. Require explicit confirmation for any remote mutation.
6. Use `--repo-path` when the agent should operate on another checkout.

## Flag placement

Committy has two flag namespaces, and mixing them up is the most common way an
agent invocation fails:

- **Global flags** — `--non-interactive` and `-q`/`--quiet` — are accepted on
either side of the subcommand.
- **Everything else** — `--repo-path`, `--output`, `--dry-run`, and every
command-specific flag — must follow the subcommand.
- **`-v`/`--verbose` must precede the subcommand.** It is deliberately not
global: `-v` already means `--validate` on `branch`, and `--verbose` is a real
flag on `config validate` and `config show`.

The most reliable option is to stop passing `--non-interactive` altogether and
set the environment variable instead, which makes placement irrelevant:

```bash
export COMMITTY_NONINTERACTIVE=1
committy schema --repo-path . --output json
```

`CI=1` has the same effect, so Committy is already non-interactive on most CI
runners.

When an invocation is rejected outright — an unknown flag, a misplaced argument
— Committy still writes one JSON document to stdout if `--output json` was
requested, with `"code": "invalid_usage"` and clap's explanation as the message.
Agents can parse the rejection and self-correct rather than falling back to
scraping stderr.

## Policy discovery

```bash
Expand Down
147 changes: 107 additions & 40 deletions src/cli/commands/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,50 +37,117 @@ struct SchemaOutput {
struct CapabilityDefinition {
name: &'static str,
description: &'static str,
/// Whether applying this capability changes the repository or a remote.
mutating: bool,
/// Whether the CLI refuses to proceed without an explicit confirmation
/// flag. An agent must never infer this consent from the task alone.
requires_confirmation: bool,
}

/// Read-only capability: previews and validation.
const fn read_only(name: &'static str, description: &'static str) -> CapabilityDefinition {
CapabilityDefinition {
name,
description,
mutating: false,
requires_confirmation: false,
}
}

/// Local mutation: changes this checkout, needs no confirmation flag.
const fn local_write(name: &'static str, description: &'static str) -> CapabilityDefinition {
CapabilityDefinition {
name,
description,
mutating: true,
requires_confirmation: false,
}
}

/// Remote mutation: gated behind an explicit confirmation flag.
const fn remote_write(name: &'static str, description: &'static str) -> CapabilityDefinition {
CapabilityDefinition {
name,
description,
mutating: true,
requires_confirmation: true,
}
}

fn capabilities() -> Vec<CapabilityDefinition> {
vec![
CapabilityDefinition {
name: "branch.preview",
description: "Resolve and validate a branch without changing git",
},
CapabilityDefinition {
name: "branch.apply",
description: "Create a validated branch",
},
CapabilityDefinition {
name: "branch.lint",
description: "Validate structured or policy-enforced branch names",
},
CapabilityDefinition {
name: "commit.preview",
description: "Resolve and lint a commit without changing git",
},
CapabilityDefinition {
name: "commit.apply",
description: "Create a validated conventional commit",
},
CapabilityDefinition {
name: "commit.amend",
description: "Preview or apply a validated amend",
},
CapabilityDefinition {
name: "commit.lint",
description: "Lint one message or repository history",
},
CapabilityDefinition {
name: "hooks.install",
description: "Preview or install native Git hooks and CI enforcement",
},
CapabilityDefinition {
name: "hooks.commit-msg",
description: "Enforce commit messages through a native git hook",
},
CapabilityDefinition {
name: "hooks.pre-push",
description: "Enforce outgoing commit history through a native git hook",
},
read_only(
"schema.discover",
"Report active convention, types, and this capability list",
),
read_only(
"branch.preview",
"Resolve and validate a branch without changing git",
),
local_write("branch.apply", "Create a validated branch"),
read_only(
"branch.lint",
"Validate structured or policy-enforced branch names",
),
read_only(
"commit.preview",
"Resolve and lint a commit without changing git",
),
local_write("commit.apply", "Create a validated conventional commit"),
local_write("commit.amend", "Preview or apply a validated amend"),
read_only("commit.lint", "Lint one message or repository history"),
read_only(
"group-commit.plan",
"Group a dirty worktree into coherent commits without changing git",
),
local_write(
"group-commit.apply",
"Create the planned grouped commits; pushing additionally requires confirmation",
),
read_only(
"bump.preview",
"Compute the next version from commit history without writing files",
),
local_write(
"bump.apply",
"Write the computed version to the configured version files",
),
read_only(
"changelog.preview",
"Render a changelog from git history without writing files",
),
read_only(
"tag.preview",
"Compute the next tag and release plan without changing git",
),
local_write(
"tag.apply",
"Create the computed tag in the local repository",
),
remote_write(
"tag.publish",
"Push tags and publish the release; requires an explicit confirmation flag",
),
read_only(
"config.validate",
"Validate repository configuration and report findings",
),
read_only(
"packages.list",
"List configured packages and their resolved versions",
),
local_write(
"hooks.install",
"Preview or install native Git hooks and CI enforcement",
),
read_only(
"hooks.commit-msg",
"Enforce commit messages through a native git hook",
),
read_only(
"hooks.pre-push",
"Enforce outgoing commit history through a native git hook",
),
]
}

Expand Down
31 changes: 30 additions & 1 deletion src/cli/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,41 @@ struct MachineError<'a> {
#[derive(Debug, Serialize)]
struct ErrorEnvelope<'a> {
api_version: u8,
command: &'static str,
command: &'a str,
ok: bool,
dry_run: bool,
errors: [MachineError<'a>; 1],
}

/// Strip ANSI styling so the envelope carries plain text.
///
/// Clap colours its rejections, and those escape sequences would otherwise end
/// up inside a JSON string value.
fn strip_ansi(text: &str) -> String {
static ANSI: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap());
ANSI.replace_all(text, "").into_owned()
}

/// Emit the machine envelope for an argument-parse rejection.
///
/// `command` is attributed from raw argv and falls back to `"unknown"`, because
/// clap rejects the invocation before any value is parsed.
pub fn print_usage_error(command: &str, message: &str) {
let message = strip_ansi(message);
let payload = ErrorEnvelope {
api_version: API_VERSION,
command,
ok: false,
dry_run: false,
errors: [MachineError {
code: "invalid_usage",
message: &message,
}],
};
println!("{}", serde_json::to_string(&payload).unwrap());
}

pub fn print_error(context: MachineContext, error: &CliError) {
let message = error.to_string();
let payload = ErrorEnvelope {
Expand Down
101 changes: 95 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,91 @@ struct Opt {
)]
pre_release: bool,

#[structopt(long = "non-interactive", help = "Run in non-interactive mode")]
#[structopt(
long = "non-interactive",
global = true,
help = "Run in non-interactive mode"
)]
non_interactive: bool,

#[structopt(long = "metrics-toggle", help = "Toggle metrics collection on/off")]
metrics_toggle: bool,

// Not global: `-v` is already taken by `branch --validate` and `--verbose`
// by `config validate|show`. Promoting it would silently steal those
// shorts. Agents use `-q` and `--non-interactive`, which are global below.
#[structopt(
short = "v",
long = "verbose",
parse(from_occurrences),
help = "Increase verbosity (-v, -vv)"
help = "Increase verbosity (-v, -vv); must precede the subcommand"
)]
verbose: u8,

#[structopt(short = "q", long = "quiet", help = "Reduce verbosity (errors only)")]
#[structopt(
short = "q",
long = "quiet",
global = true,
help = "Reduce verbosity (errors only)"
)]
quiet: bool,
}

/// Subcommand names as they appear on the command line.
///
/// Used only to attribute an argv rejection to a command, since clap gives us
/// no parsed value in that case.
const SUBCOMMAND_NAMES: &[&str] = &[
"commit",
"amend",
"tag",
"bump",
"changelog",
"lint",
"lint-message",
"example",
"info",
"ls",
"schema",
"version",
"branch",
"group-commit",
"hooks",
"init",
"config",
"packages",
];

/// Whether the caller asked for machine output, determined from raw argv.
///
/// An argv rejection happens before any flag is parsed, so this is the only way
/// to know whether the caller is an agent expecting one JSON document.
fn requested_json_output(args: &[String]) -> bool {
args.iter().enumerate().any(|(index, arg)| {
arg == "--output=json"
|| (arg == "--output" && args.get(index + 1).map(String::as_str) == Some("json"))
})
}

/// Best-effort command attribution for an argv rejection.
fn command_from_args(args: &[String]) -> &str {
args.iter()
.skip(1)
.find(|arg| !arg.starts_with('-'))
.map(String::as_str)
.filter(|candidate| SUBCOMMAND_NAMES.contains(candidate))
.unwrap_or("unknown")
}

fn main() {
// Parse argv before touching configuration so a malformed invocation is
// rejected without side effects.
let args: Vec<String> = std::env::args().collect();
let opt = match Opt::from_iter_safe(&args) {
Ok(opt) => opt,
Err(error) => exit_on_parse_error(error, &args),
};

// Load configuration
let mut config = Config::load().unwrap_or_else(|_| {
let default_config = Config::default();
Expand All @@ -84,7 +150,7 @@ fn main() {
default_config
});

if let Err(e) = run(&mut config) {
if let Err(e) = run(&mut config, opt) {
eprintln!("{e}");
let exit_code = e
.downcast_ref::<CliError>()
Expand All @@ -94,9 +160,32 @@ fn main() {
}
}

fn run(config: &mut Config) -> Result<()> {
let opt = Opt::from_args();
/// Report a clap rejection and terminate.
///
/// `--help` and `--version` arrive here as errors too; they are successful
/// output and keep their plain-text form. A genuine rejection honours
/// `--output json` so that an agent's parser sees the same envelope it sees for
/// every other failure, instead of an empty stdout and prose on stderr.
fn exit_on_parse_error(error: structopt::clap::Error, args: &[String]) -> ! {
use structopt::clap::ErrorKind;

if matches!(
error.kind,
ErrorKind::HelpDisplayed | ErrorKind::VersionDisplayed
) {
println!("{}", error.message);
std::process::exit(0);
}

if requested_json_output(args) {
cli::output::print_usage_error(command_from_args(args), &error.message);
} else {
eprintln!("{}", error.message);
}
std::process::exit(1);
}

fn run(config: &mut Config, opt: Opt) -> Result<()> {
// Initialize logger based on verbosity flags
let mut builder = Builder::from_env(Env::default().default_filter_or("info"));
let level = if opt.quiet {
Expand Down
Loading
Loading