portico-cli 0.1.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.
portico/.DS_Store ADDED
Binary file
portico/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
portico/analyzer.py ADDED
@@ -0,0 +1,213 @@
1
+ import json
2
+ import re
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import ValidationError
6
+
7
+ from portico.providers.base import LLMProvider
8
+ from portico.providers.claude import DEFAULT_MODEL
9
+ from portico.schema import PorticoJSON
10
+
11
+ PROMPT_TEMPLATE = """\
12
+ You are portico. You decompose any input into a three-layer "portico": a roof \
13
+ (the unifying idea), pillars (the load-bearing components), and a base (the foundation \
14
+ everything rests on).
15
+
16
+ Read the input below and emit STRICTLY VALID JSON matching the schema.
17
+
18
+ The JSON is a SINGLE FLAT OBJECT with these top-level keys, in this exact order. None \
19
+ of the keys nest under category headers; do not introduce wrapper objects like \
20
+ "reasoning" or "output".
21
+
22
+ Top-level keys:
23
+
24
+ 1. "input_type": string. What kind of artifact this is (essay, codebase, business plan, ...).
25
+ 2. "type_rationale": string. One sentence on why you classified it that way.
26
+ 3. "decomposition_strategy": string. One sentence on how you'll split it into roof / pillars / base.
27
+ 4. "scratch_outline": array of 3-7 short strings capturing the load-bearing parts before you label.
28
+ 5. "mece_check": string. One sentence: are the pillars mutually exclusive and collectively \
29
+ exhaustive at the same level of abstraction?
30
+ 6. "theme": string. A short free-form label for the input type ("essay", "codebase", ...).
31
+ 7. "title": string. The input's title or a 1-3 word identifier.
32
+ 8. "roof": object {"label": string, "summary": string}. The unifying idea on top.
33
+ 9. "pillars": array of 2-9 objects, each {"label": string, "summary": string}. STRONGLY PREFER 3-5.
34
+ 10. "base": object {"labels": array of 1-4 strings, "summary": string}. The foundation that \
35
+ everything rests on. Use 1 label for a single foundation; add a 2nd/3rd/4th ONLY when each \
36
+ names a distinct foundational course that earns its place. Redundancy is noise.
37
+ 11. "fit_quality": one of "good", "stretched", "forced", "not_applicable".
38
+ 12. "notes_on_fit": string. If not "good", explain why in one sentence.
39
+
40
+ Concrete example of the exact shape (illustrative content; do not copy the values):
41
+
42
+ {
43
+ "input_type": "essay",
44
+ "type_rationale": "Argumentative prose with thesis and supporting reasoning.",
45
+ "decomposition_strategy": "Thesis on top; two arguments as pillars; evidence at base.",
46
+ "scratch_outline": ["Sub-linear trust ...", "Feedback loops ...", "Dunbar ...", "Evidence ..."],
47
+ "mece_check": "Two pillars are non-overlapping causal mechanisms.",
48
+ "theme": "essay",
49
+ "title": "trust at scale",
50
+ "roof": {"label": "Sub-linear Trust", "summary": "Trust scales sub-linearly with size."},
51
+ "pillars": [
52
+ {"label": "Feedback loops", "summary": "Smaller groups close feedback faster."},
53
+ {"label": "Dunbar limit", "summary": "Diffusion of responsibility past ~150."}
54
+ ],
55
+ "base": {"labels": ["Empirical evidence"], "summary": "Observed work on team size and trust."},
56
+ "fit_quality": "good",
57
+ "notes_on_fit": "Essay decomposes cleanly into thesis / arguments / evidence."
58
+ }
59
+
60
+ Notice the roof uses "Sub-linear Trust" (the actual claim from the input), NOT a generic \
61
+ "Thesis". This is the most important rule below.
62
+
63
+ Rules:
64
+
65
+ LABELS (calibrate between two failure modes):
66
+
67
+ Failure mode A -- TOO GENERIC: labels that could apply to any input of the same type \
68
+ ("Hypothesis", "Methodology", "Findings", "Seed Thesis", "Central Claim"). These add no \
69
+ value because they describe the slot, not the contents.
70
+
71
+ Failure mode B -- TOO CRYPTIC: labels so specific they need the input to decode \
72
+ ("5→15→30→60", "Gini-NIAH Tie", "Re-found Each Doubling"). These add no value because the \
73
+ reader has to consult the source to make sense of them.
74
+
75
+ The sweet spot: short noun phrases that are *recognizable from the input* but *readable on \
76
+ their own*. Bias slightly toward the abstract side -- when in doubt between "too specific \
77
+ to read at a glance" and "a bit generic but clear", choose clear.
78
+
79
+ Concrete rules:
80
+ - USE THE INPUT'S OWN VOCABULARY: when the input names a concept ("Dunbar limit", \
81
+ "tragedy of the commons", "loaders"), reuse those terms. Don't invent synonyms.
82
+ - ROOF MUST NOT EQUAL THE TITLE: the banner above the portico already names the input \
83
+ (e.g. "── software README: httpx ──"). The roof must do different work -- name the \
84
+ central claim, the unifying idea, what the input is *about* one rung up from what it \
85
+ *is*. Title `httpx` + roof `Modern HTTP Client` is correct; title `httpx` + roof \
86
+ `httpx` is wrong. EXCEPTION: codebase inputs where the repo/module name is the \
87
+ natural roof and no higher abstraction is available.
88
+ - READABLE AT A GLANCE: every label should make sense to a reader who has not seen the \
89
+ input. If a pillar label is a numerical range, an abbreviation, a coined phrase, or \
90
+ requires recall of a specific passage to decode -- abstract one rung up.
91
+ - NO INVENTED ADJECTIVES: do not modify a noun with an adjective unless that adjective \
92
+ appears in the input or is directly entailed. "Resilient", "Modern", "Robust", \
93
+ "Comprehensive", "Strategic" are red flags -- if the input does not use them, drop them.
94
+ - NO HALLUCINATED CONCEPTS: every label and every summary clause must trace back to \
95
+ language or ideas in the input. Do not extend the author's metaphors or coin new ones.
96
+ - Plain language: when the input uses everyday words, prefer everyday words in the labels. \
97
+ Reach for jargon only if the input itself does.
98
+ - Length: target <= 16 characters per label. Concise noun phrases. Summaries are one sentence.
99
+
100
+ PORTICO:
101
+ - MECE: pillars must NOT overlap; together they must cover the load-bearing parts.
102
+ - Same abstraction level: roof, each pillar, and base operate at one consistent level.
103
+ - Load-bearing test: if you remove a pillar, the input's central purpose collapses.
104
+ - Pillars are not steps: if the input has temporal/sequential structure (recipes, \
105
+ walkthroughs, ordered instructions), the portico is the wrong metaphor -- set \
106
+ fit_quality to "stretched" or worse rather than forcing steps into pillars.
107
+ - Pillar-base separation: pillars and base must not overlap. A core ingredient of the input \
108
+ belongs in the base, not also as a pillar.
109
+ - Pillar count: 2-9 allowed; STRONGLY PREFER 3-5 (Minto's Rule of 3, working memory).
110
+
111
+ FIT (push back when the metaphor does not earn its keep):
112
+ - "good" -- the metaphor lands cleanly.
113
+ - "stretched" -- the metaphor is forced but still informative.
114
+ - "forced" -- you had to invent structure that is not really there.
115
+ - "not_applicable" -- nothing to decompose. Use for: gibberish, random words, flat lists \
116
+ with no organizing principle, single sentences with no internal structure, very short \
117
+ conventional inputs (greetings, idioms), or any content that lacks the kind of \
118
+ structural decomposition the portico models.
119
+ - POETRY ALWAYS REFUSES: lyric and narrative poems do not admit portico decomposition. \
120
+ Poems work through image, rhythm, and meaning-by-accumulation -- they have no \
121
+ load-bearing pillars in the architectural sense. Set fit_quality to "not_applicable" \
122
+ for any poem and explain briefly in notes_on_fit.
123
+ - FLAT LISTS REFUSE: simple lists (shopping lists, word lists, enumerations) lack the \
124
+ structural decomposition the portico models. Set fit_quality to "not_applicable" rather \
125
+ than inventing categorical pillars.
126
+ - When torn between "forced" and "not_applicable", choose "not_applicable" and explain \
127
+ briefly in notes_on_fit. Refusing is a feature; the portico is not for everything.
128
+
129
+ Emit ONLY the JSON object. No markdown fences. No commentary. No preamble.
130
+
131
+ Input to analyze:
132
+
133
+ {input}
134
+ """
135
+
136
+ RETRY_FEEDBACK_HEADER = """\
137
+ Your previous response could not be parsed: {error}
138
+
139
+ Re-emit the JSON object correctly. Output ONLY the JSON object, no fences, no prose.
140
+ Schema and input below remain the same.
141
+
142
+ """
143
+
144
+ _FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```\s*$", re.DOTALL)
145
+
146
+
147
+ class AnalyzerError(Exception):
148
+ """Base for analyzer failures."""
149
+
150
+
151
+ class F4MalformedJSON(AnalyzerError):
152
+ """LLM produced unparseable / schema-invalid JSON after the retry budget (F4)."""
153
+
154
+
155
+ @dataclass
156
+ class AnalyzeResult:
157
+ data: PorticoJSON
158
+ raw_response: str
159
+ attempts: int
160
+
161
+
162
+ def build_prompt(text: str) -> str:
163
+ return PROMPT_TEMPLATE.replace("{input}", text)
164
+
165
+
166
+ def build_retry_prompt(base_prompt: str, error: str) -> str:
167
+ header = RETRY_FEEDBACK_HEADER.format(error=error)
168
+ return header + base_prompt
169
+
170
+
171
+ def _strip_fence(s: str) -> str:
172
+ s = s.strip()
173
+ m = _FENCE_RE.match(s)
174
+ if m:
175
+ return m.group(1).strip()
176
+ return s
177
+
178
+
179
+ def _parse_and_validate(raw: str) -> PorticoJSON:
180
+ cleaned = _strip_fence(raw)
181
+ data = json.loads(cleaned)
182
+ return PorticoJSON.model_validate(data)
183
+
184
+
185
+ def analyze(
186
+ text: str,
187
+ *,
188
+ provider: LLMProvider,
189
+ model: str = DEFAULT_MODEL,
190
+ max_retries: int = 2,
191
+ ) -> AnalyzeResult:
192
+ """Send `text` to the LLM, parse + validate JSON, retry on malformed output.
193
+
194
+ Raises F4MalformedJSON after `max_retries` retries (so up to 1 + max_retries calls).
195
+ Provider-side errors (auth, transport) propagate unchanged from the provider.
196
+ """
197
+ base_prompt = build_prompt(text)
198
+ prompt = base_prompt
199
+ last_error: Exception | None = None
200
+ last_raw = ""
201
+
202
+ for attempt in range(max_retries + 1):
203
+ last_raw = provider.generate(prompt, model=model)
204
+ try:
205
+ data = _parse_and_validate(last_raw)
206
+ return AnalyzeResult(data=data, raw_response=last_raw, attempts=attempt + 1)
207
+ except (json.JSONDecodeError, ValidationError) as e:
208
+ last_error = e
209
+ prompt = build_retry_prompt(base_prompt, str(e))
210
+
211
+ raise F4MalformedJSON(
212
+ f"LLM returned unusable output after {max_retries + 1} attempts: {last_error}"
213
+ )
portico/cache.py ADDED
@@ -0,0 +1,46 @@
1
+ """Content-hashed cache for analyzer outputs.
2
+
3
+ The cache is keyed on (text, provider, model) -- the same input through the
4
+ same model returns the same JSON. Per the failure taxonomy, F3 refusals are
5
+ cached (the input genuinely doesn't fit); F1/F2/F4 failures are not (re-run
6
+ may succeed). Caching policy is enforced by the caller, not this module.
7
+ """
8
+
9
+ import hashlib
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from portico.schema import PorticoJSON
14
+
15
+ DEFAULT_CACHE_DIR = Path.home() / ".cache" / "portico"
16
+
17
+
18
+ def cache_key(text: str, *, provider: str, model: str) -> str:
19
+ payload = f"{provider}|{model}|{text}".encode()
20
+ return hashlib.sha256(payload).hexdigest()
21
+
22
+
23
+ @dataclass
24
+ class Cache:
25
+ root: Path = DEFAULT_CACHE_DIR
26
+
27
+ def _path(self, key: str) -> Path:
28
+ return self.root / f"{key}.json"
29
+
30
+ def get(self, key: str) -> PorticoJSON | None:
31
+ path = self._path(key)
32
+ if not path.exists():
33
+ return None
34
+ try:
35
+ return PorticoJSON.model_validate_json(path.read_text())
36
+ except Exception:
37
+ # Corrupt cache entry: drop it and miss.
38
+ path.unlink(missing_ok=True)
39
+ return None
40
+
41
+ def put(self, key: str, data: PorticoJSON) -> None:
42
+ self.root.mkdir(parents=True, exist_ok=True)
43
+ self._path(key).write_text(data.model_dump_json(indent=2))
44
+
45
+ def invalidate(self, key: str) -> None:
46
+ self._path(key).unlink(missing_ok=True)
portico/cli.py ADDED
@@ -0,0 +1,346 @@
1
+ """portico CLI -- single entry point that wires the pipeline together.
2
+
3
+ Loader -> Summarizer (if oversized) -> Cache check -> Analyzer -> Renderer.
4
+ Failure classes route to exit codes per the spec failure taxonomy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import shutil
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from portico import __version__
17
+ from portico.analyzer import F4MalformedJSON, analyze
18
+ from portico.cache import Cache, cache_key
19
+ from portico.config import (
20
+ get_anthropic_api_key,
21
+ get_default_model,
22
+ get_default_provider,
23
+ )
24
+ from portico.loaders.base import (
25
+ F1NetworkUnavailable,
26
+ F1NotFound,
27
+ F1RemoteInaccessible,
28
+ F2NotParseable,
29
+ F2TooLarge,
30
+ LoadedInput,
31
+ )
32
+ from portico.loaders.dir import load_dir
33
+ from portico.loaders.file import load_file
34
+ from portico.loaders.repo import load_repo
35
+ from portico.loaders.text import load_stdin, load_text
36
+ from portico.loaders.url import load_url
37
+ from portico.providers.base import (
38
+ LLMProvider,
39
+ ProviderAuthError,
40
+ ProviderTransportError,
41
+ )
42
+ from portico.providers.claude import DEFAULT_MODEL, ClaudeProvider
43
+ from portico.providers.gemini import GeminiProvider
44
+ from portico.providers.openai import OpenAIProvider
45
+ from portico.render import MAX_WIDTH, render
46
+ from portico.render.apex import generate_apex
47
+ from portico.render.color import ColorMode
48
+ from portico.schema import FitQuality, PorticoJSON
49
+ from portico.summarize import summarize
50
+
51
+ REFUSAL_TEMPLATE = """\
52
+ portico could not build a portico for this input.
53
+
54
+ reason: {reason}
55
+
56
+ input type detected: {theme}
57
+
58
+ what you can try:
59
+ • run with --force to render anyway
60
+ • narrow the input and try again
61
+ • verify the input is what you intended
62
+ """
63
+
64
+
65
+ # --- Exit codes ---
66
+ EXIT_OK = 0
67
+ EXIT_F1_NOT_FOUND = 2
68
+ EXIT_F1_REMOTE = 3
69
+ EXIT_F1_NETWORK = 4
70
+ EXIT_F2_NOT_PARSEABLE = 5
71
+ EXIT_F2_TOO_LARGE = 6
72
+ EXIT_F4_MALFORMED = 7
73
+ EXIT_F4_TRANSPORT = 8
74
+ EXIT_F4_AUTH = 9
75
+
76
+
77
+ @dataclass
78
+ class Args:
79
+ input_value: str | None
80
+ input_type: str | None
81
+ style: str
82
+ color: ColorMode
83
+ verbose: bool
84
+ width: int
85
+ json_out: bool
86
+ provider: str
87
+ model: str
88
+ no_cache: bool
89
+ diagnose: bool
90
+ force: bool
91
+ strict: bool
92
+ reapex: bool
93
+ reapex_seed: int | None
94
+
95
+
96
+ def make_provider(name: str) -> LLMProvider:
97
+ if name == "claude":
98
+ return ClaudeProvider(api_key=get_anthropic_api_key())
99
+ if name == "openai":
100
+ return OpenAIProvider()
101
+ if name == "gemini":
102
+ return GeminiProvider()
103
+ raise ValueError(f"unknown provider: {name}")
104
+
105
+
106
+ # Bare filenames like `essay.md` look like file paths even without a slash.
107
+ # Listing common extensions stops the heuristic from misreading "Trust scales..."
108
+ # (period + word) as a file path.
109
+ _FILE_EXTENSIONS = frozenset(
110
+ {
111
+ ".txt", ".md", ".rst", ".log", ".csv", ".tsv", ".json", ".yaml", ".yml",
112
+ ".toml", ".ini", ".conf", ".xml", ".html", ".htm", ".css", ".js", ".ts",
113
+ ".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java", ".c", ".h", ".cpp",
114
+ ".hpp", ".cs", ".php", ".lua", ".sh", ".bash", ".zsh", ".sql", ".lock",
115
+ }
116
+ )
117
+
118
+
119
+ def _looks_like_path(value: str) -> bool:
120
+ if "/" in value or "\\" in value:
121
+ return True
122
+ if " " in value or "\n" in value:
123
+ return False
124
+ suffix = Path(value).suffix.lower()
125
+ return suffix in _FILE_EXTENSIONS
126
+
127
+
128
+ def detect_input_type(value: str) -> str:
129
+ if value.startswith(("http://", "https://")):
130
+ return "url"
131
+ p = Path(value)
132
+ if p.exists():
133
+ return "dir" if p.is_dir() else "file"
134
+ if _looks_like_path(value):
135
+ # Path-shaped but missing -- let the file loader surface the F1 instead
136
+ # of silently treating the string as raw text.
137
+ return "file"
138
+ return "text"
139
+
140
+
141
+ def load(value: str | None, *, input_type: str | None) -> LoadedInput:
142
+ if value is None:
143
+ if sys.stdin.isatty():
144
+ raise F2NotParseable(
145
+ "no input provided. Pass a path, URL, raw text, or pipe via stdin "
146
+ "(e.g. `echo 'hello' | portico -`). See `portico --help`."
147
+ )
148
+ return load_stdin()
149
+ if value == "-":
150
+ return load_stdin()
151
+ t = input_type or detect_input_type(value)
152
+ if t == "url":
153
+ return load_url(value)
154
+ if t == "file":
155
+ return load_file(value)
156
+ if t == "dir":
157
+ return load_dir(value)
158
+ if t == "repo":
159
+ return load_repo(value)
160
+ return load_text(value)
161
+
162
+
163
+ def resolve_width(arg_width: int | None) -> int:
164
+ if arg_width is not None:
165
+ return arg_width
166
+ return min(shutil.get_terminal_size((MAX_WIDTH, 24)).columns, MAX_WIDTH)
167
+
168
+
169
+ def render_refusal(data: PorticoJSON) -> str:
170
+ return REFUSAL_TEMPLATE.format(reason=data.notes_on_fit, theme=data.theme)
171
+
172
+
173
+ def render_diagnostics(
174
+ loaded: LoadedInput, data: PorticoJSON, *, model: str, provider_name: str
175
+ ) -> str:
176
+ return (
177
+ f"input: {loaded.source}\n"
178
+ f"type: {loaded.input_type}\n"
179
+ f"chars: {loaded.metadata.get('chars', len(loaded.text))}\n"
180
+ f"summarized: {loaded.metadata.get('summarized', False)}\n"
181
+ f"provider: {provider_name}\n"
182
+ f"model: {model}\n"
183
+ f"fit_quality: {data.fit_quality.value}\n"
184
+ f"notes_on_fit: {data.notes_on_fit}\n"
185
+ )
186
+
187
+
188
+ def run(args: Args, *, provider: LLMProvider | None = None) -> int:
189
+ """The pipeline. `provider` injection is for tests."""
190
+ try:
191
+ loaded = load(args.input_value, input_type=args.input_type)
192
+ except F1NotFound as e:
193
+ print(f"portico: {e}", file=sys.stderr)
194
+ return EXIT_F1_NOT_FOUND
195
+ except F1RemoteInaccessible as e:
196
+ print(f"portico: {e}", file=sys.stderr)
197
+ return EXIT_F1_REMOTE
198
+ except F1NetworkUnavailable as e:
199
+ print(f"portico: {e}", file=sys.stderr)
200
+ return EXIT_F1_NETWORK
201
+ except F2NotParseable as e:
202
+ print(f"portico: {e}", file=sys.stderr)
203
+ return EXIT_F2_NOT_PARSEABLE
204
+
205
+ prov = provider or make_provider(args.provider)
206
+
207
+ try:
208
+ loaded = summarize(loaded, provider=prov, model=args.model)
209
+ except F2TooLarge as e:
210
+ print(f"portico: {e}", file=sys.stderr)
211
+ return EXIT_F2_TOO_LARGE
212
+ except ProviderAuthError as e:
213
+ print(f"portico: {e}", file=sys.stderr)
214
+ return EXIT_F4_AUTH
215
+ except ProviderTransportError as e:
216
+ print(f"portico: {e}", file=sys.stderr)
217
+ return EXIT_F4_TRANSPORT
218
+
219
+ cache = Cache()
220
+ key = cache_key(loaded.text, provider=args.provider, model=args.model)
221
+ data: PorticoJSON | None = None
222
+ if not args.no_cache:
223
+ data = cache.get(key)
224
+
225
+ if data is None:
226
+ try:
227
+ result = analyze(loaded.text, provider=prov, model=args.model)
228
+ except F4MalformedJSON as e:
229
+ print(f"portico: {e}", file=sys.stderr)
230
+ return EXIT_F4_MALFORMED
231
+ except ProviderAuthError as e:
232
+ print(f"portico: {e}", file=sys.stderr)
233
+ return EXIT_F4_AUTH
234
+ except ProviderTransportError as e:
235
+ print(f"portico: {e}", file=sys.stderr)
236
+ return EXIT_F4_TRANSPORT
237
+ data = result.data
238
+ if not args.no_cache:
239
+ cache.put(key, data)
240
+
241
+ if args.diagnose:
242
+ print(render_diagnostics(loaded, data, model=args.model, provider_name=args.provider))
243
+ return EXIT_OK
244
+
245
+ if args.json_out:
246
+ print(json.dumps(data.model_dump(mode="json"), indent=2))
247
+ return EXIT_OK
248
+
249
+ # F3 routing -- abstain when fit_quality demands it.
250
+ fq = data.fit_quality
251
+ if fq == FitQuality.NOT_APPLICABLE:
252
+ print(render_refusal(data))
253
+ return EXIT_OK
254
+ if fq == FitQuality.FORCED and not args.force:
255
+ print(render_refusal(data))
256
+ return EXIT_OK
257
+ if fq == FitQuality.STRETCHED and args.strict:
258
+ print(render_refusal(data))
259
+ return EXIT_OK
260
+
261
+ apex_override: tuple[str, str] | None = None
262
+ apex_seed_label: str | None = None
263
+ if args.reapex:
264
+ finial, keystone, used_seed = generate_apex(args.reapex_seed)
265
+ apex_override = (finial, keystone)
266
+ apex_seed_label = f"apex seed: {used_seed}"
267
+
268
+ print(
269
+ render(
270
+ data,
271
+ width=args.width,
272
+ color=args.color,
273
+ verbose=args.verbose,
274
+ apex_override=apex_override,
275
+ apex_seed_label=apex_seed_label,
276
+ ),
277
+ end="",
278
+ )
279
+ return EXIT_OK
280
+
281
+
282
+ def parse_args(argv: list[str] | None = None) -> Args:
283
+ parser = argparse.ArgumentParser(prog="portico", description="Render any input as a portico.")
284
+ parser.add_argument("input", nargs="?", help="Path, URL, raw text, or '-' for stdin.")
285
+ parser.add_argument("--type", dest="input_type", choices=["text", "file", "dir", "url", "repo"])
286
+ parser.add_argument("--style", default="default")
287
+ parser.add_argument(
288
+ "--color",
289
+ choices=[c.value for c in ColorMode],
290
+ default=ColorMode.NEVER.value,
291
+ )
292
+ parser.add_argument("--verbose", "-v", action="store_true")
293
+ parser.add_argument("--width", type=int, default=None)
294
+ parser.add_argument("--json", dest="json_out", action="store_true")
295
+ parser.add_argument(
296
+ "--provider",
297
+ choices=["claude", "openai", "gemini"],
298
+ default=get_default_provider(),
299
+ )
300
+ parser.add_argument("--model", default=get_default_model() or DEFAULT_MODEL)
301
+ parser.add_argument("--no-cache", action="store_true")
302
+ parser.add_argument("--diagnose", action="store_true")
303
+ parser.add_argument("--force", action="store_true")
304
+ parser.add_argument("--strict", action="store_true")
305
+ parser.add_argument(
306
+ "--reapex",
307
+ nargs="?",
308
+ const="__random__",
309
+ default=None,
310
+ help="Roll a random symmetric apex ornament. Pass --reapex=SEED to pin one.",
311
+ )
312
+ parser.add_argument("--version", action="version", version=f"portico {__version__}")
313
+ parsed = parser.parse_args(argv)
314
+
315
+ reapex = parsed.reapex is not None
316
+ reapex_seed: int | None = None
317
+ if reapex and parsed.reapex != "__random__":
318
+ try:
319
+ reapex_seed = int(parsed.reapex)
320
+ except ValueError:
321
+ parser.error(
322
+ f"--reapex value must be an integer seed, got {parsed.reapex!r}"
323
+ )
324
+
325
+ return Args(
326
+ input_value=parsed.input,
327
+ input_type=parsed.input_type,
328
+ style=parsed.style,
329
+ color=ColorMode(parsed.color),
330
+ verbose=parsed.verbose,
331
+ width=resolve_width(parsed.width),
332
+ json_out=parsed.json_out,
333
+ provider=parsed.provider,
334
+ model=parsed.model,
335
+ no_cache=parsed.no_cache,
336
+ diagnose=parsed.diagnose,
337
+ force=parsed.force,
338
+ strict=parsed.strict,
339
+ reapex=reapex,
340
+ reapex_seed=reapex_seed,
341
+ )
342
+
343
+
344
+ def main() -> None:
345
+ args = parse_args()
346
+ sys.exit(run(args))
portico/config.py ADDED
@@ -0,0 +1,13 @@
1
+ import os
2
+
3
+
4
+ def get_anthropic_api_key() -> str | None:
5
+ return os.environ.get("ANTHROPIC_API_KEY")
6
+
7
+
8
+ def get_default_provider() -> str:
9
+ return os.environ.get("ARQII_PROVIDER", "claude")
10
+
11
+
12
+ def get_default_model() -> str | None:
13
+ return os.environ.get("ARQII_MODEL")
File without changes
@@ -0,0 +1,45 @@
1
+ """Loader contract and shared exception hierarchy.
2
+
3
+ Every loader returns a `LoadedInput`. Failure cases map to F1/F2 categories
4
+ per the spec failure taxonomy and surface as specific exception subclasses
5
+ the CLI can route to exit codes 2-6.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class LoadedInput:
14
+ """Output of a loader -- raw text plus diagnostic metadata."""
15
+
16
+ text: str
17
+ source: str
18
+ input_type: str # text | file | dir | url | repo
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ class LoaderError(Exception):
23
+ """Base for all loader-side failures."""
24
+
25
+
26
+ # F1: input access (no LLM call)
27
+ class F1NotFound(LoaderError):
28
+ """Local input does not exist or is unreadable. Exit 2."""
29
+
30
+
31
+ class F1RemoteInaccessible(LoaderError):
32
+ """Remote input returned 4xx/5xx or DNS failed. Exit 3."""
33
+
34
+
35
+ class F1NetworkUnavailable(LoaderError):
36
+ """Network required but unavailable. Exit 4."""
37
+
38
+
39
+ # F2: input parsing (no LLM call)
40
+ class F2NotParseable(LoaderError):
41
+ """Input cannot be parsed as text (binary blob, encrypted, etc.). Exit 5."""
42
+
43
+
44
+ class F2TooLarge(LoaderError):
45
+ """Input exceeds the hard size cap. Exit 6."""