cli: faithful argparse port (single flag table, HelpFormatter, textwrap, abbreviations, usage on error)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 07:02:03 +01:00
co-authored by Claude Sonnet 5.5
parent f44e505b0f
commit c8f6818997
16 changed files with 1268 additions and 139 deletions
+66
View File
@@ -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: <message>`, 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).
+327
View File
@@ -0,0 +1,327 @@
#include "arg_parser.h"
#include <algorithm>
#include <cctype>
#include <climits>
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<unsigned char>(s[i]));
}
} // namespace
ArgParser::ArgParser(const std::vector<FlagSpec>& 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<unsigned char>(quote) || c == '\\') { out += '\\'; out += static_cast<char>(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<char>(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<int>(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<unsigned>& out) {
for (size_t i = 0; i < s.size();) {
const unsigned char c = static_cast<unsigned char>(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<size_t>(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<unsigned char>(s[i + static_cast<size_t>(k)]);
if ((cc & 0xc0) != 0x80) return false;
cp = (cp << 6) | (cc & 0x3f);
}
out.push_back(cp);
i += static_cast<size_t>(n);
}
return true;
}
} // namespace
bool ArgParser::pyInt(const std::string& raw, long long& out) {
std::vector<unsigned> 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<unsigned>(d);
}
if (prevUnderscore) return false;
if (overflow || v > static_cast<unsigned long long>(LLONG_MAX)) out = neg ? LLONG_MIN : LLONG_MAX;
else out = neg ? -static_cast<long long>(v) : static_cast<long long>(v);
return true;
}
std::vector<ArgParser::Tuple> ArgParser::optionTuples(const std::string& arg) const {
std::vector<Tuple> 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<std::string> 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<std::string> 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::Tuple> 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<std::string>& 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<size_t, std::vector<Tuple>> 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<std::string> 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<std::string> 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<std::string> sep = tuples[0].sep, explicitArg = tuples[0].explicitArg;
struct Pending { const FlagSpec* action; std::vector<std::string> values; };
std::vector<Pending> 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<std::string>(args.begin() + static_cast<long>(start),
args.begin() + static_cast<long>(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<std::string> values(args.begin() + static_cast<long>(startIndex),
args.begin() + static_cast<long>(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
+47
View File
@@ -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 <map>
#include <optional>
#include <string>
#include <vector>
#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<std::string, bool> flags;
std::map<std::string, std::string> strings;
std::map<std::string, long long> numbers;
std::map<std::string, std::vector<std::string>> 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<FlagSpec>& table = FlagTable::all());
ParseOutcome parse(const std::vector<std::string>& 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<std::string> sep, explicitArg;
};
std::vector<Tuple> parseOptional(const std::string& arg) const;
std::vector<Tuple> optionTuples(const std::string& arg) const;
const FlagSpec* lookup(const std::string& optionString) const;
const std::vector<FlagSpec>& table_;
std::vector<std::pair<std::string, const FlagSpec*>> options_; // declaration order
};
} // namespace detz::cli
+40 -139
View File
@@ -1,152 +1,53 @@
#include "cli.h"
#include "version.hpp"
#include "arg_parser.h"
#include "help_formatter.h"
#include <algorithm>
#include <climits>
#include <cstdio>
#include <string>
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 <home>/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<std::string>& 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<std::vector<std::string>>& f) { auto it = ns.lists.find(d); if (it != ns.lists.end()) f = it->second; };
auto num = [&](const char* d, std::optional<int>& f) {
auto it = ns.numbers.find(d);
if (it != ns.numbers.end()) f = static_cast<int>(std::clamp<long long>(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<std::string>& 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<argc && argv[i+1][0]!='-') ? argv[++i] : "file"; if (v!="file"&&v!="stdout"&&v!="stderr") { if (o.error.empty()) o.error = "tai: error: argument --debug: invalid choice: '" + v + "' (choose from 'file', 'stdout', 'stderr')"; } else o.debug = v; }
else if (a=="--debug-format") { if (i+1<argc) { std::string v = argv[++i]; if (v!="sentence"&&v!="json") { if (o.error.empty()) o.error = "tai: error: argument --debug-format: invalid choice: '" + v + "' (choose from 'sentence', 'json')"; } else o.debug_format = v; } else if (o.error.empty()) o.error = "tai: error: argument --debug-format: expected one argument"; }
else if (a=="--env") nxt(o.env);
else if (a=="--no-opencode") o.no_opencode = true;
else if (a=="--update-models") o.update_models = true;
else if (a=="--eval-retrieval") o.eval_retrieval = true;
else if (a=="--stats-rag") o.stats_rag = true;
else if (a=="--version") o.version = true;
else if (a=="--cap-manifest") o.cap_manifest = true;
else if (a=="--cap-speak") o.cap_speak = true;
else if (a=="--cap-cwd") nxt(o.cap_cwd);
else if (a=="--cap-model") nxt(o.cap_model);
else if (a=="--cap-permission-mode") { if (i+1<argc) { std::string v = argv[++i]; if (v!="none"&&v!="confirm"&&v!="interactive") { if (o.error.empty()) o.error = "tai: error: argument --cap-permission-mode: invalid choice: '" + v + "' (choose from 'none', 'confirm', 'interactive')"; } else o.cap_permission_mode = v; } }
else if (a=="--cap-max-turns") { if (i+1<argc) o.cap_max_turns = std::stoi(argv[++i]); }
else if (a=="--cap") { o.cap = std::vector<std::string>{}; while (++i<argc) o.cap->push_back(argv[i]); break; }
else if (a=="--serve-mcp") nxt(o.serve_mcp);
else if (a=="--mcp-port") { if (i+1<argc) o.mcp_port = std::stoi(argv[++i]); }
else if (a=="--mcp-bind") nxt(o.mcp_bind);
else if (a=="--telegram") o.telegram = true;
else if (a=="--scheduler") o.scheduler = true;
else if (a=="--install") o.install = true;
else if (a=="--install-telegram") o.install_telegram = true;
else if (a=="--uninstall-telegram") o.uninstall_telegram = true;
else if (a=="--plexer-wrap") o.plexer_wrap = true;
else if (a=="--mplex") { o.mplex = std::vector<std::string>{}; while (++i<argc) o.mplex->push_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<argc) o.model=argv[++i]; }
else if (a=="--driver") { if(i+1<argc) o.driver=argv[++i]; }
else if (a=="--task") { if(i+1<argc) o.task=argv[++i]; }
else if (a=="--cwd") { if(i+1<argc) o.cwd=argv[++i]; }
else if (a=="--argv") { if(i+1<argc) o.argv=argv[++i]; }
else if (a=="--binary") { if(i+1<argc) o.binary=argv[++i]; }
else if (a=="--max-risk") { if(i+1<argc) o.max_risk=argv[++i]; }
else if (a=="--auto-answer") { if(i+1<argc) o.auto_answer=argv[++i]; }
else if (a=="--allow-tool") { if(i+1<argc) o.allow_tool.push_back(argv[++i]); }
else if (a=="--deny-tool") { if(i+1<argc) o.deny_tool.push_back(argv[++i]); }
else if (a=="--tui") o.tui = true;
else if (a=="--tui-local") o.tui_local = true;
else if (a=="--theme") nxt(o.theme);
else if (a=="--help"||a=="-h") o.help = true;
else if (!a.empty() && a[0]=='-') { if (o.error.empty()) o.error = "tai: error: unrecognized arguments: " + a; }
else o.prompt.push_back(a);
std::vector<std::string> 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) {
+79
View File
@@ -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<FlagSpec> build() {
std::vector<FlagSpec> 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 <home>/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<FlagSpec>& FlagTable::all() {
static const std::vector<FlagSpec> 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
+28
View File
@@ -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 <string>
#include <vector>
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<std::string> names; // option strings in declaration order; empty for a positional
std::string dest;
Nargs nargs = Nargs::Flag;
bool integer = false; // type=int
std::vector<std::string> 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<FlagSpec>& 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
+199
View File
@@ -0,0 +1,199 @@
#include "help_formatter.h"
#include <sys/ioctl.h>
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include "text_wrap.h"
namespace detz::cli {
namespace {
std::string upper(std::string s) {
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return s;
}
std::string join(const std::vector<std::string>& 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<size_t>(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<unsigned char>(*end))) ++end;
if (end != env && end && *end == '\0') columns = static_cast<int>(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<FlagSpec>& 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<std::string> HelpFormatter::usageParts(size_t& posStart) const {
std::vector<std::string> 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<std::string> parts = usageParts(posStart);
std::string usage = prog;
for (const auto& p : parts) usage += " " + p;
const int textWidth = width_;
if (static_cast<int>(prefix.size() + usage.size()) > textWidth) {
std::vector<std::string> optParts(parts.begin(), parts.begin() + static_cast<long>(posStart));
std::vector<std::string> posParts(parts.begin() + static_cast<long>(posStart), parts.end());
auto getLines = [&](const std::vector<std::string>& ps, const std::string& indent, bool withPrefix) {
std::vector<std::string> lines, line;
const int indentLen = static_cast<int>(indent.size());
int lineLen = withPrefix ? static_cast<int>(prefix.size()) - 1 : indentLen - 1;
for (const auto& part : ps) {
const int partLen = static_cast<int>(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<size_t>(indentLen));
return lines;
};
std::vector<std::string> lines;
const double progLen = static_cast<double>(prog.size());
if (static_cast<double>(prefix.size()) + progLen <= 0.75 * textWidth) {
const std::string indent = pad(static_cast<int>(prefix.size() + prog.size()) + 1);
std::vector<std::string> 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<int>(prefix.size()));
std::vector<std::string> 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<int>(header.size()) <= actionWidth) {
out = pad(indent) + header + pad(actionWidth - static_cast<int>(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<unsigned char>(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<int>(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
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// Port of argparse.HelpFormatter (Python 3.14) driven by the FlagTable. Plain text only.
#include <string>
#include <vector>
#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<FlagSpec>& 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<std::string> usageParts(size_t& posStart) const;
std::string actionBlock(const FlagSpec& f, int indent, int helpPosition) const;
int width_, maxHelpPosition_;
const std::vector<FlagSpec>& table_;
};
} // namespace detz::cli
+108
View File
@@ -0,0 +1,108 @@
#include "text_wrap.h"
#include <algorithm>
#include <cctype>
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<unsigned char>(c)) != 0; }
bool isWord(char c) { return std::isalnum(static_cast<unsigned char>(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<std::string> TextWrap::chunks(const std::string& t) {
std::vector<std::string> 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<std::string> 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<std::string> rev = chunks(text);
std::reverse(rev.begin(), rev.end());
std::vector<std::string> lines;
while (!rev.empty()) {
std::vector<std::string> cur;
int curLen = 0;
const std::string& indent = lines.empty() ? initial : subsequent;
const int w = width - static_cast<int>(indent.size());
if (blank(rev.back()) && !lines.empty()) rev.pop_back(); // drop whitespace at line start
while (!rev.empty()) {
const int l = static_cast<int>(rev.back().size());
if (curLen + l <= w) { cur.push_back(rev.back()); rev.pop_back(); curLen += l; }
else break;
}
if (!rev.empty() && static_cast<int>(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<int>(chunk.size()) > spaceLeft) {
const size_t hy = chunk.rfind('-', static_cast<size_t>(spaceLeft) - 1);
if (hy != std::string::npos && hy > 0 &&
std::any_of(chunk.begin(), chunk.begin() + static_cast<long>(hy), [](char c) { return c != '-'; }))
end = static_cast<int>(hy) + 1;
}
cur.push_back(chunk.substr(0, static_cast<size_t>(end)));
rev.back() = chunk.substr(static_cast<size_t>(end));
} else if (cur.empty()) {
cur.push_back(rev.back());
rev.pop_back();
}
curLen = 0;
for (const auto& c : cur) curLen += static_cast<int>(c.size());
}
if (!cur.empty() && blank(cur.back())) { curLen -= static_cast<int>(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
+16
View File
@@ -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 <string>
#include <vector>
namespace detz::cli {
class TextWrap {
public:
static std::vector<std::string> 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<std::string> chunks(const std::string& text);
};
} // namespace detz::cli
+36
View File
@@ -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)
+101
View File
@@ -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 ""
+42
View File
@@ -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 <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#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<char>(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<char>(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<std::string> args(argv + 1, argv + argc);
const ParseOutcome r = ArgParser{}.parse(args);
std::vector<std::pair<std::string, std::string>> 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;
}
+14
View File
@@ -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 <cstdio>
#include <string>
#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();
}
+86
View File
@@ -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())
+55
View File
@@ -1,7 +1,62 @@
#include "../src/cli/cli.h"
#include <cassert>
#include <cstdlib>
#include <string>
#include <vector>
#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<const char*> a) {
std::vector<char*> v{const_cast<char*>("detz")};
for (auto x : a) v.push_back(const_cast<char*>(x));
return Parser().parseOpts(static_cast<int>(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<std::string>{"hello", "world"}));
assert(opts({"--cap", "--yes", "x"}).cap.value() == (std::vector<std::string>{"--yes", "x"}));
assert(!opts({"--cap", "--yes"}).yes);
assert(opts({"--mplex"}).mplex.has_value() && opts({"--mplex"}).mplex->empty());
assert(opts({"--", "--yes"}).prompt == (std::vector<std::string>{"--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 <home>/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<std::string>{"per-record", "context"}));
assert(TextWrap::wrap("abcdefghij", 4) == (std::vector<std::string>{"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();