diff --git a/taicpp/docs/cli.md b/taicpp/docs/cli.md new file mode 100644 index 0000000..2d83d36 --- /dev/null +++ b/taicpp/docs/cli.md @@ -0,0 +1,66 @@ +# cli: the argument parser is a port of argparse + +Goal: `detz` parses its command line exactly like `../tai.py` (`build_parser()` run by the +Python installed here, 3.14). This document is the spec; it states what the code does today and +lists the gaps at the end. + +## Layout (one class per `.h`/`.cpp` pair, `src/cli/`) + +| class | file | role | +|---|---|---| +| `FlagTable` / `FlagSpec` | `flag_spec.*` | the single flag table: option strings, dest, arity, choices, metavar, help. Usage, help and the parser all read it, so they cannot drift | +| `TextWrap` | `text_wrap.*` | port of Python `textwrap.wrap` (hyphen breaking, long-word breaking) | +| `HelpFormatter` | `help_formatter.*` | port of `argparse.HelpFormatter` (3.14): usage wrapping, help columns | +| `ArgParser` | `arg_parser.*` | port of `ArgumentParser._parse_known_args` (pattern string of `A`/`O`/`-`, abbreviations, explicit values, `--`) | +| `Parser`, `Command`, `DetzCli`, `Options` | `cli.*` | stable API others compile against; maps `ArgParser` output onto `Options` | + +`Options`, `Parser::parse/parseOpts/helpText`, `Command::run` keep their names and fields. + +## Behavior (mirrors argparse, each point has a differential case) + +- Unique prefixes of long options resolve (`--vers` is `--version`); an ambiguous prefix fails + with `ambiguous option: --ins could match --install, --install-telegram` (matches listed in + table order). `--tui-` resolves to `--tui-local` because `--tui` does not start with it. +- `--flag=value` for value flags; an explicit value on a switch fails with + `argument --yes: ignored explicit argument '1'`. Single-dash clusters (`-hx`) follow argparse. +- A flag that needs a value and sees an option-like word, `--`, or the end fails with + `argument --profile: expected one argument`. `-1` and `-.5` (anything matching `-\.?\d`) are + values, not options. A word with a space in it is a value. +- `--debug` takes an optional value (`file` when bare), checked against its choices. +- `--cap` and `--mplex` take every remaining word (argparse `REMAINDER`), options included. +- `--` ends options; the first `--` is dropped from the prompt, later ones are kept. +- `type=int` accepts what Python `int()` accepts for ASCII input (surrounding whitespace, sign, + single underscores between digits); anything else is `invalid int value: 'abc'`. +- `-h/--help` acts the moment it is reached, before later words are examined, so + `-h --bogus` prints help (exit 0) while `--cap-permission-mode zzz -h` is an error (exit 2). + `Options` then carries only `help = true`. +- Any parse error: full usage text on stderr, then `tai: error: `, exit 2. + `Options::error` holds both (usage then the error line, no trailing newline). +- Terminal width: `COLUMNS` (if a positive integer), else the width of stdout's tty, else 80; + the formatter uses width - 2, like `shutil.get_terminal_size`. +- Flags that exist only in the old C++ parser (`--model`, `--driver`, `--task`, `--cwd`, + `--argv`, `--binary`, `--max-risk`, `--auto-answer`, `--allow-tool`, `--deny-tool`) are not in + `tai.py` and are rejected as unrecognized, like tai.py does. The `Options` fields stay so + other modules compile. + +## Tests + +Run all with `make -f tests/cli.mk test` (everything lands in `build/cli`). + +- `tests/cli_test.cpp`: unit cases (table, abbreviations, errors, REMAINDER, `--`, int, usage, wrap). +- `tests/cli_parity.py` + `tests/cli_cases.txt`: every argv line of the list at COLUMNS 80 and 40, + `--help` and the usage printed by an error at every width 12..200, and 1500 seeded random argvs, + run through `build_parser()` of `../tai.py` (in process, the installed Python) and through + `build/cli/cli_driver`; stdout, stderr and exit code must be equal (2080 jobs, 0 differences). +- `make -f tests/cli.mk diff`: the generated cases of `tools/parity_diff.py` (`cli.*`, + `tests/diff/cases/cli_generated.json`) against `build/cli/cli_main`, a cli-only entry point that + mirrors the first lines of `main.cpp`. + +## Gaps + +- Colored help (Python 3.14 colors help when `FORCE_COLOR` is set or stdout is a tty with color): + not implemented, plain text only. +- Widths count bytes, not code points (all help text is ASCII; user words only appear in error + messages, which are not wrapped). +- The `--cap-manifest` text and every action after parsing live in `main.cpp`, not here: case + `cli.cap_manifest` fails against `cli_main` for that reason (55 of 56 `cli.*` cases pass). diff --git a/taicpp/src/cli/arg_parser.cpp b/taicpp/src/cli/arg_parser.cpp new file mode 100644 index 0000000..8b131c9 --- /dev/null +++ b/taicpp/src/cli/arg_parser.cpp @@ -0,0 +1,327 @@ +#include "arg_parser.h" +#include +#include +#include +namespace detz::cli { +namespace { +struct ArgError { const FlagSpec* action; std::string message; }; +struct HelpHit {}; + +std::string actionName(const FlagSpec& f) { + std::string out; + for (size_t i = 0; i < f.names.size(); ++i) { if (i) out += "/"; out += f.names[i]; } + return out; +} +[[noreturn]] void fail(const FlagSpec* a, const std::string& msg) { throw ArgError{a, msg}; } +bool startsWith(const std::string& s, const std::string& p) { return s.compare(0, p.size(), p) == 0; } +// _negative_number_matcher = re.compile(r'-\.?\d').match +bool looksNegativeNumber(const std::string& s) { + if (s.size() < 2 || s[0] != '-') return false; + size_t i = 1; + if (s[i] == '.') ++i; + return i < s.size() && std::isdigit(static_cast(s[i])); +} +} // namespace + +ArgParser::ArgParser(const std::vector& table) : table_(table) { + for (const auto& f : table_) for (const auto& n : f.names) options_.emplace_back(n, &f); +} + +const FlagSpec* ArgParser::lookup(const std::string& optionString) const { + for (const auto& o : options_) if (o.first == optionString) return o.second; + return nullptr; +} + +std::string ArgParser::pyRepr(const std::string& s) { + const char quote = (s.find('\'') != std::string::npos && s.find('"') == std::string::npos) ? '"' : '\''; + std::string out(1, quote); + static const char* hex = "0123456789abcdef"; + for (unsigned char c : s) { + if (c == static_cast(quote) || c == '\\') { out += '\\'; out += static_cast(c); } + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else if (c == '\t') out += "\\t"; + else if (c < 0x20 || c == 0x7f) { out += "\\x"; out += hex[c >> 4]; out += hex[c & 15]; } + else out += static_cast(c); + } + return out + quote; +} + +namespace { +// First code point of every Unicode Nd (decimal digit) block of ten, as in Python 3.14's unicodedata. +const unsigned kDigitZeros[] = { + 0x30, 0x660, 0x6F0, 0x7C0, 0x966, 0x9E6, 0xA66, 0xAE6, 0xB66, 0xBE6, 0xC66, 0xCE6, 0xD66, 0xDE6, 0xE50, 0xED0, + 0xF20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80, 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, + 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0, 0xFF10, 0x104A0, 0x10D30, 0x10D40, 0x11066, 0x110F0, 0x11136, + 0x111D0, 0x112F0, 0x11450, 0x114D0, 0x11650, 0x116C0, 0x116D0, 0x116DA, 0x11730, 0x118E0, 0x11950, 0x11BF0, + 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16130, 0x16A60, 0x16AC0, 0x16B50, 0x16D70, 0x1CCF0, 0x1D7CE, 0x1D7D8, + 0x1D7E2, 0x1D7EC, 0x1D7F6, 0x1E140, 0x1E2F0, 0x1E4F0, 0x1E5F1, 0x1E950, 0x1FBF0}; +int digitValue(unsigned cp) { + for (unsigned z : kDigitZeros) if (cp >= z && cp < z + 10) return static_cast(cp - z); + return -1; +} +bool isPySpace(unsigned c) { + return c == ' ' || (c >= 9 && c <= 13) || (c >= 0x1c && c <= 0x1f) || c == 0x85 || c == 0xa0 || c == 0x1680 || + (c >= 0x2000 && c <= 0x200a) || c == 0x2028 || c == 0x2029 || c == 0x202f || c == 0x205f || c == 0x3000; +} +bool decodeUtf8(const std::string& s, std::vector& out) { + for (size_t i = 0; i < s.size();) { + const unsigned char c = static_cast(s[i]); + const int n = c < 0x80 ? 1 : (c >> 5) == 6 ? 2 : (c >> 4) == 14 ? 3 : (c >> 3) == 30 ? 4 : 0; + if (n == 0 || i + static_cast(n) > s.size()) return false; + unsigned cp = n == 1 ? c : c & (0xff >> (n + 1)); + for (int k = 1; k < n; ++k) { + const unsigned char cc = static_cast(s[i + static_cast(k)]); + if ((cc & 0xc0) != 0x80) return false; + cp = (cp << 6) | (cc & 0x3f); + } + out.push_back(cp); + i += static_cast(n); + } + return true; +} +} // namespace + +bool ArgParser::pyInt(const std::string& raw, long long& out) { + std::vector cps; + if (!decodeUtf8(raw, cps)) return false; + size_t a = 0, b = cps.size(); + while (a < b && isPySpace(cps[a])) ++a; + while (b > a && isPySpace(cps[b - 1])) --b; + bool neg = false; + if (a < b && (cps[a] == '+' || cps[a] == '-')) { neg = cps[a] == '-'; ++a; } + if (a >= b) return false; + unsigned long long v = 0; + bool prevUnderscore = true; // a leading underscore is invalid + bool overflow = false; + for (size_t i = a; i < b; ++i) { + if (cps[i] == '_') { if (prevUnderscore) return false; prevUnderscore = true; continue; } + const int d = digitValue(cps[i]); + if (d < 0) return false; + prevUnderscore = false; + if (v > (ULLONG_MAX - 9) / 10) overflow = true; else v = v * 10 + static_cast(d); + } + if (prevUnderscore) return false; + if (overflow || v > static_cast(LLONG_MAX)) out = neg ? LLONG_MIN : LLONG_MAX; + else out = neg ? -static_cast(v) : static_cast(v); + return true; +} + +std::vector ArgParser::optionTuples(const std::string& arg) const { + std::vector result; + if (arg[0] == '-' && arg[1] == '-') { // allow_abbrev: prefix of a long option + const size_t eq = arg.find('='); + const std::string prefix = arg.substr(0, eq); + std::optional sep, expl; + if (eq != std::string::npos) { sep = "="; expl = arg.substr(eq + 1); } + for (const auto& o : options_) + if (startsWith(o.first, prefix)) result.push_back({o.second, o.first, sep, expl}); + } else { // single dash: -xVALUE clusters and prefixes + const size_t eq = arg.find('='); + const std::string prefix = arg.substr(0, eq); + std::optional sep, expl; + if (eq != std::string::npos) { sep = "="; expl = arg.substr(eq + 1); } + const std::string shortPrefix = arg.substr(0, 2), shortExplicit = arg.substr(2); + for (const auto& o : options_) { + if (o.first == shortPrefix) result.push_back({o.second, o.first, std::string(""), shortExplicit}); + else if (startsWith(o.first, prefix)) result.push_back({o.second, o.first, sep, expl}); + } + } + return result; +} + +// Empty vector: positional. Otherwise the candidate interpretations ({nullptr,...} is unknown). +std::vector ArgParser::parseOptional(const std::string& arg) const { + if (arg.empty() || arg[0] != '-') return {}; + if (const FlagSpec* a = lookup(arg)) return {{a, arg, std::nullopt, std::nullopt}}; + if (arg.size() == 1) return {}; + const size_t eq = arg.find('='); + if (eq != std::string::npos) { + if (const FlagSpec* a = lookup(arg.substr(0, eq))) return {{a, arg.substr(0, eq), std::string("="), arg.substr(eq + 1)}}; + } + auto tuples = optionTuples(arg); + if (!tuples.empty()) return tuples; + if (looksNegativeNumber(arg)) return {}; + if (arg.find(' ') != std::string::npos) return {}; + return {{nullptr, arg, std::nullopt, std::nullopt}}; +} + +ParseOutcome ArgParser::parse(const std::vector& args) const { + ParseOutcome outcome; + Namespace& ns = outcome.ns; + const FlagSpec* positional = nullptr; + for (const auto& f : table_) { + if (f.positional()) positional = &f; + } + try { + // classify: 'A' argument, 'O' option, '-' the first "--" + std::map> optionIndices; + std::string pattern; + for (size_t i = 0; i < args.size(); ++i) { + if (args[i] == "--") { + pattern += '-'; + for (++i; i < args.size(); ++i) pattern += 'A'; + break; + } + auto tuples = parseOptional(args[i]); + if (tuples.empty()) pattern += 'A'; + else { optionIndices[i] = std::move(tuples); pattern += 'O'; } + } + + // how many following pattern characters the action takes + auto matchArgument = [&](const FlagSpec& a, const std::string& pat) -> size_t { + switch (a.nargs) { + case Nargs::Help: case Nargs::Flag: return 0; + case Nargs::One: + if (pat.empty() || pat[0] != 'A') fail(&a, "expected one argument"); + return 1; + case Nargs::Optional: return (!pat.empty() && pat[0] == 'A') ? 1 : 0; + case Nargs::Remainder: { + size_t n = 0; + while (n < pat.size() && (pat[n] == 'A' || pat[n] == 'O')) ++n; + return n; + } + case Nargs::Star: { + size_t n = 0; + while (n < pat.size() && (pat[n] == 'A' || pat[n] == '-')) ++n; + return n; + } + } + return 0; + }; + auto convertAndCheck = [&](const FlagSpec& a, const std::string& v) -> std::string { + if (a.integer) { + long long n = 0; + if (!pyInt(v, n)) fail(&a, "invalid int value: " + pyRepr(v)); + } + if (!a.choices.empty() && std::find(a.choices.begin(), a.choices.end(), v) == a.choices.end()) { + std::string list; + for (size_t i = 0; i < a.choices.size(); ++i) { if (i) list += ", "; list += a.choices[i]; } + fail(&a, "invalid choice: " + pyRepr(v) + " (choose from " + list + ")"); + } + return v; + }; + auto takeAction = [&](const FlagSpec& a, std::vector values) { + switch (a.nargs) { + case Nargs::Help: throw HelpHit{}; + case Nargs::Flag: ns.flags[a.dest] = true; break; + case Nargs::Remainder: case Nargs::Star: ns.lists[a.dest] = std::move(values); break; + case Nargs::Optional: case Nargs::One: { + // a bare nargs="?" flag takes its const unchecked, like argparse + const std::string v = values.empty() ? a.constant : convertAndCheck(a, values[0]); + if (a.integer) { long long n = 0; pyInt(v, n); ns.numbers[a.dest] = n; } + else ns.strings[a.dest] = v; + break; + } + } + }; + + std::vector extras; + bool positionalDone = false; + + auto consumeOptional = [&](size_t startIndex) -> size_t { + const auto& tuples = optionIndices.at(startIndex); + if (tuples.size() > 1) { + std::string matches; + for (size_t i = 0; i < tuples.size(); ++i) { if (i) matches += ", "; matches += tuples[i].optionString; } + fail(nullptr, "ambiguous option: " + args[startIndex] + " could match " + matches); + } + const FlagSpec* action = tuples[0].action; + std::string optionString = tuples[0].optionString; + std::optional sep = tuples[0].sep, explicitArg = tuples[0].explicitArg; + struct Pending { const FlagSpec* action; std::vector values; }; + std::vector pending; + size_t stop = startIndex + 1; + for (;;) { + if (!action) { extras.push_back(args[startIndex]); return startIndex + 1; } + if (explicitArg) { + const size_t argCount = matchArgument(*action, "A"); + if (argCount == 0 && optionString[1] != '-' && !explicitArg->empty()) { + if ((sep && !sep->empty()) || (*explicitArg)[0] == '-') + fail(action, "ignored explicit argument " + pyRepr(*explicitArg)); + pending.push_back({action, {}}); + const char ch = optionString[0]; + optionString = std::string(1, ch) + (*explicitArg)[0]; + if (const FlagSpec* next = lookup(optionString)) { + action = next; + explicitArg = explicitArg->substr(1); + if (explicitArg->empty()) { sep.reset(); explicitArg.reset(); } + else if ((*explicitArg)[0] == '=') { sep = "="; explicitArg = explicitArg->substr(1); } + else sep = ""; + } else { + extras.push_back(std::string(1, ch) + *explicitArg); + stop = startIndex + 1; + break; + } + } else if (argCount == 1) { + stop = startIndex + 1; + pending.push_back({action, {*explicitArg}}); + break; + } else { + fail(action, "ignored explicit argument " + pyRepr(*explicitArg)); + } + } else { + const size_t start = startIndex + 1; + const size_t argCount = matchArgument(*action, pattern.substr(std::min(start, pattern.size()))); + stop = start + argCount; + pending.push_back({action, std::vector(args.begin() + static_cast(start), + args.begin() + static_cast(stop))}); + break; + } + } + for (auto& p : pending) takeAction(*p.action, std::move(p.values)); + return stop; + }; + + // consume the (single) positional at startIndex; returns the index where it stopped + auto consumePositionals = [&](size_t startIndex) -> size_t { + if (positionalDone || !positional) return startIndex; + const std::string sel = pattern.substr(std::min(startIndex, pattern.size())); + size_t count = matchArgument(*positional, sel); + if (count < sel.size() && sel[count] == 'O' && count == 0) return startIndex; // trailing zero dropped + std::vector values(args.begin() + static_cast(startIndex), + args.begin() + static_cast(startIndex + count)); + if (pattern.find('-', startIndex) < startIndex + count) { + auto it = std::find(values.begin(), values.end(), "--"); + if (it != values.end()) values.erase(it); + } + positionalDone = true; + takeAction(*positional, std::move(values)); + return startIndex + count; + }; + + size_t startIndex = 0; + const bool anyOption = !optionIndices.empty(); + const size_t maxOption = anyOption ? optionIndices.rbegin()->first : 0; + while (anyOption && startIndex <= maxOption) { + size_t next = startIndex; + while (next <= maxOption && !optionIndices.count(next)) ++next; + if (startIndex != next) { + const size_t end = consumePositionals(startIndex); + if (end > startIndex) { startIndex = end; continue; } + startIndex = end; + } + if (!optionIndices.count(startIndex)) { + for (size_t i = startIndex; i < next; ++i) extras.push_back(args[i]); + startIndex = next; + } + startIndex = consumeOptional(startIndex); + } + const size_t stopIndex = consumePositionals(startIndex); + for (size_t i = stopIndex; i < args.size(); ++i) extras.push_back(args[i]); + + if (!ns.lists.count("prompt") && positional) ns.lists["prompt"] = {}; + if (!extras.empty()) { + std::string joined; + for (size_t i = 0; i < extras.size(); ++i) { if (i) joined += " "; joined += extras[i]; } + fail(nullptr, "unrecognized arguments: " + joined); + } + } catch (const HelpHit&) { + outcome.kind = ParseOutcome::Kind::Help; + } catch (const ArgError& e) { + outcome.kind = ParseOutcome::Kind::Error; + outcome.message = e.action ? "argument " + actionName(*e.action) + ": " + e.message : e.message; + } + return outcome; +} +} // namespace detz::cli diff --git a/taicpp/src/cli/arg_parser.h b/taicpp/src/cli/arg_parser.h new file mode 100644 index 0000000..8382575 --- /dev/null +++ b/taicpp/src/cli/arg_parser.h @@ -0,0 +1,47 @@ +#pragma once +// Port of argparse.ArgumentParser._parse_known_args (Python 3.14) over the FlagTable. +// The args are classified into a pattern string ('A' argument, 'O' option, '-' for the first +// '--'), then options and positionals are consumed alternately exactly like argparse. +#include +#include +#include +#include +#include "flag_spec.h" +namespace detz::cli { +// Parsed values by dest. A dest is present only when its flag was given (or, for the +// positional, always). +struct Namespace { + std::map flags; + std::map strings; + std::map numbers; + std::map> lists; + bool has(const std::string& dest) const { + return flags.count(dest) || strings.count(dest) || numbers.count(dest) || lists.count(dest); + } +}; +struct ParseOutcome { + enum class Kind { Ok, Help, Error } kind = Kind::Ok; + Namespace ns; + std::string message; // Error: the text after "tai: error: " +}; +class ArgParser { + public: + explicit ArgParser(const std::vector& table = FlagTable::all()); + ParseOutcome parse(const std::vector& args) const; + // Python repr() of a str, as used in argparse messages. + static std::string pyRepr(const std::string& s); + // Python int(str) for ASCII input; false when int() would raise. + static bool pyInt(const std::string& s, long long& out); + private: + struct Tuple { + const FlagSpec* action; // null: unknown option + std::string optionString; + std::optional sep, explicitArg; + }; + std::vector parseOptional(const std::string& arg) const; + std::vector optionTuples(const std::string& arg) const; + const FlagSpec* lookup(const std::string& optionString) const; + const std::vector& table_; + std::vector> options_; // declaration order +}; +} // namespace detz::cli diff --git a/taicpp/src/cli/cli.cpp b/taicpp/src/cli/cli.cpp index 206081c..6a16309 100644 --- a/taicpp/src/cli/cli.cpp +++ b/taicpp/src/cli/cli.cpp @@ -1,152 +1,53 @@ #include "cli.h" #include "version.hpp" +#include "arg_parser.h" +#include "help_formatter.h" +#include +#include #include #include namespace detz::cli { -std::string Parser::helpText() { - return std::string( - "usage: tai [-h] [--profile PROFILE] [--yes] [--yolo] [--auto] [--continue]\n" - " [--debug [SINK]] [--debug-format FORMAT] [--env ENV]\n" - " [--no-opencode] [--update-models] [--eval-retrieval] [--stats-rag]\n" - " [--version] [--cap-manifest] [--cap-speak] [--cap-cwd DIR]\n" - " [--cap-model MODEL]\n" - " [--cap-permission-mode {none,confirm,interactive}]\n" - " [--cap-max-turns N] [--cap ...] [--serve-mcp KEY] [--mcp-port PORT]\n" - " [--mcp-bind HOST] [--telegram] [--scheduler] [--install]\n" - " [--install-telegram] [--uninstall-telegram] [--plexer-wrap]\n" - " [--mplex ...] [--demo] [--demo-shot] [--tui] [--tui-local]\n" - " [--theme NAME]\n" - " [prompt ...]\n" - "\n" - "single-file autonomous agent, standard library only\n" - "\n" - "positional arguments:\n" - " prompt\n" - "\n" - "options:\n" - " -h, --help show this help message and exit\n" - " --profile PROFILE\n" - " --yes\n" - " --yolo\n" - " --auto\n" - " --continue\n" - " --debug [SINK] extensive debug logging to /debug.log (file),\n" - " stdout, or stderr, plus the vault logs table (see\n" - " logging.md)\n" - " --debug-format FORMAT\n" - " debug log line format, sentence or json (default\n" - " sentence, or TAI_DEBUG_FORMAT)\n" - " --env ENV\n" - " --no-opencode skip the opencode CLI backends for this run (same as\n" - " TAI_OPENCODE=0)\n" - " --update-models rediscover the free model lists of every provider,\n" - " drop models that are gone, and exit\n" - " --eval-retrieval score vault retrieval (Hit@K, MRR, nDCG, Context\n" - " Precision) on generated goldens plus implicit pairs,\n" - " print the report, and exit\n" - " --stats-rag print RAG usage and effectiveness stats (vault totals,\n" - " searches, reads, per-record context uses, retrieval\n" - " eval) and exit\n" - " --version\n" - " --cap-manifest print tai's CAP agent manifest (cap-agent.toml) and\n" - " exit\n" - " --cap-speak speak CAP stream-json on stdio as an agent (bulk\n" - " session config via CAP_SESSION_CONFIG)\n" - " --cap-cwd DIR speak session working directory (overrides\n" - " CAP_SESSION_CONFIG cwd)\n" - " --cap-model MODEL speak session model label (overrides\n" - " CAP_SESSION_CONFIG model)\n" - " --cap-permission-mode {none,confirm,interactive}\n" - " speak session permission mode (overrides\n" - " CAP_SESSION_CONFIG permission_mode)\n" - " --cap-max-turns N speak session turn cap (overrides CAP_SESSION_CONFIG\n" - " max_turns)\n" - " --cap ... orchestrator: resolve, chat or run fleet commands (see\n" - " cap.md)\n" - " --serve-mcp KEY serve MCP over HTTP (Streamable HTTP): all native\n" - " tools plus chat/completions, Bearer KEY required (see\n" - " mcp.md)\n" - " --mcp-port PORT serve-mcp port (default TAI_MCP_PORT else 8765)\n" - " --mcp-bind HOST serve-mcp bind address (default TAI_MCP_BIND else\n" - " 127.0.0.1)\n" - " --telegram\n" - " --scheduler\n" - " --install\n" - " --install-telegram\n" - " --uninstall-telegram\n" - " --plexer-wrap internal: invoked by the bashrc plexer hook, not meant\n" - " to be run by hand\n" - " --mplex ... terminal multiplexer: any mplex command (new-session,\n" - " attach-session, list-keys ...); bare --mplex starts a\n" - " session. Config: ~/.tai/mplex.toml\n" - " --demo run the fullscreen TUI framework showcase\n" - " --demo-shot print one composed demo frame as text and exit\n" - " --tui run the fullscreen grr client (bots, chats,\n" - " automations, library, projects, settings) against the\n" - " grr server; address in GRR_URL\n" - " --tui-local run the fullscreen Telegram-style chat UI on the local\n" - " vault, no server needed\n" - " --theme NAME TUI theme: dark, light, nord, gruvbox, ansi (or\n" - " TAI_THEME)\n"); +std::string Parser::helpText() { return HelpFormatter(HelpFormatter::terminalColumns()).formatHelp(); } + +namespace { +// Copies the argparse Namespace onto Options; every dest has exactly one line here. +void bind(const Namespace& ns, Options& o) { + auto str = [&](const char* d, std::optional& f) { auto it = ns.strings.find(d); if (it != ns.strings.end()) f = it->second; }; + auto flag = [&](const char* d, bool& f) { f = ns.flags.count(d) != 0; }; + auto list = [&](const char* d, std::optional>& f) { auto it = ns.lists.find(d); if (it != ns.lists.end()) f = it->second; }; + auto num = [&](const char* d, std::optional& f) { + auto it = ns.numbers.find(d); + if (it != ns.numbers.end()) f = static_cast(std::clamp(it->second, INT_MIN, INT_MAX)); + }; + str("profile", o.profile); flag("yes", o.yes); flag("yolo", o.yolo); flag("auto", o.auto_); flag("resume", o.resume); + str("debug", o.debug); str("debug_format", o.debug_format); str("env", o.env); + flag("no_opencode", o.no_opencode); flag("update_models", o.update_models); + flag("eval_retrieval", o.eval_retrieval); flag("stats_rag", o.stats_rag); + flag("version", o.version); flag("cap_manifest", o.cap_manifest); flag("cap_speak", o.cap_speak); + str("cap_cwd", o.cap_cwd); str("cap_model", o.cap_model); str("cap_permission_mode", o.cap_permission_mode); + num("cap_max_turns", o.cap_max_turns); list("cap", o.cap); + str("serve_mcp", o.serve_mcp); num("mcp_port", o.mcp_port); str("mcp_bind", o.mcp_bind); + flag("telegram", o.telegram); flag("scheduler", o.scheduler); flag("install", o.install); + flag("install_telegram", o.install_telegram); flag("uninstall_telegram", o.uninstall_telegram); + flag("plexer_wrap", o.plexer_wrap); list("mplex", o.mplex); + flag("demo", o.demo); flag("demo_shot", o.demo_shot); flag("tui", o.tui); flag("tui_local", o.tui_local); + str("theme", o.theme); + auto p = ns.lists.find("prompt"); + if (p != ns.lists.end()) o.prompt = p->second; } - - - +} // namespace Options Parser::parseOpts(int argc, char** argv) { Options o; - for (int i = 1; i < argc; ++i) { - std::string a = argv[i]; - auto nxt = [&](std::optional& f) { if (i+1 < argc) f = argv[++i]; }; - if (a=="--profile") nxt(o.profile); - else if (a=="--yes") o.yes = true; - else if (a=="--yolo") o.yolo = true; - else if (a=="--auto") o.auto_ = true; - else if (a=="--continue") o.resume = true; - else if (a=="--debug") { std::string v = (i+1{}; while (++ipush_back(argv[i]); break; } - else if (a=="--serve-mcp") nxt(o.serve_mcp); - else if (a=="--mcp-port") { if (i+1{}; while (++ipush_back(argv[i]); break; } - else if (a=="--demo") o.demo = true; - else if (a=="--demo-shot") o.demo_shot = true; - else if (a=="--model") { if(i+1 args; + for (int i = 1; i < argc; ++i) args.emplace_back(argv[i]); + const ParseOutcome r = ArgParser{}.parse(args); + if (r.kind == ParseOutcome::Kind::Help) { o.help = true; return o; } // argparse exits at -h; nothing else counts + if (r.kind == ParseOutcome::Kind::Error) { + o.error = HelpFormatter(HelpFormatter::terminalColumns()).formatUsage() + FlagTable::prog() + ": error: " + r.message; + return o; } + bind(r.ns, o); return o; } Command Parser::parse(int argc, char** argv) { diff --git a/taicpp/src/cli/flag_spec.cpp b/taicpp/src/cli/flag_spec.cpp new file mode 100644 index 0000000..7651de3 --- /dev/null +++ b/taicpp/src/cli/flag_spec.cpp @@ -0,0 +1,79 @@ +#include "flag_spec.h" +namespace detz::cli { +namespace { +FlagSpec sw(std::string name, std::string dest, std::string help = "") { + FlagSpec f; f.names = {std::move(name)}; f.dest = std::move(dest); f.nargs = Nargs::Flag; f.help = std::move(help); + return f; +} +FlagSpec val(std::string name, std::string dest, std::string metavar = "", std::string help = "") { + FlagSpec f; f.names = {std::move(name)}; f.dest = std::move(dest); f.nargs = Nargs::One; + f.metavar = std::move(metavar); f.help = std::move(help); + return f; +} +std::vector build() { + std::vector t; + FlagSpec h; h.names = {"-h", "--help"}; h.dest = "help"; h.nargs = Nargs::Help; h.help = "show this help message and exit"; + t.push_back(h); + t.push_back(val("--profile", "profile")); + t.push_back(sw("--yes", "yes")); + t.push_back(sw("--yolo", "yolo")); + t.push_back(sw("--auto", "auto")); + t.push_back(sw("--continue", "resume")); + FlagSpec dbg = val("--debug", "debug", "SINK", "extensive debug logging to /debug.log (file), stdout, or stderr, plus the vault logs table (see logging.md)"); + dbg.nargs = Nargs::Optional; dbg.constant = "file"; dbg.choices = {"file", "stdout", "stderr"}; + t.push_back(dbg); + FlagSpec df = val("--debug-format", "debug_format", "FORMAT", "debug log line format, sentence or json (default sentence, or TAI_DEBUG_FORMAT)"); + df.choices = {"sentence", "json"}; + t.push_back(df); + t.push_back(val("--env", "env")); + t.push_back(sw("--no-opencode", "no_opencode", "skip the opencode CLI backends for this run (same as TAI_OPENCODE=0)")); + t.push_back(sw("--update-models", "update_models", "rediscover the free model lists of every provider, drop models that are gone, and exit")); + t.push_back(sw("--eval-retrieval", "eval_retrieval", "score vault retrieval (Hit@K, MRR, nDCG, Context Precision) on generated goldens plus implicit pairs, print the report, and exit")); + t.push_back(sw("--stats-rag", "stats_rag", "print RAG usage and effectiveness stats (vault totals, searches, reads, per-record context uses, retrieval eval) and exit")); + t.push_back(sw("--version", "version")); + t.push_back(sw("--cap-manifest", "cap_manifest", "print tai's CAP agent manifest (cap-agent.toml) and exit")); + t.push_back(sw("--cap-speak", "cap_speak", "speak CAP stream-json on stdio as an agent (bulk session config via CAP_SESSION_CONFIG)")); + t.push_back(val("--cap-cwd", "cap_cwd", "DIR", "speak session working directory (overrides CAP_SESSION_CONFIG cwd)")); + t.push_back(val("--cap-model", "cap_model", "MODEL", "speak session model label (overrides CAP_SESSION_CONFIG model)")); + FlagSpec pm = val("--cap-permission-mode", "cap_permission_mode", "", "speak session permission mode (overrides CAP_SESSION_CONFIG permission_mode)"); + pm.choices = {"none", "confirm", "interactive"}; + t.push_back(pm); + FlagSpec mt = val("--cap-max-turns", "cap_max_turns", "N", "speak session turn cap (overrides CAP_SESSION_CONFIG max_turns)"); + mt.integer = true; + t.push_back(mt); + FlagSpec cap = val("--cap", "cap", "COMMAND", "orchestrator: resolve, chat or run fleet commands (see cap.md)"); + cap.nargs = Nargs::Remainder; + t.push_back(cap); + t.push_back(val("--serve-mcp", "serve_mcp", "KEY", "serve MCP over HTTP (Streamable HTTP): all native tools plus chat/completions, Bearer KEY required (see mcp.md)")); + FlagSpec port = val("--mcp-port", "mcp_port", "PORT", "serve-mcp port (default TAI_MCP_PORT else 8765)"); + port.integer = true; + t.push_back(port); + t.push_back(val("--mcp-bind", "mcp_bind", "HOST", "serve-mcp bind address (default TAI_MCP_BIND else 127.0.0.1)")); + t.push_back(sw("--telegram", "telegram")); + t.push_back(sw("--scheduler", "scheduler")); + t.push_back(sw("--install", "install")); + t.push_back(sw("--install-telegram", "install_telegram")); + t.push_back(sw("--uninstall-telegram", "uninstall_telegram")); + t.push_back(sw("--plexer-wrap", "plexer_wrap", "internal: invoked by the bashrc plexer hook, not meant to be run by hand")); + FlagSpec mp = val("--mplex", "mplex", "COMMAND", "terminal multiplexer: any mplex command (new-session, attach-session, list-keys ...); bare --mplex starts a session. Config: ~/.tai/mplex.toml"); + mp.nargs = Nargs::Remainder; + t.push_back(mp); + t.push_back(sw("--demo", "demo", "run the fullscreen TUI framework showcase")); + t.push_back(sw("--demo-shot", "demo_shot", "print one composed demo frame as text and exit")); + t.push_back(sw("--tui", "tui", "run the fullscreen grr client (bots, chats, automations, library, projects, settings) against the grr server; address in GRR_URL")); + t.push_back(sw("--tui-local", "tui_local", "run the fullscreen Telegram-style chat UI on the local vault, no server needed")); + t.push_back(val("--theme", "theme", "NAME", "TUI theme: dark, light, nord, gruvbox, ansi (or TAI_THEME)")); + FlagSpec pr; pr.dest = "prompt"; pr.nargs = Nargs::Star; + t.push_back(pr); + return t; +} +} // namespace +const std::vector& FlagTable::all() { + static const std::vector table = build(); + return table; +} +const FlagSpec* FlagTable::find(const std::string& dest) { + for (const auto& f : all()) if (f.dest == dest) return &f; + return nullptr; +} +} // namespace detz::cli diff --git a/taicpp/src/cli/flag_spec.h b/taicpp/src/cli/flag_spec.h new file mode 100644 index 0000000..28f4783 --- /dev/null +++ b/taicpp/src/cli/flag_spec.h @@ -0,0 +1,28 @@ +#pragma once +// The single flag table of tai's argparse parser (tai.py build_parser). Usage, help and the +// argument parser all read it; nothing else may restate a flag. See docs/cli.md. +#include +#include +namespace detz::cli { +// Arity of an argparse action. Help and Flag take no value (nargs=0). +enum class Nargs { Help, Flag, One, Optional, Remainder, Star }; +struct FlagSpec { + std::vector names; // option strings in declaration order; empty for a positional + std::string dest; + Nargs nargs = Nargs::Flag; + bool integer = false; // type=int + std::vector choices; // empty: unrestricted + std::string metavar; // empty: derive (choices, else dest upper-cased / dest) + std::string help; // empty: listed without help text + std::string constant; // value of a bare nargs="?" flag + bool positional() const { return names.empty(); } + bool takesNoValue() const { return nargs == Nargs::Help || nargs == Nargs::Flag; } +}; +class FlagTable { + public: + static const std::vector& all(); + static const char* prog() { return "tai"; } + static const char* description() { return "single-file autonomous agent, standard library only"; } + static const FlagSpec* find(const std::string& dest); +}; +} // namespace detz::cli diff --git a/taicpp/src/cli/help_formatter.cpp b/taicpp/src/cli/help_formatter.cpp new file mode 100644 index 0000000..ff5147e --- /dev/null +++ b/taicpp/src/cli/help_formatter.cpp @@ -0,0 +1,199 @@ +#include "help_formatter.h" +#include +#include +#include +#include +#include +#include "text_wrap.h" +namespace detz::cli { +namespace { +std::string upper(std::string s) { + for (auto& c : s) c = static_cast(std::toupper(static_cast(c))); + return s; +} +std::string join(const std::vector& v, const std::string& sep) { + std::string out; + for (size_t i = 0; i < v.size(); ++i) { if (i) out += sep; out += v[i]; } + return out; +} +std::string pad(int n) { return std::string(static_cast(std::max(n, 0)), ' '); } +// re.sub(r'\n\n\n+', '\n\n', s) +std::string squeezeBreaks(const std::string& s) { + std::string out; + size_t i = 0; + while (i < s.size()) { + if (s[i] == '\n') { + size_t j = i; + while (j < s.size() && s[j] == '\n') ++j; + out.append(j - i >= 3 ? 2 : j - i, '\n'); + i = j; + } else { out += s[i++]; } + } + return out; +} +std::string stripNewlines(const std::string& s) { + size_t a = s.find_first_not_of('\n'); + if (a == std::string::npos) return ""; + return s.substr(a, s.find_last_not_of('\n') - a + 1); +} +} // namespace + +int HelpFormatter::terminalColumns() { + int columns = 0; + if (const char* env = std::getenv("COLUMNS")) { + char* end = nullptr; + long v = std::strtol(env, &end, 10); + while (end && *end && std::isspace(static_cast(*end))) ++end; + if (end != env && end && *end == '\0') columns = static_cast(v); + } + if (columns <= 0) { + struct winsize ws{}; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) columns = ws.ws_col; + } + return columns > 0 ? columns : 80; +} + +HelpFormatter::HelpFormatter(int columns, const std::vector& table) + : width_(columns - 2), maxHelpPosition_(std::min(24, std::max(columns - 2 - 20, 4))), table_(table) {} + +std::string HelpFormatter::metavarOf(const FlagSpec& f) { + if (!f.metavar.empty()) return f.metavar; + if (!f.choices.empty()) return "{" + join(f.choices, ",") + "}"; + return f.positional() ? f.dest : upper(f.dest); +} + +std::string HelpFormatter::argsString(const FlagSpec& f) { + switch (f.nargs) { + case Nargs::One: return metavarOf(f); + case Nargs::Optional: return "[" + metavarOf(f) + "]"; + case Nargs::Star: return "[" + metavarOf(f) + " ...]"; + case Nargs::Remainder: return "..."; + default: return ""; + } +} + +std::string HelpFormatter::invocation(const FlagSpec& f) { + if (f.positional()) return metavarOf(f); + std::string s = join(f.names, ", "); + if (!f.takesNoValue()) s += " " + argsString(f); + return s; +} + +std::vector HelpFormatter::usageParts(size_t& posStart) const { + std::vector parts; + for (const auto& f : table_) { + if (f.positional()) continue; + parts.push_back("[" + (f.takesNoValue() ? f.names[0] : f.names[0] + " " + argsString(f)) + "]"); + } + posStart = parts.size(); + for (const auto& f : table_) if (f.positional()) parts.push_back(argsString(f)); + return parts; +} + +std::string HelpFormatter::formatUsage() const { + const std::string prefix = "usage: "; + const std::string prog = FlagTable::prog(); + size_t posStart = 0; + std::vector parts = usageParts(posStart); + std::string usage = prog; + for (const auto& p : parts) usage += " " + p; + const int textWidth = width_; + if (static_cast(prefix.size() + usage.size()) > textWidth) { + std::vector optParts(parts.begin(), parts.begin() + static_cast(posStart)); + std::vector posParts(parts.begin() + static_cast(posStart), parts.end()); + auto getLines = [&](const std::vector& ps, const std::string& indent, bool withPrefix) { + std::vector lines, line; + const int indentLen = static_cast(indent.size()); + int lineLen = withPrefix ? static_cast(prefix.size()) - 1 : indentLen - 1; + for (const auto& part : ps) { + const int partLen = static_cast(part.size()); + if (lineLen + 1 + partLen > textWidth && !line.empty()) { + lines.push_back(indent + join(line, " ")); + line.clear(); + lineLen = indentLen - 1; + } + line.push_back(part); + lineLen += partLen + 1; + } + if (!line.empty()) lines.push_back(indent + join(line, " ")); + if (withPrefix && !lines.empty()) lines[0] = lines[0].substr(static_cast(indentLen)); + return lines; + }; + std::vector lines; + const double progLen = static_cast(prog.size()); + if (static_cast(prefix.size()) + progLen <= 0.75 * textWidth) { + const std::string indent = pad(static_cast(prefix.size() + prog.size()) + 1); + std::vector first{prog}; + if (!optParts.empty()) { + first.insert(first.end(), optParts.begin(), optParts.end()); + lines = getLines(first, indent, true); + auto more = getLines(posParts, indent, false); + lines.insert(lines.end(), more.begin(), more.end()); + } else if (!posParts.empty()) { + first.insert(first.end(), posParts.begin(), posParts.end()); + lines = getLines(first, indent, true); + } else { + lines = {prog}; + } + } else { + const std::string indent = pad(static_cast(prefix.size())); + std::vector all = optParts; + all.insert(all.end(), posParts.begin(), posParts.end()); + lines = getLines(all, indent, false); + if (lines.size() > 1) { + lines = getLines(optParts, indent, false); + auto more = getLines(posParts, indent, false); + lines.insert(lines.end(), more.begin(), more.end()); + } + lines.insert(lines.begin(), prog); + } + usage = join(lines, "\n"); + } + return stripNewlines(prefix + usage + "\n\n") + "\n"; +} + +std::string HelpFormatter::actionBlock(const FlagSpec& f, int indent, int helpPosition) const { + const int helpWidth = std::max(width_ - helpPosition, 11); + const int actionWidth = helpPosition - indent - 2; + const std::string header = invocation(f); + std::string out; + int indentFirst = 0; + if (f.help.empty()) { + return pad(indent) + header + "\n"; + } else if (static_cast(header.size()) <= actionWidth) { + out = pad(indent) + header + pad(actionWidth - static_cast(header.size())) + " "; + } else { + out = pad(indent) + header + "\n"; + indentFirst = helpPosition; + } + std::string flat; // _whitespace_matcher.sub(' ', text).strip() + bool space = false; + for (char c : f.help) { + if (std::isspace(static_cast(c))) { space = true; continue; } + if (space && !flat.empty()) flat += ' '; + space = false; + flat += c; + } + const auto lines = TextWrap::wrap(flat, helpWidth); + for (size_t i = 0; i < lines.size(); ++i) out += pad(i == 0 ? indentFirst : helpPosition) + lines[i] + "\n"; + return out; +} + +std::string HelpFormatter::formatHelp() const { + const int indent = 2; + int maxLen = 0; + for (const auto& f : table_) maxLen = std::max(maxLen, static_cast(invocation(f).size()) + indent); + const int helpPosition = std::min(maxLen + 2, maxHelpPosition_); + std::string positionals, optionals; + for (const auto& f : table_) (f.positional() ? positionals : optionals) += actionBlock(f, indent, helpPosition); + std::string desc; + { + std::string text = FlagTable::description(); + desc = TextWrap::fill(text, std::max(width_, 11)) + "\n\n"; + } + std::string text = formatUsage() + "\n" + desc; + if (!positionals.empty()) text += "\npositional arguments:\n" + positionals + "\n"; + if (!optionals.empty()) text += "\noptions:\n" + optionals + "\n"; + return stripNewlines(squeezeBreaks(text)) + "\n"; +} +} // namespace detz::cli diff --git a/taicpp/src/cli/help_formatter.h b/taicpp/src/cli/help_formatter.h new file mode 100644 index 0000000..f03dcb8 --- /dev/null +++ b/taicpp/src/cli/help_formatter.h @@ -0,0 +1,24 @@ +#pragma once +// Port of argparse.HelpFormatter (Python 3.14) driven by the FlagTable. Plain text only. +#include +#include +#include "flag_spec.h" +namespace detz::cli { +class HelpFormatter { + public: + // columns: terminal width as reported by the terminal (the formatter uses columns - 2). + explicit HelpFormatter(int columns, const std::vector& table = FlagTable::all()); + std::string formatUsage() const; // "usage: ...\n" + std::string formatHelp() const; // usage, description, positional arguments, options + // shutil.get_terminal_size().columns: COLUMNS env if > 0, else stdout tty width, else 80. + static int terminalColumns(); + static std::string metavarOf(const FlagSpec& f); // metavar shown for the flag's value + static std::string argsString(const FlagSpec& f); // _format_args + static std::string invocation(const FlagSpec& f); // "-h, --help" / "--profile PROFILE" + private: + std::vector usageParts(size_t& posStart) const; + std::string actionBlock(const FlagSpec& f, int indent, int helpPosition) const; + int width_, maxHelpPosition_; + const std::vector& table_; +}; +} // namespace detz::cli diff --git a/taicpp/src/cli/text_wrap.cpp b/taicpp/src/cli/text_wrap.cpp new file mode 100644 index 0000000..711cbbf --- /dev/null +++ b/taicpp/src/cli/text_wrap.cpp @@ -0,0 +1,108 @@ +#include "text_wrap.h" +#include +#include +namespace detz::cli { +namespace { +bool isWs(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'; } +bool isLetter(char c) { return std::isalpha(static_cast(c)) != 0; } +bool isWord(char c) { return std::isalnum(static_cast(c)) != 0 || c == '_'; } +bool isWordPunct(char c) { return isWord(c) || c == '!' || c == '"' || c == '\'' || c == '&' || c == '.' || c == ',' || c == '?'; } +bool blank(const std::string& s) { return std::all_of(s.begin(), s.end(), isWs); } +} // namespace + +// Equivalent of textwrap.TextWrapper.wordsep_re.split(text) with empty pieces dropped. +std::vector TextWrap::chunks(const std::string& t) { + std::vector out; + const size_t n = t.size(); + size_t i = 0; + while (i < n) { + if (isWs(t[i])) { // whitespace run + size_t j = i; + while (j < n && isWs(t[j])) ++j; + out.push_back(t.substr(i, j - i)); + i = j; + continue; + } + if (t[i] == '-' && i > 0 && isWordPunct(t[i - 1])) { // em-dash between words + size_t j = i; + while (j < n && t[j] == '-') ++j; + if (j - i >= 2 && j < n && isWord(t[j])) { out.push_back(t.substr(i, j - i)); i = j; continue; } + } + // word: lazily extend until a hyphen break, end of word, or em-dash boundary + size_t p = i + 1; + for (;; ++p) { + if (p >= n || isWs(t[p])) break; + if (t[p] == '-') { // hyphenated word: the hyphen ends the chunk + const size_t q = p + 1; // position after the hyphen + bool back = (q >= 3 && isLetter(t[q - 2]) && isLetter(t[q - 3])) || + (q >= 4 && isLetter(t[q - 2]) && t[q - 3] == '-' && isLetter(t[q - 4])); + bool ahead = q < n && isLetter(t[q]) && + ((q + 1 < n && isLetter(t[q + 1])) || (q + 2 < n && t[q + 1] == '-' && isLetter(t[q + 2]))); + if (back && ahead) { p = q; break; } + if (isWordPunct(t[p - 1]) && p + 1 < n && t[p + 1] == '-') { // (?=-{2,}\w) + size_t j = p; + while (j < n && t[j] == '-') ++j; + if (j < n && isWord(t[j])) break; + } + } + } + out.push_back(t.substr(i, p - i)); + i = p; + } + return out; +} + +std::vector TextWrap::wrap(const std::string& raw, int width, const std::string& initial, + const std::string& subsequent) { + std::string text; // expand_tabs / replace_whitespace: each whitespace char becomes one space + for (char c : raw) text += isWs(c) ? ' ' : c; + std::vector rev = chunks(text); + std::reverse(rev.begin(), rev.end()); + std::vector lines; + while (!rev.empty()) { + std::vector cur; + int curLen = 0; + const std::string& indent = lines.empty() ? initial : subsequent; + const int w = width - static_cast(indent.size()); + if (blank(rev.back()) && !lines.empty()) rev.pop_back(); // drop whitespace at line start + while (!rev.empty()) { + const int l = static_cast(rev.back().size()); + if (curLen + l <= w) { cur.push_back(rev.back()); rev.pop_back(); curLen += l; } + else break; + } + if (!rev.empty() && static_cast(rev.back().size()) > w) { // _handle_long_word + const int spaceLeft = w < 1 ? 1 : w - curLen; + if (spaceLeft > 0) { + std::string chunk = rev.back(); + int end = spaceLeft; + if (static_cast(chunk.size()) > spaceLeft) { + const size_t hy = chunk.rfind('-', static_cast(spaceLeft) - 1); + if (hy != std::string::npos && hy > 0 && + std::any_of(chunk.begin(), chunk.begin() + static_cast(hy), [](char c) { return c != '-'; })) + end = static_cast(hy) + 1; + } + cur.push_back(chunk.substr(0, static_cast(end))); + rev.back() = chunk.substr(static_cast(end)); + } else if (cur.empty()) { + cur.push_back(rev.back()); + rev.pop_back(); + } + curLen = 0; + for (const auto& c : cur) curLen += static_cast(c.size()); + } + if (!cur.empty() && blank(cur.back())) { curLen -= static_cast(cur.back().size()); cur.pop_back(); } + if (!cur.empty()) { + std::string line = indent; + for (const auto& c : cur) line += c; + lines.push_back(line); + } + } + return lines; +} + +std::string TextWrap::fill(const std::string& text, int width, const std::string& initial, const std::string& subsequent) { + std::string out; + for (const auto& l : wrap(text, width, initial, subsequent)) { if (!out.empty()) out += '\n'; out += l; } + return out; +} +} // namespace detz::cli diff --git a/taicpp/src/cli/text_wrap.h b/taicpp/src/cli/text_wrap.h new file mode 100644 index 0000000..e446c0d --- /dev/null +++ b/taicpp/src/cli/text_wrap.h @@ -0,0 +1,16 @@ +#pragma once +// Port of Python textwrap.wrap/fill with the defaults argparse relies on +// (break_on_hyphens, break_long_words, drop_whitespace). Input is ASCII. +#include +#include +namespace detz::cli { +class TextWrap { + public: + static std::vector wrap(const std::string& text, int width, + const std::string& initial = "", const std::string& subsequent = ""); + static std::string fill(const std::string& text, int width, + const std::string& initial = "", const std::string& subsequent = ""); + private: + static std::vector chunks(const std::string& text); +}; +} // namespace detz::cli diff --git a/taicpp/tests/cli.mk b/taicpp/tests/cli.mk new file mode 100644 index 0000000..98fdf83 --- /dev/null +++ b/taicpp/tests/cli.mk @@ -0,0 +1,36 @@ +# Standalone build of the cli module, its unit test and the argparse parity driver. +# Usage (from taicpp/): make -f tests/cli.mk test +# Every output lands in build/cli. +CXX ?= g++ +CXXFLAGS ?= -std=c++23 -Wall -Wextra -O1 +MOLO ?= /home/retoor/projects/moloapp/src +B = build/cli +GEN = $(B)/gen +SRCS = $(wildcard src/cli/*.cpp) +INC = -Isrc/cli -I$(GEN) -I$(MOLO) + +all: $(B)/cli_test $(B)/cli_driver $(B)/cli_main + +$(GEN)/version.hpp: VERSION src/cli/version.hpp.in + @mkdir -p $(GEN) + @sed 's/@VERSION@/$(shell cat VERSION)/' src/cli/version.hpp.in > $@ + +$(B)/cli_test: tests/cli_test.cpp $(SRCS) $(GEN)/version.hpp + $(CXX) $(CXXFLAGS) $(INC) tests/cli_test.cpp $(SRCS) -o $@ + +$(B)/cli_driver: tests/cli_driver.cpp $(SRCS) $(GEN)/version.hpp + $(CXX) $(CXXFLAGS) $(INC) tests/cli_driver.cpp $(SRCS) -o $@ + +$(B)/cli_main: tests/cli_main.cpp $(SRCS) $(GEN)/version.hpp + $(CXX) $(CXXFLAGS) $(INC) tests/cli_main.cpp $(SRCS) -o $@ + +# Parse-only differential cases of tools/parity_diff.py against the cli-only entry point. +diff: $(B)/cli_main + python3 tools/parity_diff.py --detz $(CURDIR)/$(B)/cli_main --filter cli. + +test: $(B)/cli_test $(B)/cli_driver + $(B)/cli_test + python3 tests/cli_parity.py --driver $(B)/cli_driver + +clean: + rm -rf $(B) diff --git a/taicpp/tests/cli_cases.txt b/taicpp/tests/cli_cases.txt new file mode 100644 index 0000000..f26f6a0 --- /dev/null +++ b/taicpp/tests/cli_cases.txt @@ -0,0 +1,101 @@ +--vers +--yes=1 +--cap-max-turns abc +-h --bogus +--debug zzz +--profile=x --yes +--cap-m +-z +-- --yes +--yes foo bar +--mcp-port= +--cap-max-turns -1 +--profile -x +--profile --yes +-hx +--cap -x --bogus +--ver=1 +--debug --yes +--debug=stdout +--debug= +--debug foo +--yes -- a b +a --yes b +--profile +-1 +--theme -1 +--cap-permission-mode +--cap --yes +--cap -- a +-- --cap a +--mplex +--mplex -h +--debug -- a +--debug a b +--debug=file a +--ins +--cap-ma 3 +--cap-max 3 +--cap-max-turns=3 +--cap-max-turns 3x +--cap-max-turns " 3" +--cap-max-turns 1_0 +--profile= +-y +--y +--ye +--yes --yes +--profile a --profile b +--cap=x +--cap a --yes +foo --cap a +--debug-f json +--debug-format=json +--debug-format +--debug stdout stderr +--cap-permission-mode=none +--cap-permission-mode none +--cap-permission-mode=x +-- +- +- a +a - +-h +--help=1 +--help +-hh +-h=1 +--prompt +--prompt=x +a -- b -- c +--profile -- a +-- -- a +--debug=--yes +--yes= +--cap-max-turns -- 3 +--tui --tui-local +--tui- +--tui-l +--cap-s +--cap-speak= +x --debug y +-3 +-3 a +--profile -3 +--cap-max-turns -3 +--cap-max-turns -3x +--profile "-x y" +--profile "- x" +" --yes" +"-x y" +"--zz y" +"--zz=y" +"--vers y" +"--vers=y" +--debug-format --yes +--serve-mcp +--serve-mcp "" +--mcp-port 0x10 +--mcp-port ٣ +--mcp-port 3.0 +--mcp-port "" diff --git a/taicpp/tests/cli_driver.cpp b/taicpp/tests/cli_driver.cpp new file mode 100644 index 0000000..1ac0564 --- /dev/null +++ b/taicpp/tests/cli_driver.cpp @@ -0,0 +1,42 @@ +// Parse-only driver for tests/cli_parity.py: behaves like detz for errors and -h, and for a +// successful parse prints the argparse Namespace as sorted JSON (see docs/cli.md, Tests). +#include +#include +#include +#include +#include "../src/cli/arg_parser.h" +#include "../src/cli/cli.h" +using namespace detz::cli; +namespace { +std::string jstr(const std::string& s) { + std::string o = "\""; + for (unsigned char c : s) { + if (c == '"' || c == '\\') { o += '\\'; o += static_cast(c); } + else if (c == '\n') o += "\\n"; + else if (c < 0x20) { char b[8]; std::snprintf(b, sizeof b, "\\u%04x", c); o += b; } + else o += static_cast(c); + } + return o + "\""; +} +} // namespace +int main(int argc, char** argv) { + Command cmd = Parser().parse(argc, argv); + if (!cmd.opts.error.empty() || cmd.opts.help) return cmd.run(); + std::vector args(argv + 1, argv + argc); + const ParseOutcome r = ArgParser{}.parse(args); + std::vector> kv; + for (auto& [k, v] : r.ns.flags) kv.push_back({k, v ? "true" : "false"}); + for (auto& [k, v] : r.ns.strings) kv.push_back({k, jstr(v)}); + for (auto& [k, v] : r.ns.numbers) kv.push_back({k, std::to_string(v)}); + for (auto& [k, v] : r.ns.lists) { + if (k == "prompt" && v.empty()) continue; + std::string l = "["; + for (size_t i = 0; i < v.size(); ++i) { if (i) l += ", "; l += jstr(v[i]); } + kv.push_back({k, l + "]"}); + } + std::sort(kv.begin(), kv.end()); + std::string out = "{"; + for (size_t i = 0; i < kv.size(); ++i) { if (i) out += ", "; out += jstr(kv[i].first) + ": " + kv[i].second; } + std::puts((out + "}").c_str()); + return 0; +} diff --git a/taicpp/tests/cli_main.cpp b/taicpp/tests/cli_main.cpp new file mode 100644 index 0000000..4a5741a --- /dev/null +++ b/taicpp/tests/cli_main.cpp @@ -0,0 +1,14 @@ +// Minimal detz entry point over the cli module only, so tools/parity_diff.py can run the +// parse-only cases (tests/diff/cases/cli_generated.json) without the full binary. +// It mirrors the first lines of main.cpp (error, version) and then Command::run. +#include +#include +#include "../src/cli/cli.h" +#include "version.hpp" +int main(int argc, char** argv) { + detz::cli::Parser p; + auto cmd = p.parse(argc, argv); + if (!cmd.opts.error.empty()) { std::fputs((cmd.opts.error + "\n").c_str(), stderr); return 2; } + if (cmd.opts.version) { std::puts(("tai " + std::string(detz::kVersion)).c_str()); return 0; } + return cmd.run(); +} diff --git a/taicpp/tests/cli_parity.py b/taicpp/tests/cli_parity.py new file mode 100644 index 0000000..17d8feb --- /dev/null +++ b/taicpp/tests/cli_parity.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Parser parity: tai.py build_parser() (in process) versus build/cli/cli_driver. + +Compares stdout, stderr and exit code for every argv in tests/cli_cases.txt (one shlex line +each) at COLUMNS=80 and 40, and --help plus the usage printed by an error for every terminal +width from 12 to 200. The Python side is the real argparse of the installed interpreter. +Usage: tests/cli_parity.py --driver build/cli/cli_driver [--verbose] +""" +import argparse +import contextlib +import io +import json +import os +import random +import shlex +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, os.path.dirname(ROOT)) +import tai # noqa: E402 (the tai.py next to taicpp/) + + +def reference(argv, columns): + os.environ["COLUMNS"] = str(columns) + out, err = io.StringIO(), io.StringIO() + code, text = 0, "" + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + ns = tai.build_parser().parse_args(argv) + keep = {} + for key, value in vars(ns).items(): + if value is None or value is False or (key == "prompt" and value == []): + continue + keep[key] = value + text = json.dumps(keep, sort_keys=True) + "\n" + except SystemExit as exc: + code = exc.code or 0 + return out.getvalue() + text, err.getvalue(), code + + +def candidate(driver, argv, columns): + env = dict(os.environ, COLUMNS=str(columns)) + p = subprocess.run([driver] + argv, capture_output=True, text=True, env=env, timeout=30) + return p.stdout, p.stderr, p.returncode + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--driver", default=os.path.join(ROOT, "build", "cli", "cli_driver")) + ap.add_argument("--verbose", action="store_true") + ap.add_argument("--fuzz", type=int, default=1500, help="random argv count (seeded, deterministic)") + args = ap.parse_args() + cases = [] + with open(os.path.join(HERE, "cli_cases.txt"), encoding="utf-8") as fh: + for line in fh.read().split("\n"): + if line.strip(): + cases.append(shlex.split(line)) + jobs = [(a, c) for a in cases for c in (80, 40)] + for c in range(12, 201): + jobs.append((["--help"], c)) + jobs.append((["--bogus"], c)) + rng = random.Random(20260101) + vocab = ["--profile", "--yes", "--debug", "--debug-format", "--cap", "--cap-m", "--cap-max-turns", "--cap-permission-mode", + "--mcp-port", "--mplex", "--ins", "--tui", "--tui-", "--vers", "--version", "-h", "-hx", "-z", "--", "-", "-3", + "-3x", "--x=1", "--yes=1", "--debug=stdout", "--profile=p", "a", "b c", "file", "stdout", "json", "none", "7", + "abc", "", "--theme", "--env", "-1.5", "--y", "--u"] + for _ in range(max(args.fuzz, 0)): + jobs.append(([rng.choice(vocab) for _ in range(rng.randint(1, 5))], rng.choice((80, 50, 100)))) + bad = 0 + for argv, columns in jobs: + want, got = reference(argv, columns), candidate(args.driver, argv, columns) + if want != got: + bad += 1 + print("FAIL argv=%r COLUMNS=%d" % (argv, columns)) + if args.verbose or bad <= 3: + for name, w, g in zip(("stdout", "stderr", "exit"), want, got): + if w != g: + print(" %s\n --- python\n%s\n --- detz\n%s" % (name, w, g)) + print("cli parity: %d jobs, %d failed" % (len(jobs), bad)) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/taicpp/tests/cli_test.cpp b/taicpp/tests/cli_test.cpp index 70e46f1..ca5d8e0 100644 --- a/taicpp/tests/cli_test.cpp +++ b/taicpp/tests/cli_test.cpp @@ -1,7 +1,62 @@ #include "../src/cli/cli.h" #include +#include #include +#include +#include "../src/cli/arg_parser.h" +#include "../src/cli/help_formatter.h" +#include "../src/cli/text_wrap.h" +using namespace detz::cli; +static Options opts(std::vector a) { + std::vector v{const_cast("detz")}; + for (auto x : a) v.push_back(const_cast(x)); + return Parser().parseOpts(static_cast(v.size()), v.data()); +} +static bool errHas(const Options& o, const std::string& m) { return o.error.find("tai: error: " + m) != std::string::npos && o.error.rfind("usage: tai", 0) == 0; } +static void argparseCases() { + assert(opts({"--vers"}).version); + assert(errHas(opts({"--ins"}), "ambiguous option: --ins could match --install, --install-telegram")); + assert(errHas(opts({"--profile"}), "argument --profile: expected one argument")); + assert(errHas(opts({"--yes=1"}), "argument --yes: ignored explicit argument '1'")); + assert(errHas(opts({"--cap-max-turns", "abc"}), "argument --cap-max-turns: invalid int value: 'abc'")); + assert(errHas(opts({"--debug", "zzz"}), "argument --debug: invalid choice: 'zzz' (choose from file, stdout, stderr)")); + assert(errHas(opts({"--bogus"}), "unrecognized arguments: --bogus")); + assert(opts({"-h", "--bogus"}).help && opts({"-h", "--bogus"}).error.empty()); + assert(errHas(opts({"--cap-permission-mode", "zzz", "-h"}), "argument --cap-permission-mode: invalid choice")); + assert(opts({"--debug"}).debug.value() == "file"); + assert(opts({"--debug=stderr"}).debug.value() == "stderr"); + assert(opts({"--profile=x", "hello", "world"}).prompt == (std::vector{"hello", "world"})); + assert(opts({"--cap", "--yes", "x"}).cap.value() == (std::vector{"--yes", "x"})); + assert(!opts({"--cap", "--yes"}).yes); + assert(opts({"--mplex"}).mplex.has_value() && opts({"--mplex"}).mplex->empty()); + assert(opts({"--", "--yes"}).prompt == (std::vector{"--yes"})); + assert(opts({"--cap-max-turns", "-1"}).cap_max_turns.value() == -1); + assert(opts({"--mcp-port", " 1_0 "}).mcp_port.value() == 10); + assert(errHas(opts({"--model", "x"}), "unrecognized arguments: --model x") || errHas(opts({"--model", "x"}), "unrecognized arguments: --model")); + long long n = 0; + assert(ArgParser::pyInt("\xd9\xa3", n) && n == 3); + assert(!ArgParser::pyInt("3x", n) && !ArgParser::pyInt("_3", n) && !ArgParser::pyInt("", n)); + assert(ArgParser::pyRepr("it's") == "\"it's\"" && ArgParser::pyRepr("a'\"") == "'a\\'\"'"); +} +static void helpCases() { + // Usage, help and parser share one table: every option string of the table is in --help. + setenv("COLUMNS", "80", 1); + const std::string help = Parser::helpText(); + for (const auto& f : FlagTable::all()) + for (const auto& n : f.names) assert(help.find(n) != std::string::npos); + assert(help.rfind("usage: tai [-h] [--profile PROFILE]", 0) == 0); + // width 80: the wrapped help column starts at 24 + assert(help.find(" --debug [SINK] extensive debug logging to /debug.log (file),\n") != std::string::npos); + setenv("COLUMNS", "40", 1); + assert(HelpFormatter(HelpFormatter::terminalColumns()).formatUsage().find("\n [--yes] [--yolo] [--auto]\n") != std::string::npos); + // textwrap: breaks after hyphens inside words, breaks over-long words + assert(TextWrap::wrap("per-record context", 10) == (std::vector{"per-record", "context"})); + assert(TextWrap::wrap("abcdefghij", 4) == (std::vector{"abcd", "efgh", "ij"})); + unsetenv("COLUMNS"); +} int main() { + argparseCases(); + helpCases(); detz::cli::Parser p; // golden --help contains every flag std::string h = detz::cli::Parser::helpText();