cli: argparse-style errors and choices; add parity ledger generator

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 01:09:03 +02:00
co-authored by Claude Sonnet 5.5
parent e184f68292
commit 01bd396a12
4 changed files with 120 additions and 8 deletions
+1
View File
@@ -8,6 +8,7 @@
#include <string>
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; }
if(cmd.opts.cap_manifest){ std::fputs("# tai CAP agent manifest\n", stdout); return 0; }
if(cmd.name=="run" && !cmd.args.empty()){
+8 -8
View File
@@ -5,7 +5,6 @@
namespace detz::cli {
std::string Parser::helpText() {
return std::string(
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"
@@ -88,8 +87,7 @@ std::string Parser::helpText() {
" --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("\n version ") + std::string(kVersion) + std::string("\n"));
" TAI_THEME)\n");
}
@@ -105,8 +103,8 @@ Options Parser::parseOpts(int argc, char** argv) {
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") { o.debug = (i+1<argc && argv[i+1][0]!='-') ? argv[++i] : "file"; }
else if (a=="--debug-format") nxt(o.debug_format);
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;
@@ -117,7 +115,7 @@ Options Parser::parseOpts(int argc, char** argv) {
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") nxt(o.cap_permission_mode);
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);
@@ -146,20 +144,22 @@ Options Parser::parseOpts(int argc, char** argv) {
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]=='-') { /* unknown flag: ignore */ }
else if (!a.empty() && a[0]=='-') { if (o.error.empty()) o.error = "tai: error: unrecognized arguments: " + a; }
else o.prompt.push_back(a);
}
return o;
}
Command Parser::parse(int argc, char** argv) {
Command c; c.opts = parseOpts(argc, argv);
if (c.opts.help || argc<=1) c.name = "--help";
if (!c.opts.error.empty()) c.name = "--error";
else if (c.opts.help || argc<=1) c.name = "--help";
else if (c.opts.version) c.name = "--version";
else c.name = "run";
for (int i=1;i<argc;++i) c.args.emplace_back(argv[i]);
return c;
}
int Command::run() {
if (!opts.error.empty()) { std::fputs((opts.error + "\n").c_str(), stderr); return 2; }
if (opts.version || name=="--version") { std::puts(("tai " + std::string(kVersion)).c_str()); return 0; }
std::fputs(Parser::helpText().c_str(), stdout);
return 0;
+1
View File
@@ -28,6 +28,7 @@ struct Options {
std::string max_risk = "low", auto_answer;
std::vector<std::string> allow_tool, deny_tool;
bool help = false;
std::string error; // non-empty => argparse-style parse failure, exit 2
std::vector<std::string> prompt;
};
struct Command { std::string name{"--help"}; std::vector<std::string> args; Options opts; int run(); };
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Parity ledger: every tai.py symbol against the C++ tree.
Usage: tools/parity_ledger.py [--check] (writes build/parity/ledger.json and ledger.md)
Status per symbol:
missing no identifier in src/ or main.cpp matches the normalized name
name-only an identifier matches; behavior is NOT verified by this tool
Verification (diff of real outputs against tai.py) is a separate, later status
and is never inferred here. --check exits 1 while any symbol is missing.
"""
import ast
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PY = os.path.join(os.path.dirname(ROOT), "tai.py")
OUT = os.path.join(ROOT, "build", "parity")
def norm(name):
return re.sub(r"[^a-z0-9]", "", name.lower())
def cpp_idents():
idents = set()
files = [os.path.join(ROOT, "main.cpp")]
for base, _dirs, names in os.walk(os.path.join(ROOT, "src")):
files += [os.path.join(base, n) for n in names if n.endswith((".cpp", ".h", ".hpp"))]
for path in files:
with open(path, encoding="utf-8", errors="replace") as fh:
idents.update(norm(t) for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", fh.read()))
return idents
def python_symbols(tree):
out = []
for node in tree.body:
if isinstance(node, ast.ClassDef):
out.append(("class", node.name, node.lineno))
for sub in node.body:
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.append(("method", "%s.%s" % (node.name, sub.name), sub.lineno))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.append(("function", node.name, node.lineno))
return out
def tool_names(tree):
names = []
for node in ast.walk(tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "tool_schema" and node.args:
arg = node.args[0]
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
names.append((arg.value, node.lineno))
return names
def flag_names(tree):
flags = []
for node in ast.walk(tree):
if isinstance(node, ast.Call) and getattr(node.func, "attr", "") == "add_argument":
for arg in node.args:
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.startswith("--"):
flags.append((arg.value, node.lineno))
return flags
def build():
with open(PY, encoding="utf-8") as fh:
tree = ast.parse(fh.read())
idents = cpp_idents()
rows = []
for kind, name, line in python_symbols(tree):
leaf = name.split(".")[-1]
hit = norm(name.replace(".", "")) in idents or (kind != "class" and len(norm(leaf)) > 5 and norm(leaf) in idents)
rows.append({"kind": kind, "name": name, "line": line, "status": "name-only" if hit else "missing"})
for name, line in tool_names(tree):
rows.append({"kind": "tool", "name": name, "line": line, "status": "name-only" if norm(name) in idents else "missing"})
for name, line in flag_names(tree):
rows.append({"kind": "flag", "name": name, "line": line, "status": "name-only" if norm(name) in idents else "missing"})
return rows
def main():
rows = build()
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ledger.json"), "w") as fh:
json.dump(rows, fh, indent=1)
kinds = sorted({r["kind"] for r in rows})
lines = ["# Parity ledger (generated, do not edit)", "",
"Source: tai.py. `name-only` means an identifier matches, NOT that behavior is verified.", "",
"| kind | total | name-only | missing |", "|---|---|---|---|"]
for kind in kinds:
sub = [r for r in rows if r["kind"] == kind]
have = sum(1 for r in sub if r["status"] == "name-only")
lines.append("| %s | %d | %d | %d |" % (kind, len(sub), have, len(sub) - have))
total_missing = sum(1 for r in rows if r["status"] == "missing")
lines += ["", "Total symbols: %d, missing: %d, verified: 0" % (len(rows), total_missing)]
with open(os.path.join(OUT, "ledger.md"), "w") as fh:
fh.write("\n".join(lines) + "\n")
print("\n".join(lines))
return 1 if "--check" in sys.argv and total_missing else 0
if __name__ == "__main__":
sys.exit(main())