eightstatecli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ """
2
+ CLI self-description system — generates machine-readable manifests
3
+ from argparse parsers for agent discovery.
4
+
5
+ Agents call `escli --describe` to get a compact JSON manifest of all
6
+ commands, or `escli <command> --schema` for per-command detail.
7
+
8
+ Token budget: --describe returns ~300-500 tokens (compact index).
9
+ Per-command --schema returns ~100-200 tokens (full detail for one command).
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ from typing import Any
15
+
16
+ from escli import __version__
17
+
18
+
19
+ def generate_manifest(parser: argparse.ArgumentParser) -> dict:
20
+ """Generate the compact CLI manifest for --describe."""
21
+ commands = []
22
+ # Walk the subparsers
23
+ for action in parser._subparsers._actions:
24
+ if isinstance(action, argparse._SubParsersAction):
25
+ # Build a help text lookup from choices_actions
26
+ help_map: dict[str, str] = {}
27
+ for choice_action in getattr(action, '_choices_actions', []):
28
+ help_map[choice_action.dest] = choice_action.help or ""
29
+
30
+ for name, subparser in action.choices.items():
31
+ if _is_alias(name, action):
32
+ continue
33
+ cmd = _describe_command(name, subparser, help_map.get(name, ""))
34
+ if cmd:
35
+ commands.append(cmd)
36
+
37
+ return {
38
+ "name": "escli",
39
+ "version": __version__,
40
+ "description": "Eightstate CLI — AI services from the command line",
41
+ "agent_instructions": {
42
+ "structured_output": "Use --json for all commands to get {ok, data, error, meta} envelope on stdout",
43
+ "quiet_mode": "Use --quiet to suppress stderr progress messages",
44
+ "auth": "Run 'escli auth login' for browser OAuth, or set service-specific env vars",
45
+ "exit_codes": {
46
+ "0": "success",
47
+ "1": "error",
48
+ "2": "usage/validation",
49
+ "4": "auth failure",
50
+ "5": "not found",
51
+ "8": "transient/retryable",
52
+ },
53
+ },
54
+ "commands": commands,
55
+ }
56
+
57
+
58
+ def generate_command_schema(name: str, parser: argparse.ArgumentParser) -> dict | None:
59
+ """Generate detailed schema for a single command (--schema flag)."""
60
+ # Find the subparser for this command
61
+ for action in parser._subparsers._actions:
62
+ if isinstance(action, argparse._SubParsersAction):
63
+ if name in action.choices:
64
+ subparser = action.choices[name]
65
+ return _describe_command_detail(name, subparser)
66
+ return None
67
+
68
+
69
+ def _is_alias(name: str, action: argparse._SubParsersAction) -> bool:
70
+ """Check if a subparser name is an alias (shares parser with another name)."""
71
+ parser = action.choices[name]
72
+ for other_name, other_parser in action.choices.items():
73
+ if other_parser is parser and other_name != name and len(other_name) > len(name):
74
+ return True # This name is shorter = alias
75
+ return False
76
+
77
+
78
+ def _describe_command(name: str, parser: argparse.ArgumentParser, help_text: str = "") -> dict | None:
79
+ """Generate compact command entry for the manifest."""
80
+ desc = help_text or _first_line(parser.description or "") or name
81
+
82
+ # Check for subcommands
83
+ subcommands = []
84
+ for action in parser._subparsers._actions if parser._subparsers else []:
85
+ if isinstance(action, argparse._SubParsersAction):
86
+ for sub_name, sub_parser in action.choices.items():
87
+ if not _is_alias(sub_name, action):
88
+ subcommands.append(sub_name)
89
+
90
+ entry: dict[str, Any] = {
91
+ "name": name,
92
+ "description": desc,
93
+ }
94
+ if subcommands:
95
+ entry["subcommands"] = subcommands
96
+ return entry
97
+
98
+
99
+ def _describe_command_detail(name: str, parser: argparse.ArgumentParser) -> dict:
100
+ """Generate full command schema with args, flags, examples."""
101
+ args = []
102
+ flags = []
103
+
104
+ for action in parser._actions:
105
+ if isinstance(action, (argparse._HelpAction, argparse._VersionAction)):
106
+ continue
107
+ if isinstance(action, argparse._SubParsersAction):
108
+ continue
109
+
110
+ if action.option_strings:
111
+ # It's a flag
112
+ flag: dict[str, Any] = {
113
+ "names": action.option_strings,
114
+ "type": _type_name(action),
115
+ "required": action.required,
116
+ }
117
+ if action.default is not None and action.default != argparse.SUPPRESS:
118
+ flag["default"] = str(action.default) if action.default is not argparse.SUPPRESS else None
119
+ if action.help and action.help != argparse.SUPPRESS:
120
+ flag["description"] = action.help
121
+ if action.choices:
122
+ flag["choices"] = list(action.choices)
123
+ flags.append(flag)
124
+ else:
125
+ # Positional argument
126
+ arg: dict[str, Any] = {
127
+ "name": action.dest,
128
+ "type": _type_name(action),
129
+ "required": action.nargs not in ('?', '*'),
130
+ }
131
+ if action.help and action.help != argparse.SUPPRESS:
132
+ arg["description"] = action.help
133
+ if action.nargs in ('+', '*'):
134
+ arg["multiple"] = True
135
+ args.append(arg)
136
+
137
+ result: dict[str, Any] = {
138
+ "command": name,
139
+ "description": _first_line(parser.description or ""),
140
+ }
141
+ if args:
142
+ result["arguments"] = args
143
+ if flags:
144
+ result["flags"] = flags
145
+
146
+ # Extract examples from epilog
147
+ if parser.epilog:
148
+ examples = _extract_examples(parser.epilog)
149
+ if examples:
150
+ result["examples"] = examples
151
+
152
+ return result
153
+
154
+
155
+ def _type_name(action: argparse.Action) -> str:
156
+ """Get a human/machine-readable type name for an action."""
157
+ if isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction)):
158
+ return "bool"
159
+ if action.type is int:
160
+ return "int"
161
+ if action.type is float:
162
+ return "float"
163
+ if action.choices:
164
+ return "enum"
165
+ if action.nargs in ('+', '*'):
166
+ return "string[]"
167
+ return "string"
168
+
169
+
170
+ def _first_line(text: str) -> str:
171
+ """Get the first non-empty line of text."""
172
+ for line in text.strip().split('\n'):
173
+ line = line.strip()
174
+ if line:
175
+ return line
176
+ return ""
177
+
178
+
179
+ def _extract_examples(epilog: str) -> list[str]:
180
+ """Extract example commands from epilog text."""
181
+ examples = []
182
+ for line in epilog.split('\n'):
183
+ line = line.strip()
184
+ if line.startswith('escli '):
185
+ examples.append(line)
186
+ return examples[:5] # Cap at 5 examples
@@ -0,0 +1,168 @@
1
+ """
2
+ Structured output envelope and error types for agent consumption.
3
+
4
+ Every --json response uses the standard envelope:
5
+ {"ok": true, "data": {...}, "error": null, "meta": {...}}
6
+ {"ok": false, "data": null, "error": {...}, "meta": {...}}
7
+
8
+ Error objects include machine-readable codes and remediation hints.
9
+ """
10
+
11
+ import json as jsonlib
12
+ import sys
13
+ import time
14
+ from typing import Any
15
+
16
+
17
+ class CliError(Exception):
18
+ """Structured CLI error with code, category, and remediation."""
19
+
20
+ def __init__(
21
+ self,
22
+ code: str,
23
+ message: str,
24
+ category: str = "user", # user, system, transient
25
+ exit_code: int = 1,
26
+ retryable: bool = False,
27
+ remediation: list[dict] | None = None,
28
+ details: dict | None = None,
29
+ ):
30
+ super().__init__(message)
31
+ self.code = code
32
+ self.category = category
33
+ self.exit_code = exit_code
34
+ self.retryable = retryable
35
+ self.remediation = remediation or []
36
+ self.details = details
37
+
38
+ def to_dict(self) -> dict:
39
+ d: dict = {
40
+ "code": self.code,
41
+ "category": self.category,
42
+ "message": str(self),
43
+ "retryable": self.retryable,
44
+ }
45
+ if self.remediation:
46
+ d["remediation"] = self.remediation
47
+ if self.details:
48
+ d["details"] = self.details
49
+ return d
50
+
51
+
52
+ # ── Common errors ────────────────────────────────────────────────
53
+
54
+ class AuthError(CliError):
55
+ def __init__(self, message: str = "not authenticated", **kwargs):
56
+ super().__init__(
57
+ code="cli.auth.required",
58
+ message=message,
59
+ category="user",
60
+ exit_code=4,
61
+ remediation=[{"action": "run", "command": "escli auth login"}],
62
+ **kwargs,
63
+ )
64
+
65
+
66
+ class NotFoundError(CliError):
67
+ def __init__(self, resource: str, **kwargs):
68
+ super().__init__(
69
+ code="cli.not_found",
70
+ message=f"not found: {resource}",
71
+ category="user",
72
+ exit_code=5,
73
+ **kwargs,
74
+ )
75
+
76
+
77
+ class ValidationError(CliError):
78
+ def __init__(self, message: str, **kwargs):
79
+ super().__init__(
80
+ code="cli.validation",
81
+ message=message,
82
+ category="user",
83
+ exit_code=2,
84
+ **kwargs,
85
+ )
86
+
87
+
88
+ class TransientError(CliError):
89
+ def __init__(self, message: str, retry_after: int | None = None, **kwargs):
90
+ remediation = [{"action": "retry"}]
91
+ if retry_after:
92
+ remediation[0]["backoff_seconds"] = retry_after
93
+ super().__init__(
94
+ code="cli.transient",
95
+ message=message,
96
+ category="transient",
97
+ exit_code=8,
98
+ retryable=True,
99
+ remediation=remediation,
100
+ **kwargs,
101
+ )
102
+
103
+
104
+ class ApiError(CliError):
105
+ def __init__(self, status: int, message: str, **kwargs):
106
+ retryable = status in (429, 500, 502, 503, 504)
107
+ category = "transient" if retryable else "system"
108
+ code = {
109
+ 401: "cli.auth.invalid",
110
+ 403: "cli.auth.forbidden",
111
+ 404: "cli.not_found",
112
+ 429: "cli.rate_limited",
113
+ }.get(status, f"cli.api.{status}")
114
+
115
+ remediation = []
116
+ if status == 401:
117
+ remediation = [{"action": "run", "command": "escli auth login"}]
118
+ elif status == 429:
119
+ remediation = [{"action": "retry", "backoff_seconds": 30}]
120
+ elif status >= 500:
121
+ remediation = [{"action": "retry", "backoff_seconds": 5}]
122
+
123
+ super().__init__(
124
+ code=code,
125
+ message=message,
126
+ category=category,
127
+ exit_code=8 if retryable else 1,
128
+ retryable=retryable,
129
+ remediation=remediation,
130
+ **kwargs,
131
+ )
132
+
133
+
134
+ # ── Envelope ─────────────────────────────────────────────────────
135
+
136
+ def success(data: Any = None, meta: dict | None = None) -> dict:
137
+ """Build a success envelope."""
138
+ return {"ok": True, "data": data, "error": None, "meta": meta or {}}
139
+
140
+
141
+ def failure(error: CliError | dict | str, meta: dict | None = None) -> dict:
142
+ """Build a failure envelope."""
143
+ if isinstance(error, CliError):
144
+ err = error.to_dict()
145
+ elif isinstance(error, dict):
146
+ err = error
147
+ else:
148
+ err = {"code": "cli.error", "category": "system", "message": str(error), "retryable": False}
149
+ return {"ok": False, "data": None, "error": err, "meta": meta or {}}
150
+
151
+
152
+ def emit_json(envelope: dict):
153
+ """Print a JSON envelope to stdout."""
154
+ print(jsonlib.dumps(envelope))
155
+
156
+
157
+ def emit_error(error: CliError, use_json: bool = False) -> int:
158
+ """Print an error and return the exit code."""
159
+ if use_json:
160
+ emit_json(failure(error))
161
+ else:
162
+ print(f" ✗ {error}", file=sys.stderr)
163
+ for r in error.remediation:
164
+ if r.get("command"):
165
+ print(f" → run: {r['command']}", file=sys.stderr)
166
+ elif r.get("hint"):
167
+ print(f" → {r['hint']}", file=sys.stderr)
168
+ return error.exit_code