mod amount;
mod assemble;
mod claim;
mod color;
mod complete;
mod config;
mod error;
mod hint;
mod init;
mod interpret;
mod jev;
mod output;
mod questions;
mod repair;
mod rules;
mod schema;
mod setup;
mod skill;
mod suggest;
mod testrun;
mod token;
use color::{C, paint};
use error::JanyError;
const HELP: &str = "\
jany — jev x any command. Turn loosely ordered words into a command line.
usage: jany <command> [words ...] [flags] [-- passthrough args]
jany --init <zsh|bash|fish> [--locale en|ja]
print the shell wrapper (eval \"$(jany --init zsh)\");
also installs the /jany-setup, /jany-register, /jany-update and /jany-teardown skills
to ~/.agents/skills
(in English, or Japanese with --locale ja)
and the built-in commands (find, curl, docker run) to ~/.config/jany/cmd
jany --skills [--locale en|ja] only install the skills (before the first --init: then /jany-setup)
jany --register <name> [sub] [--locale en|ja]
scaffold ~/.config/jany/cmd/<name>/ (then: /jany-register <name>)
jany --update [name] [sub] [--locale en|ja]
update the built-in commands you have not edited, and tell
what the others lack (then: /jany-update <name>);
also installs the skills that are missing
jany --test <command> [sub] run cases.toml of a command definition
jany --setup save your OpenRouter API key
jany --list show the command definitions found
jany --complete -- [words] print shell completion candidates
jany --suggest [--on] -- [words]
print the dim hint for the words still to say (zsh);
--on: nothing for lines `[on] skip` runs as typed
jany --on | --off in this zsh, type `find log files older than 7 days` without `jany`
(lines with a `-` word, a pipe or a redirection run as typed)
jany --claim -- [words] exit 0 if jany would take the line after `jany --on` (zsh)
jany's own actions are flags so that <command> is always the tool's name.
jany never runs the command: it prints one shell-quoted line on stdout, and the
wrapper from `jany --init` puts it on your prompt. Everything else goes to stderr.
With `[cmd.<name>] autorun = true` in config.toml, the zsh wrapper runs the line
instead when rules alone decided it and it is risk \"none\" (no jev, no words after
`--`, no raw flags, no preview or pipe; `jany <command> -- --help` / `-- --version`
alone is fine too). It still goes into the shell history. `autorun_also = [\"pnpm install\"]`
also runs lines starting with those words when they are risk \"unsafe\" (never \"dangerous\").
flags:
--explain show how each word was classified (stderr)
--hint show what you can say to <command>, with examples from its cases.toml (stderr)
--no-jev never call jev; unresolved words are an error
-h, --help
-V, --version
env:
OPENROUTER_API_KEY required for jev (also read from ~/.config/{jany,jurl,jind}/config.toml)
JEV_MODEL default typesafe/jev-1.13
JANY_CMD_DIR where command definitions live (default ~/.config/jany/cmd)
JANY_CONFIG_DIR default ~/.config/jany
JANY_SKILL_DIR where `jany --init` puts the skill (default ~/.agents/skills/jany-register;
/jany-update goes next to it)
JANY_NO_JEV=1 same as --no-jev
JANY_SUGGEST=0|1 turn the dim hint in zsh off/on (overrides [suggest] enabled in config.toml)
JANY_CAN_RUN=1 set by the zsh wrapper: it can run the line (exit status 3 asks it to)
";
#[derive(Default)]
struct Opts {
explain: bool,
no_jev: bool,
hint: bool,
locale: Option<skill::Locale>,
action: Option<String>,
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(args) {
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("jany: {e}");
std::process::exit(e.exit_code());
}
}
}
fn run(args: Vec<String>) -> Result<i32, JanyError> {
if args.first().map(String::as_str) == Some("--complete") {
let typed = args.iter().position(|a| a == "--").map(|i| &args[i + 1..]).unwrap_or(&[]);
let cmd_dir = config::cmd_dir().ok_or_else(|| JanyError::Config("cannot determine command dir (HOME unset)".into()))?;
return Ok(complete::run(&cmd_dir, typed));
}
if args.first().map(String::as_str) == Some("--suggest") {
let typed = args.iter().position(|a| a == "--").map(|i| &args[i + 1..]).unwrap_or(&[]);
let cmd_dir = config::cmd_dir().ok_or_else(|| JanyError::Config("cannot determine command dir (HOME unset)".into()))?;
let on = args.get(1).is_some_and(|a| a == "--on");
return Ok(suggest::run(&cmd_dir, typed, on));
}
if args.first().map(String::as_str) == Some("--claim") {
let typed = args.iter().position(|a| a == "--").map(|i| &args[i + 1..]).unwrap_or(&[]);
let cmd_dir = config::cmd_dir().ok_or_else(|| JanyError::Config("cannot determine command dir (HOME unset)".into()))?;
return Ok(claim::run(&cmd_dir, typed));
}
if let [a] = args.as_slice()
&& (a == "--on" || a == "--off")
{
return Err(JanyError::Usage(format!("jany {a} works in zsh with the wrapper: put eval \"$(jany --init zsh)\" in ~/.zshrc and open a new shell")));
}
let mut opts = Opts::default();
let mut words = Vec::new();
let mut passthrough = Vec::new();
let mut after = false;
let mut args = args.into_iter();
while let Some(a) = args.next() {
if after {
passthrough.push(a);
continue;
}
match a.as_str() {
"--" => after = true,
"#" if opts.hint => break,
"--explain" => opts.explain = true,
"--no-jev" => opts.no_jev = true,
"--hint" => opts.hint = true,
"--locale" => {
let v = args.next().ok_or_else(|| JanyError::Usage(format!("--locale needs a value ({})", skill::Locale::ALL.join(", "))))?;
opts.locale = Some(skill::Locale::parse(&v)?);
}
s if s.starts_with("--locale=") => opts.locale = Some(skill::Locale::parse(&s["--locale=".len()..])?),
"--init" | "--skills" | "--register" | "--update" | "--test" | "--setup" | "--list" => {
if let Some(prev) = &opts.action {
return Err(JanyError::Usage(format!("{prev} and {a} together")));
}
opts.action = Some(a.clone());
}
"-h" | "--help" => {
output::stdout(HELP);
return Ok(0);
}
"-V" | "--version" => {
output::stdout(&format!("jany {}\n", env!("CARGO_PKG_VERSION")));
return Ok(0);
}
_ => words.push(a),
}
}
if std::env::var("JANY_NO_JEV").map(|v| v == "1").unwrap_or(false) {
opts.no_jev = true;
}
if words.is_empty() && opts.action.is_none() {
output::stdout(HELP);
return Ok(0);
}
if opts.locale.is_some() && !matches!(opts.action.as_deref(), Some("--init" | "--skills" | "--register" | "--update")) {
return Err(JanyError::Usage("--locale only works with --init, --skills, --register or --update".into()));
}
let locale = opts.locale.unwrap_or_default();
let cmd_dir = config::cmd_dir().ok_or_else(|| JanyError::Config("cannot determine command dir (HOME unset)".into()))?;
match opts.action.as_deref().unwrap_or("") {
"--setup" => return setup::run(),
"--init" => {
let shell = words.first().map(String::as_str).unwrap_or("");
output::stdout(init::script(shell)?);
match skill::install(locale) {
Ok(changed) => {
for c in changed {
eprintln!("jany: installed {c}");
}
}
Err(e) => eprintln!("jany: could not install the skills: {e}"),
}
match skill::install_commands(&cmd_dir) {
Ok(placed) => {
for d in placed {
eprintln!("jany: installed {d}");
}
}
Err(e) => eprintln!("jany: could not install the built-in commands: {e}"),
}
return Ok(0);
}
"--skills" => {
for c in skill::install(locale)? {
eprintln!("jany: installed {c}");
}
eprintln!("jany: next, in Claude Code or Codex: /jany-setup");
return Ok(0);
}
"--register" => return skill::register(&cmd_dir, &words, locale),
"--update" => {
match skill::install_missing(opts.locale) {
Ok(placed) => {
for p in placed {
eprintln!("jany: installed {p}");
}
}
Err(e) => eprintln!("jany: could not install the skills: {e}"),
}
return skill::update(&cmd_dir, &words, &list(&cmd_dir));
}
"--test" => {
let (schema, used) = schema::resolve(&cmd_dir, &words)?;
if used != words.len() {
return Err(JanyError::Usage(format!("jany --test takes a command name, got extra: {}", words[used..].join(" "))));
}
return testrun::run(&schema, opts.explain);
}
"--list" => {
let on = color::stdout_enabled();
for name in list(&cmd_dir) {
let ex = schema::Schema::load(&cmd_dir.join(name.replace(' ', "/"))).ok().and_then(|s| s.command.example);
match ex {
Some(ex) => output::stdout(&format!("{name} {}\n", paint(on, C::Dim, &format!("e.g. jany {name} {ex}")))),
None => output::stdout(&format!("{name}\n")),
}
}
return Ok(0);
}
_ => {}
}
if opts.hint
&& let Err(JanyError::NoCommand(..)) = schema::resolve(&cmd_dir, &words)
&& let Some(subs) = hint::subcommands(&cmd_dir, &words)
{
eprint!("{subs}");
return Ok(0);
}
let (schema, used) = schema::resolve(&cmd_dir, &words)?;
if opts.hint {
return Ok(hint::run(&schema));
}
match translate(&schema, &words[used..], &passthrough, &opts) {
Err(e @ (JanyError::Unresolved(_) | JanyError::LowConfidence(..) | JanyError::Assemble(_))) => {
eprintln!("jany: {e}");
output::stdout(&format!("{}\n", hint::retry_line(&words[..used], &e)));
Ok(e.exit_code())
}
other => other,
}
}
fn translate(schema: &schema::Schema, words: &[String], passthrough: &[String], opts: &Opts) -> Result<i32, JanyError> {
let cfg = config::load()?;
let on = color::stderr_enabled();
let oracle;
let oracle_ref: Option<&dyn jev::Oracle> = if opts.no_jev || !cfg.jev.enabled {
None
} else {
oracle = interpret::oracle_from_config(&cfg)?;
Some(&oracle)
};
let r = match interpret::run(schema, &cfg, words, passthrough, oracle_ref, None) {
Ok(r) => r,
Err(JanyError::Unresolved(s)) => {
let aliases = cfg.cmd.get(&schema.command.name).map(|c| c.aliases.clone()).unwrap_or_default();
let tokens = rules::classify(schema, &config::expand_aliases(&aliases, words));
output::explain(&tokens, None);
return Err(JanyError::Unresolved(s));
}
Err(e) => return Err(e),
};
if opts.explain {
output::explain(&r.tokens, r.jev.as_ref());
} else if let Some(j) = &r.jev {
eprintln!("{}", paint(on, C::Dim, &j.line()));
}
let conf = r.out.confidence;
if conf < cfg.jev.reject_below {
if !opts.explain {
output::explain(&r.tokens, r.jev.as_ref());
}
return Err(JanyError::LowConfidence(conf, cfg.jev.reject_below));
}
if r.jev.is_some() && conf < 0.8 {
eprintln!("{}", paint(on, C::Yellow, &format!("confidence {conf:.2}: check the command before you run it")));
}
let argv = r.out.argv.as_deref().unwrap_or(&[]);
match r.out.risk.as_str() {
"dangerous" => {
eprintln!("{}", paint(on, C::Red, "this command is destructive."));
if let Some(pv) = &r.out.preview
&& schema.confirm.preview_readonly
{
preview(pv, schema.confirm.preview_lines, on)?;
}
}
"unsafe" => {
if let Some(n) = &schema.confirm.unsafe_note {
eprintln!("{}", paint(on, C::Yellow, n));
}
}
_ => {}
}
let line = output::render(argv, r.out.pipe.as_deref());
let autorun = std::env::var("JANY_CAN_RUN").is_ok_and(|v| v == "1")
&& cfg.cmd.get(&schema.command.name).is_some_and(|c| r.autorun_safe(passthrough, c));
if autorun {
eprintln!("{}", paint(on, C::Dim, &format!("$ {line}")));
}
output::stdout(&format!("{line}\n"));
Ok(if autorun { AUTORUN_EXIT } else { 0 })
}
const AUTORUN_EXIT: i32 = 3;
fn preview(argv: &[String], lines: usize, on: bool) -> Result<(), JanyError> {
if argv.is_empty() {
return Ok(());
}
eprintln!("{}", paint(on, C::Dim, &format!("$ {}", output::render(argv, None))));
let out = std::process::Command::new(&argv[0]).args(&argv[1..]).stderr(std::process::Stdio::inherit()).output()?;
let text = String::from_utf8_lossy(&out.stdout);
let all: Vec<&str> = text.lines().collect();
for l in all.iter().take(lines) {
eprintln!(" {l}");
}
if all.len() > lines {
eprintln!("{}", paint(on, C::Dim, &format!(" … {} more", all.len() - lines)));
} else if all.is_empty() {
eprintln!("{}", paint(on, C::Dim, " (nothing matched)"));
}
Ok(())
}
fn list(cmd_dir: &std::path::Path) -> Vec<String> {
let mut out = Vec::new();
fn walk(dir: &std::path::Path, prefix: &str, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut names: Vec<_> = rd.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).collect();
names.sort_by_key(|e| e.file_name());
for e in names {
let name = e.file_name().to_string_lossy().to_string();
let full = if prefix.is_empty() { name.clone() } else { format!("{prefix} {name}") };
if e.path().join("schema.toml").exists() {
out.push(full.clone());
}
walk(&e.path(), &full, out);
}
}
walk(cmd_dir, "", &mut out);
out
}