From 873875fd4c3e86a7f1a6b8070e250dae8d09fa57 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Tue, 8 Sep 2026 09:35:22 -0400 Subject: [PATCH] fix: recognize literal paths without splitting words An agent repeatedly failed to read Git metadata because a dot-prefixed path became two adjacent arguments. The quoting diagnostic exposed the lexer's fragment boundary rather than a useful language rule. Recognize slash-containing relative words as paths, including numeric first components and bare current/parent directory references. Preserve their spelling and align @ and + handling across path categories. Extend colon fusion so Git revision paths remain one argument. The neighboring command-name audit found a silent split: ./bin$x could run ./bin with $x as its first argument. Accept literal relative executable paths and require a gap before the first argument. Leave adjacent equals signs with the assignment grammar so its diagnostics remain authoritative. Regression tests were run red first and now cover exact argv, redirects, assignments, executable names, interpolation boundaries, collections, globs, and the original command against fixture files. Update the help and language reference to explain literal paths independently of token count. Reviewed with kaibo: GLM-5.2 and DeepSeek V4 Flash, including a follow-up review after tests exposed diagnostic selection errors. --- CHANGELOG.md | 10 + crates/kaish-help/content/en/syntax.md | 9 +- crates/kaish-help/src/fragments.rs | 9 +- crates/kaish-kernel/src/lexer.rs | 23 +- crates/kaish-kernel/src/parser.rs | 19 +- .../tests/glued_arg_span_tests.rs | 13 +- .../tests/literal_path_words_tests.rs | 244 ++++++++++++++++++ docs/LANGUAGE.md | 24 +- 8 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 crates/kaish-kernel/tests/literal_path_words_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a66a987e..5a5ca58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ breaking entries are marked **BREAKING**. ## [Unreleased] +### Fixed + +- Literal paths such as `.git/HEAD`, `2026/report`, `./`, and `../` now + parse as one word. Relative, absolute, and tilde paths accept `@` and `+` + consistently; Git revision paths such as `HEAD:src/main.rs` stay one + argument. Text joined with an expansion still requires quoting. +- Relative executable paths such as `.git/hooks/pre-commit` and `../bin/check` + now parse. A command name attached to its first argument is refused instead + of silently running a different executable (`./bin$x` as `./bin $x`). + ## [0.17.1] - 2026-09-02 ### Migrating to 0.17.0 diff --git a/crates/kaish-help/content/en/syntax.md b/crates/kaish-help/content/en/syntax.md index b5b4e6b4..d8d7ca84 100644 --- a/crates/kaish-help/content/en/syntax.md +++ b/crates/kaish-help/content/en/syntax.md @@ -146,6 +146,10 @@ push services[web][tags] canary # bracket-path target ```sh /usr/bin/foo # absolute ../parent/file # relative with .. +.git/HEAD # dot-prefixed directory +2026/report # numeric directory name +./ # current directory +../ # parent directory ./script.sh # dot-slash (explicit relative) ~/src/project # tilde expands to $HOME cd # bare cd goes to $HOME @@ -159,8 +163,7 @@ cd - # previous directory "literal \$X" # escape $ to prevent expansion 'hello $NAME' # single quotes — literal, no interpolation -# Quote to JOIN text with interpolation — kaish does not paste adjacent -# unquoted tokens into one word (no implicit concatenation): +# Quote the whole word to join text with interpolation: "$dir/file.txt" # one path "out-$(date +%s).log" # one filename (text + command substitution) echo "/tmp/$(id -u).sock" # one argument @@ -169,7 +172,7 @@ echo "/tmp/$(id -u).sock" # one argument echo $dir/file.txt # error — quote "$dir/file.txt" echo /tmp/$(id -u).sock # error — quote "/tmp/$(id -u).sock" cmd > $dir/out.txt # error — quote "$dir/out.txt" -# (single-token words like file.txt or v1.2.3 are fine unquoted) +# Literal words like file.txt, v1.2.3, and .git/HEAD need no quotes. ``` ## Comments diff --git a/crates/kaish-help/src/fragments.rs b/crates/kaish-help/src/fragments.rs index 82e88a98..9aba435b 100644 --- a/crates/kaish-help/src/fragments.rs +++ b/crates/kaish-help/src/fragments.rs @@ -444,6 +444,10 @@ push services[web][tags] canary # bracket-path target r#"```sh /usr/bin/foo # absolute ../parent/file # relative with .. +.git/HEAD # dot-prefixed directory +2026/report # numeric directory name +./ # current directory +../ # parent directory ./script.sh # dot-slash (explicit relative) ~/src/project # tilde expands to $HOME cd # bare cd goes to $HOME @@ -458,8 +462,7 @@ cd - # previous directory "literal \$X" # escape $ to prevent expansion 'hello $NAME' # single quotes — literal, no interpolation -# Quote to JOIN text with interpolation — kaish does not paste adjacent -# unquoted tokens into one word (no implicit concatenation): +# Quote the whole word to join text with interpolation: "$dir/file.txt" # one path "out-$(date +%s).log" # one filename (text + command substitution) echo "/tmp/$(id -u).sock" # one argument @@ -468,7 +471,7 @@ echo "/tmp/$(id -u).sock" # one argument echo $dir/file.txt # error — quote "$dir/file.txt" echo /tmp/$(id -u).sock # error — quote "/tmp/$(id -u).sock" cmd > $dir/out.txt # error — quote "$dir/out.txt" -# (single-token words like file.txt or v1.2.3 are fine unquoted) +# Literal words like file.txt, v1.2.3, and .git/HEAD need no quotes. ```"#, ), syntax_section( diff --git a/crates/kaish-kernel/src/lexer.rs b/crates/kaish-kernel/src/lexer.rs index c595b8d3..b3a0200a 100644 --- a/crates/kaish-kernel/src/lexer.rs +++ b/crates/kaish-kernel/src/lexer.rs @@ -436,24 +436,21 @@ pub enum Token { Dot, /// Tilde path: `~/foo`, `~user/bar` - value includes the full string. - #[regex(r"~[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]+", lex_tilde_path, priority = 3)] + #[regex(r"~[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]+", lex_tilde_path, priority = 3)] TildePath(String), /// Bare tilde: `~` alone (expands to $HOME) #[token("~")] Tilde, - /// Relative path: `../foo/bar`, bare `src/kaish` (ident containing `/`), - /// or a directory reference with a trailing slash like `dest/`. The - /// trailing-slash form uses `*` (not `+`) after the slash so `dest/` - /// lexes as one token instead of `Ident("dest")` + `Path("/")` — the - /// latter split silently turned `cp a b dest/` into a 4-operand command. - #[regex(r"\.\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_relative_path, priority = 3)] - #[regex(r"[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*/[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]*", lex_relative_path, priority = 3)] + /// Slash-containing relative word: `.git/HEAD`, `2026/report`, `../`. + /// A slash makes the whole word text, including a numeric first component. + #[regex(r"[a-zA-Z0-9_.\u{80}-\u{10FFFF}][a-zA-Z0-9_.@+#\-\u{80}-\u{10FFFF}]*/[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_relative_path, priority = 3)] RelativePath(String), - /// Dot-slash path: `./foo`, `./script.sh`. - #[regex(r"\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_dot_slash_path, priority = 3)] + /// Dot-slash path: `./`, `./foo`, `./script.sh`. + /// Wins ties with RelativePath to retain the existing token category. + #[regex(r"\./[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_dot_slash_path, priority = 4)] DotSlashPath(String), /// Dot-prefixed bareword: `.parent`, `.gitignore`, `.foo.bar`. @@ -711,7 +708,7 @@ pub enum Token { /// `date -d @0`), or bare `@`. Mid-word `@` (`user@host`) is handled by /// `Ident`; this covers the leading-`@` cases that would otherwise be an /// "unexpected character" lexer error. - #[regex(r"@[a-zA-Z0-9_./@\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)] + #[regex(r"@[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)] AtWord(String), /// Invalid: float without leading digit (like .5) @@ -728,7 +725,7 @@ pub enum Token { // ═══════════════════════════════════════════════════════════════════ /// Absolute path: `/tmp/out`, `/etc/hosts`, `/tmp/日本語`, etc. - #[regex(r"/[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]*", lex_path)] + #[regex(r"/[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_path)] Path(String), // ═══════════════════════════════════════════════════════════════════ @@ -3183,6 +3180,8 @@ fn is_colon_mergeable(token: &Token) -> bool { | Token::DottedIdent(_) | Token::Colon | Token::Int(_) + | Token::RelativePath(_) + | Token::DotSlashPath(_) | Token::Path(_) | Token::Float(_) ) diff --git a/crates/kaish-kernel/src/parser.rs b/crates/kaish-kernel/src/parser.rs index 6cc44ec6..055d2107 100644 --- a/crates/kaish-kernel/src/parser.rs +++ b/crates/kaish-kernel/src/parser.rs @@ -2109,6 +2109,7 @@ where ident_parser(), path_parser(), select! { Token::DotSlashPath(s) => s }, + select! { Token::RelativePath(s) => s }, just(Token::True).to("true".to_string()), just(Token::False).to("false".to_string()), just(Token::Colon).to(":".to_string()), @@ -2125,7 +2126,23 @@ where // structurally after parsing, where the message is fully under our control // (verified empirically 2026-06-07). command_name - .then(args_list_parser()) + .map_with(|name, extra| -> (String, Span) { (name, extra.span()) }) + // An adjacent `=` belongs to assignment parsing, including its errors. + .then(just(Token::Eq).map_with(|_, extra| -> Span { extra.span() }).or_not().rewind()) + .filter(|((_, name_span), equals)| { + !equals.is_some_and(|span| name_span.end == span.start) + }) + .map(|(name, _)| name) + .then(args_list_parser().map_with(|args, extra| -> (Vec, Span) { (args, extra.span()) })) + .validate(|((name, name_span), (args, args_span)), _, emitter| { + if !args.is_empty() && name_span.end == args_span.start { + emitter.emit(Rich::custom( + args_span, + "command name and first argument need a space between them", + )); + } + (name, args) + }) .then(redirect_parser(primary_expr_parser()).repeated().collect::>()) .map(|((name, args), redirects)| Command { name, diff --git a/crates/kaish-kernel/tests/glued_arg_span_tests.rs b/crates/kaish-kernel/tests/glued_arg_span_tests.rs index 7b4a7cba..dbfe7898 100644 --- a/crates/kaish-kernel/tests/glued_arg_span_tests.rs +++ b/crates/kaish-kernel/tests/glued_arg_span_tests.rs @@ -40,15 +40,15 @@ fn glued_span_text(source: &str) -> &str { fn colon_glued_path_names_the_whole_word() { // Used to point at `show` — an innocent, already-fine word. assert_eq!( - glued_span_text("git show HEAD:training/v9/x.py"), - "HEAD:training/v9/x.py" + glued_span_text("git show HEAD:training/$version/x.py"), + "HEAD:training/$version/x.py" ); } #[test] fn colon_glued_short_path_names_the_whole_word() { // Used to point at `fetch`. - assert_eq!(glued_span_text("git fetch origin a/b:c"), "a/b:c"); + assert_eq!(glued_span_text("git fetch origin a/b:$ref"), "a/b:$ref"); } #[test] @@ -122,6 +122,7 @@ fn purpose_built_diagnoses_are_never_replaced_by_the_paste_message() { const PASTE: &str = "adjacent words with no space between them are not joined into one"; let cases = [ ("cat > $DIR/out.txt", "redirect target"), + ("./bin$x", "command name and first argument need a space"), ("x={msg: hello world}", "record value: unexpected word"), ("echo ${x:1:2}", "kaish slices with brackets"), ("echo $(foo", "unterminated command substitution"), @@ -162,7 +163,7 @@ fn purpose_built_diagnoses_are_never_replaced_by_the_paste_message() { #[test] fn parser_custom_guard_count_is_pinned() { const PARSER_SOURCE: &str = include_str!("../src/parser.rs"); - const EXPECTED: usize = 12; + const EXPECTED: usize = 13; let found = PARSER_SOURCE.matches("Rich::custom(").count(); assert_eq!( found, EXPECTED, @@ -236,8 +237,8 @@ fn unrelated_failure_keeps_its_own_error_and_span() { #[test] fn long_flag_value_fusion_keeps_spaced_flags_out_of_the_run() { assert_eq!( - glued_span_text("foo --a=1 --b=2 HEAD:x/y"), - "HEAD:x/y", + glued_span_text("foo --a=1 --b=2 HEAD:$dir/y"), + "HEAD:$dir/y", "spaced --key=value flags must not be mistaken for the pasted word" ); } diff --git a/crates/kaish-kernel/tests/literal_path_words_tests.rs b/crates/kaish-kernel/tests/literal_path_words_tests.rs new file mode 100644 index 00000000..7d5c98ff --- /dev/null +++ b/crates/kaish-kernel/tests/literal_path_words_tests.rs @@ -0,0 +1,244 @@ +//! Literal paths keep their source spelling across lexer and parser contexts. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use kaish_kernel::ast::{Arg, Command, Expr, Stmt, Value}; +use kaish_kernel::lexer::{Token, tokenize}; +use kaish_kernel::parser::parse; +use kaish_kernel::{Kernel, KernelConfig}; +use rstest::rstest; + +#[rstest] +#[case(".git/HEAD")] +#[case(".git/logs/HEAD")] +#[case(".git/")] +#[case("./")] +#[case("../")] +#[case("2026/report")] +#[case("007/0644")] +#[case("1.0/report")] +#[case(".5/report")] +#[case(".日本/設定")] +#[case("a+b/file")] +#[case("a@b/file")] +#[case("/tmp/a@b")] +#[case("@scope/pkg+tag")] +#[case("repo/.git/HEAD")] +#[case("./.git/HEAD")] +#[case("../HEAD")] +#[case("foo#bar/file")] +fn literal_path_is_one_word_in_each_context(#[case] path: &str) { + let tokens = tokenize(path).unwrap(); + assert_eq!(tokens.len(), 1, "{path}: {tokens:?}"); + assert_eq!(tokens[0].span, 0..path.len()); + let text = match &tokens[0].token { + Token::Path(text) + | Token::RelativePath(text) + | Token::DotSlashPath(text) + | Token::TildePath(text) + | Token::AtWord(text) => text, + other => panic!("expected literal path: {other:?}"), + }; + assert_eq!(text, path); + + for source in [format!("echo {path}"), format!("echo -- {path}")] { + let program = parse(&source).unwrap(); + assert_eq!(program.statements.len(), 1); + let command = only_command(&program.statements); + let arguments: Vec<_> = command + .args + .iter() + .filter(|arg| !matches!(arg, Arg::DoubleDash)) + .collect(); + assert_eq!(arguments.len(), 1, "{source}: {arguments:?}"); + assert_eq!( + arguments[0], + &Arg::Positional(Expr::Literal(Value::String(path.into()))) + ); + } + + let program = parse(&format!("p={path}")).unwrap(); + assert_eq!(program.statements.len(), 1); + let Stmt::Assignment(assignment) = &program.statements[0] else { + panic!("{program:?}") + }; + assert_eq!(assignment.value, Expr::Literal(Value::String(path.into()))); + + let program = parse(&format!("cat <{path}")).unwrap(); + assert_eq!(program.statements.len(), 1); + let command = only_command(&program.statements); + assert!(command.args.is_empty()); + assert_eq!(command.redirects.len(), 1); + assert_eq!( + command.redirects[0].target, + Expr::Literal(Value::String(path.into())) + ); +} + +#[rstest] +#[case("HEAD:src/main.rs")] +#[case("HEAD:.git/config")] +#[case("host:./dir/file")] +#[case("host:../dir/file")] +#[case("HEAD:007/0644")] +#[case("http://host/path")] +fn colon_path_keeps_one_literal_argument(#[case] path: &str) { + let program = parse(&format!("echo {path}")).unwrap(); + assert_eq!(program.statements.len(), 1); + let command = only_command(&program.statements); + assert_eq!( + command.args, + vec![Arg::Positional(Expr::Literal(Value::String(path.into())))] + ); +} + +#[rstest] +#[case(".git/$name")] +#[case("2026/$(echo report)")] +#[case(".git/\"HEAD\"")] +#[case(".git/'HEAD'")] +#[case("$dir/HEAD")] +#[case(".git/$((1+2))")] +fn path_fragments_still_require_quoting(#[case] word: &str) { + for source in [ + format!("echo {word}"), + format!("echo -- {word}"), + format!("cat <{word}"), + ] { + let errors = parse(&source).expect_err(&source); + assert!( + errors.iter().any(|error| error.message.contains("quote")), + "{source}: {errors:?}" + ); + } +} + +#[rstest] +#[case(".git/*")] +#[case("2026/*.txt")] +#[case("1.0/file*")] +#[case(".git/[ab]")] +fn path_globs_remain_patterns(#[case] pattern: &str) { + let program = parse(&format!("echo {pattern}")).unwrap(); + let command = only_command(&program.statements); + assert_eq!( + command.args, + vec![Arg::Positional(Expr::GlobPattern(pattern.into()))] + ); +} + +#[tokio::test] +async fn reported_command_reads_the_intended_files() { + let kernel = Kernel::new(KernelConfig::isolated()).unwrap(); + let setup = kernel.execute(r#"mkdir -p ".git/logs"; echo ref:main >".git/HEAD"; printf 'User-Agent one\nother\nuser-agent two\n' >".git/logs/HEAD""#).await.unwrap(); + assert_eq!(setup.code, 0, "{}", setup.err); + let result = kernel.execute(r#"cat .git/HEAD; grep -c "" .git/logs/HEAD; grep -n "User-Agent\|user-agent" .git/logs/HEAD | tail -n 5"#).await.unwrap(); + assert_eq!(result.code, 0, "{}", result.err); + assert_eq!( + result.text_out(), + "ref:main\n3\n1:User-Agent one\n3:user-agent two\n" + ); +} + +fn only_command(statements: &[Stmt]) -> &Command { + assert_eq!(statements.len(), 1); + match &statements[0] { + Stmt::Command(command) => command, + Stmt::Pipeline(pipeline) if pipeline.stages.len() == 1 => { + pipeline.stages[0].as_command().unwrap() + } + other => panic!("expected one command: {other:?}"), + } +} + +#[rstest] +#[case(".git/hooks/pre-commit")] +#[case("src/bin")] +#[case("../bin")] +#[case("2026/bin")] +#[case("./bin")] +#[case("/tmp/bin")] +fn literal_executable_paths_keep_the_command_name(#[case] path: &str) { + let program = parse(&format!("{path} argument")).unwrap(); + let command = only_command(&program.statements); + assert_eq!(command.name, path); + assert_eq!( + command.args, + vec![Arg::Positional(Expr::Literal(Value::String( + "argument".into() + )))] + ); +} + +#[rstest] +#[case("./bin$x")] +#[case("/tmp/bin$x")] +#[case(".git/hooks/$name")] +#[case("src/$(echo bin)")] +#[case("./bin\"suffix\"")] +#[case("./bin' suffix'")] +fn executable_fragments_do_not_become_arguments(#[case] source: &str) { + assert!( + parse(source).is_err(), + "{source} must not run a different executable" + ); +} + +#[tokio::test] +async fn collections_numbers_and_source_keep_their_meaning() { + let kernel = Kernel::new(KernelConfig::isolated()).unwrap(); + let result = kernel.execute(r#"p=.git/HEAD; xs=[.git/HEAD 007/0644]; r={file:.git/HEAD}; echo "$p"; echo "${xs[1]}"; echo "${r[file]}"; echo $((6 / 2)); echo 007; echo 1.0; echo 'echo sourced' > script; . script"#).await.unwrap(); + assert_eq!(result.code, 0, "{}", result.err); + assert_eq!( + result.text_out(), + ".git/HEAD\n007/0644\n.git/HEAD\n3\n007\n1.0\nsourced\n" + ); +} + +#[tokio::test] +async fn path_words_reach_the_tool_as_exact_arguments() { + let kernel = Kernel::new(KernelConfig::isolated()).unwrap(); + for path in [ + ".git/HEAD", + "007/0644", + "a+b/file", + "a@b/file", + "HEAD:src/main.rs", + ] { + let result = kernel + .execute(&format!("printf '%s\\n' before {path} after")) + .await + .unwrap(); + assert_eq!(result.code, 0, "{}", result.err); + assert_eq!(result.text_out(), format!("before\n{path}\nafter\n")); + } +} + +#[rstest] +#[case("./bin>out")] +#[case("./bin|cat")] +#[case("./bin;echo next")] +#[case("./bin&&echo next")] +#[case("./bin argument")] +#[case(". script")] +fn command_operators_and_spaced_arguments_still_parse(#[case] source: &str) { + assert!(parse(source).is_ok(), "{source}"); +} + +#[test] +fn tilde_path_keeps_at_sign_in_arguments_and_redirects() { + let tokens = tokenize("~/a@b").unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].token, Token::TildePath("~/a@b".into())); + let program = parse("cat ~/a@b").unwrap(); + assert_eq!( + only_command(&program.statements).args, + vec![Arg::Positional(Expr::Literal(Value::String( + "~/a@b".into() + )))] + ); + let program = parse("cat <~/a@b").unwrap(); + assert_eq!( + only_command(&program.statements).redirects[0].target, + Expr::Literal(Value::String("~/a@b".into())) + ); +} diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md index 96702afa..56649d5f 100644 --- a/docs/LANGUAGE.md +++ b/docs/LANGUAGE.md @@ -523,9 +523,20 @@ A name containing `#` is not a valid assignment target: `abc#3=5` is error `E018`, because `$abc#3` would not read it back. kaish refuses to create a variable nothing can reference. -### Quote to join — kaish does not paste adjacent tokens +### Quote to join -kaish never concatenates adjacent *unquoted* tokens into one word. `$VAR`, +```sh +cat .git/HEAD # literal path: one argument +cat 2026/report # numeric directory name: still a path +cat "$dir/HEAD" # text joined with an expansion: quote the whole word +``` + +Literal paths need no quotes when their characters are allowed unquoted. +This includes `./`, `../`, dot-prefixed directories, and numeric directory +names. A slash makes the word text: `007/0644` is a path, not a number. +`HEAD:src/main.rs` is one literal argument too. + +kaish does not join separate quoted strings, expansions, or adjacent text. `$VAR`, `$(cmd)`, and globs are each their own word; to build a single word from text plus interpolation, **quote the whole thing**: @@ -542,8 +553,8 @@ echo /tmp/$(id -u).sock # error: quote "/tmp/$(id -u).sock" Rather than silently splat such a word into multiple arguments, kaish rejects it at parse time with a "quote the whole word" hint — fail-loud beats wrong argv. -Single-token words are unaffected: `file.txt`, `a.b.c`, and `v1.2.3` lex as one -token, so they need no quoting. +Literal words such as `file.txt`, `a.b.c`, `v1.2.3`, and `.git/HEAD` +need no quoting. This is the complement of the no-word-splitting rule: kaish neither splits a variable's value **nor** pastes neighbouring words. The "always quote @@ -1353,6 +1364,8 @@ npm install # Absolute and relative paths work directly (no PATH lookup) /bin/echo hello # absolute path ./myscript.sh # relative path +.git/hooks/pre-commit # dot-prefixed directory +../bin/check # parent directory # Virtual bin path runs builtins explicitly /v/bin/echo hello # builtin via virtual path @@ -1382,6 +1395,9 @@ date --json # {"iso":…,"epoch":…,"weekday":…, …} ``` **How it works:** +A command name is literal. Put a space between the command name and its +first argument; attached text cannot become a separate argument. + 1. Kaish parses the command (handling quotes, variables, flags) 2. If the name contains `/`, it's used as a direct path (absolute or relative) 3. `/v/bin/name` dispatches to the builtin `name`