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:
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 ""
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user