sciqnt 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.
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: sciqnt
3
+ Version: 0.1.0
4
+ Summary: sciqnt — local-first, agent-native cross-asset portfolio tracker & data layer (CLI/TUI)
5
+ Author: sciqnt
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sciqnt/sciqnt
8
+ Project-URL: Source, https://github.com/sciqnt/sciqnt
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: sciqnt-schema<0.2,>=0.1
13
+ Requires-Dist: sciqnt-fmt<0.2,>=0.1
14
+ Requires-Dist: sciqnt-config<0.2,>=0.1
15
+ Requires-Dist: sciqnt-price-store<0.2,>=0.1
16
+ Requires-Dist: sciqnt-compute<0.2,>=0.1
17
+ Requires-Dist: sciqnt-performance<0.2,>=0.1
18
+ Requires-Dist: sciqnt-market-data<0.2,>=0.1
19
+ Requires-Dist: sciqnt-fx<0.2,>=0.1
20
+ Requires-Dist: sciqnt-secrets<0.2,>=0.1
21
+ Requires-Dist: sciqnt-analytics<0.2,>=0.1
22
+ Requires-Dist: sciqnt-aggregator<0.2,>=0.1
23
+ Requires-Dist: sciqnt-skills<0.2,>=0.1
24
+ Requires-Dist: sciqnt-agents<0.2,>=0.1
25
+ Requires-Dist: prompt-toolkit
26
+ Requires-Dist: questionary
27
+ Dynamic: license-file
28
+
29
+ # sciqnt
30
+
31
+ **A local-first, agent-native portfolio tracker & cross-asset financial data
32
+ layer.** One canonical, point-in-time-correct schema for your positions,
33
+ transactions, cash and prices — fed by community connectors for each broker
34
+ / exchange / data source, rendered in a fast TUI, and consumable by any AI
35
+ agent (Claude Code, Codex, …) through plain CLI + versioned JSON surfaces.
36
+
37
+ Deterministic code computes the numbers. Agents reason and explain. You own
38
+ every byte: your credentials live in your OS keyring, your data on your
39
+ disk — there is no server.
40
+
41
+ ```
42
+ net worth (EUR) │ daily
43
+
44
+ 24,570 ┤ ⢀⡤⠖⠋⠹
45
+ │ ⣀⡤⣄⣤⣀⣀⣀⣀⣀ ⡤⠤⠤⠤⣄ ⣀⣀⣀⣀⡤⠞
46
+ 22,019 ┤ ⢀⣠⢤⣰⠒⠃ ⠉ ⠈⠉⠛⠁ ⠈⠉⠁⠉⠈⠁
47
+ │ ⢀⣰⠒⠚⠉⠉⠓⠒⠋
48
+ │⣀ ⣀⢀⣀⣠⠤⠖⠋⠛⠉⠉
49
+ 19,468 ┤⠈⠙⠒⢦⣀⡤⠤⣤⣄⣠⠴⠋⠉
50
+ ╰────────────────────────────────────────────────────────────
51
+ 2026-01-02 2026-06-12
52
+
53
+ total value 24,121.05 EUR TWR 13.70 %/yr
54
+ total P/L (lifetime) +3,340.95 EUR XIRR 9.79 %/yr
55
+ dividends (lifetime) +395.10 EUR max DD −14.5 %
56
+ ```
57
+
58
+ *That's the built-in demo portfolio — synthetic and deterministic. sciqnt
59
+ runs in demo mode until you connect an account; no real finances appear in
60
+ any screenshot or doc, ever.*
61
+
62
+ ## Install
63
+
64
+ ```sh
65
+ git clone https://github.com/sciqnt/sciqnt && cd sciqnt
66
+ python3 -m venv .venv && .venv/bin/pip install pydantic prompt-toolkit keyring
67
+ ./bin/sciqnt install # adds `sciqnt` to your PATH
68
+ sciqnt # the interactive home (demo portfolio until you connect)
69
+ ```
70
+
71
+ PyPI packages (`uv tool install sciqnt`, `pip install sciqnt-degiro`, …) are
72
+ coming with the first tagged release. macOS note: use a Python built
73
+ against modern OpenSSL (e.g. Homebrew `python@3.13`) — the system Python's
74
+ LibreSSL is fragile against financial-API TLS.
75
+
76
+ ## What you get
77
+
78
+ - **The TUI**: portfolio home with net-worth chart (1D…All ranges,
79
+ 5-minute intraday), positions / exposure / income / news / flows /
80
+ history tabs, account drill-downs — everything keyboard-driven.
81
+ - **Honest money math**: `Decimal` end-to-end, FIFO/LIFO/AVG lots,
82
+ fees-inclusive cost basis, TWR (GIPS-style breaks), XIRR, max drawdown,
83
+ benchmark comparison — computed from your raw transaction history,
84
+ point-in-time-correct (`sciqnt --asof 2024-12-31`).
85
+ - **Connectors as self-contained bundles** (`modules/sq-*`): manifest +
86
+ agent-facing SKILL + a living quirks log (FINDINGS) + conformance tests.
87
+ Degiro (CSV + live), Robinhood, Kalshi, Polymarket, Yahoo, Tiingo, ECB
88
+ FX, SEC EDGAR, FIRDS, OpenFIGI, RSS news — and a scaffold + harness for
89
+ building your own.
90
+ - **Agent-native, both directions**: every view is reproducible from the
91
+ CLI (`sciqnt --help` maps it; `--json` gives versioned, Decimal-as-string
92
+ data — `sciqnt.portfolio/v1`, `sciqnt.history/v1`, …). Summon your coding
93
+ agent from any screen and it receives where you are, what's on your
94
+ screen, and the command that reproduces it; agents can leave findings on
95
+ your home screen (`sciqnt insight add`).
96
+ - **A point-in-time price archive**: append-only, bitemporal, yours.
97
+
98
+ ## For agents
99
+
100
+ Run `sciqnt --help`. Every screen of the app has a CLI form; add `--json`
101
+ for structured data. Skills ship in-repo (`sq-portfolio`, `sq-connectors`)
102
+ and install into Claude Code / Codex automatically when summoned from the
103
+ app. **[`AGENTS.md`](AGENT_GUIDE.md) is the codebase map — start there if
104
+ you're an agent.**
105
+
106
+ ## Build a connector for your broker
107
+
108
+ The platform ships the contract + conformance harness + a scaffold — the
109
+ long tail of connectors belongs to the community (and to your coding
110
+ agent). See [CONTRIBUTING.md](CONTRIBUTING.md); the short version:
111
+
112
+ ```
113
+ "build a sciqnt connector for <my broker>" # tell your coding agent
114
+ ```
115
+
116
+ Independent connector repos install with `sciqnt modules add owner/repo`
117
+ (conformance runs locally before first use — trust is earned by the
118
+ harness, not claimed).
119
+
120
+ ## Going deeper
121
+
122
+ - [`FOUNDATION.md`](FOUNDATION.md) — the worldview + the 13 Founding Articles.
123
+ - [`PRINCIPLES.md`](PRINCIPLES.md) — the 18 operating principles.
124
+ - [`research/`](research/) — the grounded reasoning behind every decision.
125
+
126
+ Principles, the short list: local-first and sovereign — fire us and keep
127
+ everything. Deterministic core, probabilistic edge — LLMs never touch the
128
+ money math. Data first — rendered text is for humans, versioned JSON is the
129
+ contract. Read wide, execute gated. Synthetic fixtures only.
130
+
131
+ ## License
132
+
133
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ sciqnt_cli.py,sha256=NPRwf1ZXn7OY8KXShJ_ZBTGGNl0RzzvN5TUGFuR8nQo,4801
2
+ sciqnt-0.1.0.dist-info/licenses/LICENSE,sha256=rozsqhc3L9xo499FRoHjuzJHojz77OlAQzl_T7VKJPE,1069
3
+ sq_config_ui/__init__.py,sha256=kvd_d3J-UjqSTq3vL8BG-c1tTPsilDexfvG3GY0P1Ls,10515
4
+ sq_platform/__init__.py,sha256=e_PtFqX1K0kytqwaPNHwGJuDo9uN_ucWzTGbW3zwj_4,19188
5
+ sq_platform/_cache.py,sha256=6HLXzkYE-pgBzvnW7TwTt21xSu3Plb0_6kZsA8jWBvU,9041
6
+ sq_platform/aggregated.py,sha256=4xEk-Nfi-NHu04gXOEAYvLVaHL1HmFOL2L5QtR7Cf-c,130608
7
+ sq_platform/home.py,sha256=_x8923kIPyTIXtttQfJrZo9gNquX1fqBmN6Cen40ANM,55851
8
+ sq_platform/insights.py,sha256=stu65YZWhUCUw7Miy-eRECymYOtCCrNkgcvynqRZqe4,4798
9
+ sq_platform/modules_cmd.py,sha256=8XvFXoyDA2inpXsJzp46tpwWt90rJ4a5QyM5slfWXvY,11499
10
+ sq_tui/__init__.py,sha256=rNi1qmz-_55MjzAuZiaYTmwKpPl7xPFo-VB1uA_y7Bs,51015
11
+ sciqnt-0.1.0.dist-info/METADATA,sha256=xDvX2g-7yCGPca0zC_03gGjouCruOfIWeLzLNKgLIU8,5968
12
+ sciqnt-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ sciqnt-0.1.0.dist-info/entry_points.txt,sha256=uwhH-DEVGXRVCvSb-XlU3MZ_sjny7WzD2EQeSp_qpqY,43
14
+ sciqnt-0.1.0.dist-info/top_level.txt,sha256=ghRsqQpgT-FsNH8n2XxZanwpZVvZs7f_kvI4ft6hHeE,43
15
+ sciqnt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sciqnt = sciqnt_cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DavideGCosta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ sciqnt_cli
2
+ sq_config_ui
3
+ sq_platform
4
+ sq_tui
sciqnt_cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """sciqnt — the installed CLI front door.
2
+
3
+ The pip-installable entry point for the component-world structure: `pip install
4
+ sciqnt` brings the app (sq_platform + sq_tui + config-UI) plus its library deps
5
+ (pinned to the `sciqnt/sq-*` repos), and `sciqnt` launches it.
6
+
7
+ Unlike the in-repo `bin/sciqnt` launcher (which assumed the monorepo layout), this
8
+ entry is layout-free: the app's library deps are installed packages, and connectors
9
+ are discovered from the **user dir** (`~/.local/share/sciqnt/modules/`, populated by
10
+ `sciqnt modules add owner/repo`) — sovereign, no repo `modules/` needed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+ from datetime import datetime, time, timezone
18
+ from pathlib import Path
19
+
20
+
21
+ def _root() -> Path:
22
+ """The app's data root. For an installed app there's no repo `modules/`, so the
23
+ root is the user data dir — `bundle_dirs(root)` then resolves to the same user
24
+ modules dir connectors are added into. Overridable via $SQ_ROOT."""
25
+ return Path(os.environ.get("SQ_ROOT", Path.home() / ".local" / "share" / "sciqnt"))
26
+
27
+
28
+ def _setup_bundles(root: Path) -> None:
29
+ """Put every installed connector bundle's src/ on sys.path so `import sq_<broker>`
30
+ resolves inside the aggregation registry (mirrors bin/sciqnt-aggregated.py)."""
31
+ import sq_platform
32
+ for src in sq_platform.bundle_src_paths(root):
33
+ if src not in sys.path:
34
+ sys.path.insert(0, src)
35
+
36
+
37
+ def _parse_asof(s: str) -> datetime:
38
+ try:
39
+ return (datetime.fromisoformat(s).replace(tzinfo=timezone.utc) if "T" in s
40
+ else datetime.combine(datetime.fromisoformat(s).date(),
41
+ time(23, 59, 59), tzinfo=timezone.utc))
42
+ except ValueError as e:
43
+ raise argparse.ArgumentTypeError(
44
+ f"--asof must be YYYY-MM-DD or ISO timestamp; got {s!r}") from e
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ argv = list(sys.argv[1:] if argv is None else argv)
49
+ root = _root()
50
+ root.mkdir(parents=True, exist_ok=True)
51
+
52
+ # Sub-commands that route straight into the platform (parsed before the
53
+ # aggregated-view flags, exactly like the original bin/sciqnt dispatcher).
54
+ if argv and argv[0] == "modules":
55
+ import sq_platform # noqa: F401
56
+ from sq_platform import modules_cmd
57
+ _setup_bundles(root)
58
+ return modules_cmd.cli(root, argv[1:])
59
+ if argv and argv[0] == "--list":
60
+ import sq_platform
61
+ _setup_bundles(root)
62
+ return sq_platform.find_modules(root, " ".join(argv[1:]))
63
+
64
+ # Otherwise: the aggregated / interactive portfolio view.
65
+ _setup_bundles(root)
66
+ import sq_config
67
+ sq_config.materialise()
68
+ from sq_platform.aggregated import run_aggregated
69
+ from sq_platform.home import run_home
70
+
71
+ p = argparse.ArgumentParser(prog="sciqnt", description="sciqnt — portfolio view")
72
+ p.add_argument("--asof", type=_parse_asof, default=None,
73
+ help="as-of date (YYYY-MM-DD) for a PIT historical view")
74
+ p.add_argument("--fresh", action="store_true",
75
+ help="bypass the snapshot cache and force a live fetch")
76
+ p.add_argument("--once", action="store_true",
77
+ help="print the aggregated view once and exit (non-interactive dump)")
78
+ p.add_argument("--history", nargs="?", const="30", default=None, metavar="RANGE",
79
+ help="print portfolio state history and exit")
80
+ p.add_argument("--account", default=None, metavar="LABEL",
81
+ help="one account only, in its own base currency")
82
+ p.add_argument("--tab", default=None, metavar="NAME",
83
+ help="dump just one tab (summary/positions/exposure/news/flows/detailed)")
84
+ p.add_argument("--json", action="store_true", dest="as_json",
85
+ help="emit structured data instead of the rendered view")
86
+ args = p.parse_args(argv)
87
+
88
+ interactive = (sys.stdin.isatty() and sys.stdout.isatty()
89
+ and args.asof is None and not args.once
90
+ and args.history is None and args.account is None
91
+ and args.tab is None and not args.as_json)
92
+ try:
93
+ if interactive:
94
+ return run_home(root, use_snapshot_cache=not args.fresh)
95
+ if args.history is not None:
96
+ from sq_platform.aggregated import run_history
97
+ return run_history(root, args.history, account=args.account,
98
+ as_json=args.as_json, use_snapshot_cache=not args.fresh)
99
+ return run_aggregated(root, asof=args.asof, account=args.account, tab=args.tab,
100
+ as_json=args.as_json, use_snapshot_cache=not args.fresh)
101
+ except KeyboardInterrupt:
102
+ print()
103
+ return 130
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())
@@ -0,0 +1,252 @@
1
+ """sq_config_ui — THE interactive settings screen for sciqnt.
2
+
3
+ One full-screen settings experience (sq_tui.select_screen on the alternate
4
+ screen), consistent with the rest of the app — NOT questionary (questionary
5
+ choices don't render ANSI, which is how the old picker leaked raw `^[[2m`
6
+ escapes into labels). Every entry point shares this loop:
7
+
8
+ * `sciqnt config` (bare) and `sciqnt config set` → set.py → run_settings()
9
+ * the home's Settings action → sq_platform.home calls
10
+ run_settings() in-process with the home chrome, so the experience is
11
+ identical from home and CLI.
12
+
13
+ The screen lists every setting from `sq_config.schema()`: key left, CURRENT
14
+ value right (accent bold); not-yet-wired (`mvp=False`) settings render dim
15
+ with a "(soon)" suffix. Per-setting help lives in the `?` overlay, not in the
16
+ rows. Selecting a row edits it:
17
+ enum → a second select_screen (current preselected, per-option descriptions),
18
+ bool → toggles immediately (no second screen),
19
+ str → text_input_screen prefilled with the current value (empty → cancel).
20
+ All writes go through `sq_config.set()` (schema-validated, atomic) — an
21
+ invalid value is never written. After an edit the screen re-renders with the
22
+ cursor kept on the same row. Esc/q returns.
23
+
24
+ Non-interactive callers must NOT enter this loop — `set.py` checks
25
+ `sq_tui._streams_interactive()` and prints the plain dump (`show.py`, the
26
+ script/agent-facing surface) instead.
27
+ """
28
+ import textwrap
29
+ from pathlib import Path
30
+
31
+ import sq_config
32
+ import sq_tui
33
+ from sq_tui import BOLD, DIM, RST
34
+
35
+ # repo root: modules/sq-config/src/sq_config_ui/__init__.py → up 4 levels
36
+ _ROOT = Path(__file__).resolve().parents[4]
37
+
38
+ # Style for the value cell on a non-hovered, wired row (accent bold — matches
39
+ # the hover/selection accent everywhere else). Dim rows pass "" so the value
40
+ # inherits the row's dim base.
41
+ _VALUE_STYLE = f"fg:{sq_tui.ACCENT_HEX} bold"
42
+
43
+ # Per-option descriptions for enum settings whose schema help implies them.
44
+ # preferred_agent is handled dynamically (live install detection).
45
+ _ENUM_DESC = {
46
+ "cost_basis_method": {
47
+ "FIFO": "first in, first out",
48
+ "LIFO": "last in, first out",
49
+ "AVG": "average cost · ACB / Section-104 pool / Degiro BEP",
50
+ },
51
+ "performance_return_method": {
52
+ "TWR": "time-weighted — manager skill (GIPS default)",
53
+ "MWR": "money-weighted / XIRR — your personal cash-flow experience",
54
+ },
55
+ }
56
+
57
+
58
+ def parse_bool(v) -> bool:
59
+ """Tolerant bool read for the toggle (true/false/yes/no/1/0/on/off…).
60
+ Anything unrecognised reads as False — the toggle then writes True, a
61
+ safe self-heal for a hand-mangled value. Writes still go through
62
+ `sq_config.set`, which is strict."""
63
+ if isinstance(v, bool):
64
+ return v
65
+ return str(v).strip().lower() in ("true", "1", "yes", "on")
66
+
67
+
68
+ def display_value(setting, value) -> str:
69
+ """The row's value cell: bools as lowercase true/false (matching the JSON
70
+ on disk), None as an em-dash, everything else str()."""
71
+ if value is None:
72
+ return "—"
73
+ if setting.type == "bool":
74
+ return "true" if parse_bool(value) else "false"
75
+ return str(value)
76
+
77
+
78
+ def build_settings_items(schema, data):
79
+ """The settings rows for select_screen: (items, item_styles).
80
+
81
+ Each item is (fragments, key) — rich labels so the value cell can carry
82
+ its own style: key left, current value right in accent bold; mvp=False
83
+ rows get base style 'dim' plus a "(soon)" suffix (their value inherits
84
+ the dim base instead of the accent). Pure — unit-testable without a
85
+ terminal. Help text deliberately NOT in the row (it's in the ? overlay)."""
86
+ keyw = max(len(s.key) for s in schema)
87
+ vals = {s.key: display_value(s, data.get(s.key, s.default)) for s in schema}
88
+ valw = max(len(v) for v in vals.values())
89
+ items, styles = [], []
90
+ for s in schema:
91
+ frags = [("", f"{s.key:<{keyw}} "),
92
+ (_VALUE_STYLE if s.mvp else "", f"{vals[s.key]:>{valw}}")]
93
+ if not s.mvp:
94
+ frags.append(("", " (soon)"))
95
+ items.append((frags, s.key))
96
+ styles.append(None if s.mvp else "dim")
97
+ return items, styles
98
+
99
+
100
+ def help_text(schema) -> str:
101
+ """The `?` overlay: the keymap plus every setting's full help (the rows
102
+ stay data-only; the prose lives one keystroke away)."""
103
+ lines = [
104
+ f" {BOLD}Keys{RST}",
105
+ "",
106
+ " ↑ ↓ · j k move",
107
+ " enter edit (a true/false setting toggles immediately)",
108
+ " ? toggle this help",
109
+ " esc · q back",
110
+ "",
111
+ f" {BOLD}Settings{RST}",
112
+ "",
113
+ ]
114
+ for s in schema:
115
+ soon = "" if s.mvp else f" {DIM}(soon — declared, not yet wired){RST}"
116
+ lines.append(f" {BOLD}{s.key}{RST}{soon}")
117
+ for ln in textwrap.wrap(s.help or "—", 72):
118
+ lines.append(f" {DIM}{ln}{RST}")
119
+ lines += ["", f" {DIM}config file: {sq_config.path()}{RST}"]
120
+ return "\n".join(lines)
121
+
122
+
123
+ def _default_header(*crumbs) -> str:
124
+ """Standalone-CLI chrome: the banner + a bold 'Menu › Settings[ › …]'
125
+ breadcrumb — the same level layout as the home (which passes its own
126
+ richer chrome via `make_header`)."""
127
+ menu = " Menu › Settings" + "".join(f" › {c}" for c in crumbs)
128
+ try: # banner needs sq_platform (core)
129
+ from sq_platform import banner_text
130
+ top = banner_text(_ROOT) + "\n\n"
131
+ except Exception: # noqa: BLE001
132
+ top = ""
133
+ return f"{top}{BOLD}{menu}{RST}"
134
+
135
+
136
+ def _intro(s, cur) -> str:
137
+ """Dim context block for an edit screen: the setting's help (wrapped) +
138
+ current/default, appended to the header (header is ANSI-rendered, unlike
139
+ row labels — this is where styled prose belongs)."""
140
+ lines = textwrap.wrap(s.help or "", 72)
141
+ lines.append(f"current: {display_value(s, cur)} · default: {s.default}")
142
+ return "\n\n" + "".join(f" {DIM}{ln}{RST}\n" for ln in lines)
143
+
144
+
145
+ def _agent_descs(s):
146
+ """Live per-option tags for preferred_agent — like picking a default
147
+ browser: what 'auto' resolves to, and which agents are actually
148
+ installed. Degrades to no descriptions if detection fails."""
149
+ try:
150
+ import sq_agents
151
+ installed = set(sq_agents.detect())
152
+ auto = sq_agents.resolve("auto")
153
+ hint = sq_agents.label(auto) if auto else "none installed"
154
+ return {v: (f"auto → {hint}" if v == "auto"
155
+ else "installed" if v in installed else "not installed")
156
+ for v in s.allowed}
157
+ except Exception: # noqa: BLE001
158
+ return {}
159
+
160
+
161
+ def enum_options(s, cur):
162
+ """The second-screen rows for an enum setting: value left, dim
163
+ description right (rich fragments — select_screen strips raw ANSI from
164
+ labels, so styling MUST be fragments, never escape codes), '(current)'
165
+ on the active value."""
166
+ descs = _agent_descs(s) if s.key == "preferred_agent" else \
167
+ _ENUM_DESC.get(s.key, {})
168
+ w = max(len(v) for v in s.allowed)
169
+ out = []
170
+ for v in s.allowed:
171
+ frags = [("", f"{v:<{w}}")]
172
+ d = descs.get(v, "")
173
+ if d:
174
+ frags.append(("class:dim", f" {d}"))
175
+ if v == cur:
176
+ frags.append(("class:dim", " (current)"))
177
+ out.append((frags, v))
178
+ return out
179
+
180
+
181
+ def _pick_enum(s, cur, header_for):
182
+ """Enum edit: a second select_screen over the allowed values, the current
183
+ one preselected. Returns the picked value, or None on Esc."""
184
+ sel = sq_tui.select_screen(
185
+ enum_options(s, cur),
186
+ header=header_for(s.key) + _intro(s, cur),
187
+ selected=(s.allowed.index(cur) if cur in s.allowed else 0),
188
+ footer_hint="↑↓ move · enter set · esc cancel",
189
+ esc_result=sq_tui.BACK)
190
+ if sel in (sq_tui.BACK, sq_tui.QUIT):
191
+ return None
192
+ return sel
193
+
194
+
195
+ def _enter_text(s, cur, header_for):
196
+ """Free-form edit: full-screen text input prefilled with the current
197
+ value. Empty (or Esc) → None = cancel, nothing written."""
198
+ raw = sq_tui.text_input_screen(
199
+ f"{s.key}:", header=header_for(s.key) + _intro(s, cur),
200
+ default="" if cur is None else str(cur),
201
+ footer_hint="enter save · esc cancel")
202
+ if raw == sq_tui.BACK or not str(raw).strip():
203
+ return None
204
+ return str(raw).strip()
205
+
206
+
207
+ def _edit(s, header_for):
208
+ """Dispatch one edit by setting type. All writes go through
209
+ `sq_config.set` (schema-validated); a ValueError is swallowed — the loop
210
+ re-renders unchanged rather than ever writing an invalid value."""
211
+ cur = sq_config.get(s.key)
212
+ if s.type == "bool":
213
+ new = not parse_bool(cur) # toggle immediately — no 2nd screen
214
+ elif s.type == "enum":
215
+ new = _pick_enum(s, cur, header_for)
216
+ else: # str / int → free-form text
217
+ new = _enter_text(s, cur, header_for)
218
+ if new is None:
219
+ return
220
+ try:
221
+ sq_config.set(s.key, new)
222
+ except ValueError:
223
+ pass
224
+
225
+
226
+ def run_settings(make_header=None):
227
+ """The settings loop. `make_header(*crumbs)` builds the chrome above the
228
+ list (the home passes its `_static_chrome`; standalone CLI defaults to
229
+ banner + breadcrumb). Re-renders after every edit with the cursor kept on
230
+ the edited row (select_screen.last_index); Esc/q returns.
231
+
232
+ Off-TTY, select_screen itself degrades to the numbered fallback — but
233
+ entry points should route pipes to the plain dump instead (see set.py)."""
234
+ header_for = make_header or _default_header
235
+ sq_config.materialise() # file exists + carries every key
236
+ cursor = 0
237
+ while True:
238
+ schema = sq_config.schema()
239
+ items, styles = build_settings_items(schema, sq_config.all())
240
+ sel = sq_tui.select_screen(
241
+ items, header=header_for() + "\n",
242
+ item_styles=styles, selected=cursor,
243
+ footer_hint="↑↓ move · enter edit · ? help · esc back",
244
+ help_lines=help_text(schema), esc_result=sq_tui.BACK)
245
+ if sel in (sq_tui.BACK, sq_tui.QUIT):
246
+ return
247
+ li = getattr(sq_tui.select_screen, "last_index", 0)
248
+ if isinstance(li, int): # mocked select stubs may omit it
249
+ cursor = li
250
+ s = next((x for x in schema if x.key == sel), None)
251
+ if s is not None:
252
+ _edit(s, header_for)