smartpipe-cli 1.3.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.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,33 @@
1
+ """``smartpipe sort`` — order records by a field, without the jq incantation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from smartpipe.core.errors import ExitCode
10
+ from smartpipe.verbs.sortverb import SortRequest, run_sort
11
+
12
+ __all__ = ["sort_command"]
13
+
14
+
15
+ @click.command(name="sort")
16
+ @click.option("--by", "by", required=True, metavar="FIELD", help="The field to order by.")
17
+ @click.option("--desc", "descending", is_flag=True, help="Largest / Z-first.")
18
+ def sort_command(by: str, descending: bool) -> None:
19
+ """Order records by a field. Free — never calls a model. Reads the whole input.
20
+
21
+ \b
22
+ Examples:
23
+ cat scored.ndjson | smartpipe sort --by _score --desc | head -5
24
+ smartpipe map "…{confidence number}" --in 'docs/*.pdf' | smartpipe sort --by confidence --desc
25
+
26
+ Numbers sort numerically, strings lexically (numbers first when mixed);
27
+ rows missing the field always land LAST, in both directions, with a
28
+ note. Stable: ties keep input order. Rows pass through byte-for-byte.
29
+ (There is no `take` — `head` already counts NDJSON rows.)
30
+ """
31
+ code = run_sort(SortRequest(by=by, descending=descending), stdin=sys.stdin, stdout=sys.stdout)
32
+ if code is not ExitCode.OK: # pragma: no cover — sort always OKs
33
+ raise SystemExit(int(code))
@@ -0,0 +1,76 @@
1
+ """``smartpipe split`` — break oversized items into chunk items. No model calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from smartpipe.cli.input_options import input_options, input_spec
12
+ from smartpipe.cli.interrupts import graceful_interrupts
13
+ from smartpipe.core.errors import ExitCode
14
+ from smartpipe.verbs.split import SplitRequest, run_split
15
+
16
+ __all__ = ["split_command"]
17
+
18
+
19
+ @click.command(name="split")
20
+ @click.option(
21
+ "--by",
22
+ "by_flag",
23
+ metavar="UNIT[:N]",
24
+ help="Split unit: tokens, pages, minutes, seconds. e.g. --by pages, --by minutes:10",
25
+ )
26
+ @click.option(
27
+ "--media",
28
+ "media",
29
+ is_flag=True,
30
+ help="Extract images embedded in PDFs/DOCX/PPTX/XLSX as items (icons dropped).",
31
+ )
32
+ @click.option(
33
+ "--max-tokens",
34
+ "max_tokens",
35
+ type=int,
36
+ help="Shorthand for --by tokens:N (default 2000).",
37
+ )
38
+ @input_options
39
+ def split_command(
40
+ by_flag: str | None,
41
+ media: bool,
42
+ max_tokens: int | None,
43
+ in_patterns: tuple[str, ...],
44
+ from_files: bool,
45
+ ) -> None:
46
+ """Break oversized items into budget-sized chunks. Free — no model calls.
47
+
48
+ \b
49
+ Examples:
50
+ smartpipe split --in '10k-filings/*.pdf' | smartpipe map "list the risk factors {risk}"
51
+ smartpipe split --by pages:5 --in report.pdf | smartpipe map "summarize these pages"
52
+ smartpipe split --by minutes:10 --in call.mp3 | smartpipe map "what was agreed?"
53
+
54
+ Each chunk is a JSON record: {"text": …, "source": "report.pdf §3/12"} —
55
+ paragraph-boundary aware, and the chunks of a document concatenate back to
56
+ its exact text. Recombine downstream with reduce.
57
+ """
58
+ request = SplitRequest(
59
+ max_tokens_flag=max_tokens,
60
+ by_flag=by_flag,
61
+ media=media,
62
+ input=input_spec(in_patterns, from_files=from_files),
63
+ )
64
+ code = asyncio.run(_run(request))
65
+ if code is not ExitCode.OK:
66
+ raise SystemExit(int(code))
67
+
68
+
69
+ async def _run(request: SplitRequest) -> ExitCode:
70
+ from smartpipe.container import build_container
71
+
72
+ async with (
73
+ graceful_interrupts() as stop,
74
+ build_container(os.environ) as container,
75
+ ):
76
+ return await run_split(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
@@ -0,0 +1,37 @@
1
+ """``smartpipe summarize`` — the numbers, without leaving for awk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from smartpipe.core.errors import ExitCode
10
+ from smartpipe.verbs.summarize import SummarizeRequest, run_summarize
11
+
12
+ __all__ = ["summarize_command"]
13
+
14
+
15
+ @click.command(name="summarize")
16
+ @click.argument("expression")
17
+ def summarize_command(expression: str) -> None:
18
+ """Aggregate records deterministically. Free — never calls a model.
19
+
20
+ \b
21
+ Examples:
22
+ cat orders.jsonl | smartpipe summarize 'count(), avg(total), p95(total) by region'
23
+ cat evals.jsonl | smartpipe summarize 'count() by pass'
24
+ ... | smartpipe summarize 'dcount(user) by day'
25
+
26
+ \b
27
+ Aggregations: count() · sum(f) · avg(f) · min(f) · max(f)
28
+ p50(f) · p90(f) · p95(f) · p99(f) · dcount(f)
29
+
30
+ KQL's own grammar and output naming (count, avg_total, p95_total).
31
+ Groups sort largest first; a missing group field groups under null,
32
+ visibly. Non-numeric values in numeric aggregations are skipped and
33
+ counted on stderr — never a mid-stream crash.
34
+ """
35
+ code = run_summarize(SummarizeRequest(expression), stdin=sys.stdin, stdout=sys.stdout)
36
+ if code is not ExitCode.OK: # pragma: no cover — summarize always OKs
37
+ raise SystemExit(int(code))
@@ -0,0 +1,97 @@
1
+ """``smartpipe top_k`` — rank items by similarity to a query."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from smartpipe.cli.completions import complete_embed_models
12
+ from smartpipe.cli.input_options import fields_option, input_options, input_spec
13
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
14
+ from smartpipe.core.errors import ExitCode
15
+ from smartpipe.verbs.top_k import TopKRequest, run_top_k
16
+
17
+ __all__ = ["top_k_command"]
18
+
19
+
20
+ @click.command(name="top_k")
21
+ @click.argument("k", type=int, required=False)
22
+ @click.option("--near", required=True, help="Rank items by similarity to this query.")
23
+ @click.option("--threshold", type=float, help="Keep everything at or above this similarity (0-1).")
24
+ @click.option(
25
+ "--embed-model", "model_flag", shell_complete=complete_embed_models, help="Embedding model."
26
+ )
27
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
28
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
29
+ @click.option("--stream", "stream", is_flag=True, help="Live leaderboard over a stream.")
30
+ @fields_option
31
+ @click.option(
32
+ "--allow-captions",
33
+ "allow_captions",
34
+ is_flag=True,
35
+ help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
36
+ )
37
+ @input_options
38
+ def top_k_command(
39
+ k: int | None,
40
+ near: str,
41
+ threshold: float | None,
42
+ model_flag: str | None,
43
+ concurrency_flag: int | None,
44
+ max_calls: int | None,
45
+ allow_captions: bool,
46
+ stream: bool,
47
+ fields: tuple[str, ...] | None,
48
+ in_patterns: tuple[str, ...],
49
+ from_files: bool,
50
+ ) -> None:
51
+ """Rank items by similarity to a query and return the top K.
52
+
53
+ \b
54
+ Examples:
55
+ smartpipe top_k 5 --near "distributed systems engineer" --in 'resumes/*.pdf'
56
+ cat corpus.embeddings | smartpipe top_k 10 --near "Q3 revenue strategy"
57
+ cat articles.jsonl | smartpipe top_k --near "climate policy" --threshold 0.8
58
+
59
+ Give a number (K), a --threshold, or both. Each result gains a _score (0-1).
60
+ In file mode, each result is a filename and its score.
61
+ """
62
+ request = TopKRequest(
63
+ allow_captions=allow_captions,
64
+ near=near,
65
+ k=k,
66
+ threshold=threshold,
67
+ model_flag=model_flag,
68
+ concurrency_flag=concurrency_flag,
69
+ stream=stream,
70
+ input=input_spec(in_patterns, from_files=from_files),
71
+ fields=fields,
72
+ )
73
+ code = asyncio.run(_run(request, max_calls))
74
+ if code is not ExitCode.OK:
75
+ raise SystemExit(int(code))
76
+
77
+
78
+ async def _run(request: TopKRequest, max_calls: int | None) -> ExitCode:
79
+ from smartpipe.container import build_container
80
+
81
+ if not request.stream: # whole-set mode: ^C exits immediately; budget is fatal (D18)
82
+ async with build_container(os.environ, max_calls=max_calls) as container:
83
+ if not request.allow_captions and container.config.allow_captions:
84
+ from dataclasses import replace as _replace
85
+
86
+ request = _replace(request, allow_captions=True) # profile consent (D35)
87
+ return await run_top_k(request, container, stdin=sys.stdin, stdout=sys.stdout)
88
+ async with (
89
+ graceful_interrupts() as stop,
90
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
91
+ ):
92
+ if not request.allow_captions and container.config.allow_captions:
93
+ from dataclasses import replace as _replace
94
+
95
+ request = _replace(request, allow_captions=True) # profile consent (D35)
96
+ code = await run_top_k(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
97
+ return settle_budget(container.budget, code)
@@ -0,0 +1,66 @@
1
+ """``smartpipe usage`` — the ledger of what the meter observed (D41)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import click
8
+
9
+ from smartpipe.io.metering import count as count_fmt
10
+ from smartpipe.io.metering import duration as duration_fmt
11
+ from smartpipe.io.metering import megabytes as megabytes_fmt
12
+ from smartpipe.io.usage import Totals, read_ledger, reset_ledger, stamp
13
+
14
+ __all__ = ["usage_command"]
15
+
16
+
17
+ @click.group(name="usage", invoke_without_command=True)
18
+ @click.pass_context
19
+ def usage_command(ctx: click.Context) -> None:
20
+ """Model usage over time: hour, day, week, month, lifetime. Resettable.
21
+
22
+ Counts model-touching runs only (free verbs never call a model). The
23
+ numbers are the meter's observed units — tokens, media, conversions.
24
+ """
25
+ if ctx.invoked_subcommand is not None:
26
+ return
27
+ windows, first_seen, last_reset = read_ledger(os.environ)
28
+ if windows["lifetime"].runs == 0:
29
+ click.echo("no model usage recorded yet")
30
+ return
31
+ from smartpipe.cli.screens import heading, tint
32
+
33
+ header = (
34
+ f"{'':<12}{'runs':>6}{'tokens in':>12}{'tokens out':>12}"
35
+ f"{'media':>10}{'audio':>9}{'conv':>6}"
36
+ )
37
+ click.echo(heading(header))
38
+ for name in ("past hour", "past day", "past week", "past month", "lifetime"):
39
+ row = _row(name, windows[name])
40
+ click.echo(tint(row, "1") if name == "lifetime" else row)
41
+ notes: list[str] = []
42
+ if last_reset is not None:
43
+ notes.append(f"last reset {stamp(last_reset)}")
44
+ if first_seen is not None:
45
+ notes.append(f"first use {stamp(first_seen)}")
46
+ if notes:
47
+ click.echo(tint("since: " + " · ".join(notes), "2"))
48
+
49
+
50
+ def _row(name: str, totals: Totals) -> str:
51
+ audio = duration_fmt(totals.audio_seconds) if totals.audio_seconds else "-"
52
+ media = megabytes_fmt(totals.media_bytes) if totals.media_bytes else "-"
53
+ return (
54
+ f"{name:<12}{totals.runs:>6}{count_fmt(totals.tokens_in):>12}"
55
+ f"{count_fmt(totals.tokens_out):>12}{media:>10}{audio:>9}{totals.conversions:>6}"
56
+ )
57
+
58
+
59
+ @usage_command.command(name="reset")
60
+ def usage_reset() -> None:
61
+ """Zero the ledger; the reset time is remembered and shown."""
62
+ previous = reset_ledger(os.environ)
63
+ click.echo(
64
+ f"usage reset (previous lifetime: {count_fmt(previous.tokens_in)} in · "
65
+ f"{count_fmt(previous.tokens_out)} out tokens across {previous.runs} runs)"
66
+ )
@@ -0,0 +1,36 @@
1
+ """``smartpipe where`` — the free filter. Runs before anything paid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from smartpipe.core.errors import ExitCode
10
+ from smartpipe.verbs.where import WhereRequest, run_where
11
+
12
+ __all__ = ["where_command"]
13
+
14
+
15
+ @click.command(name="where")
16
+ @click.argument("predicate")
17
+ def where_command(predicate: str) -> None:
18
+ """Keep rows matching a deterministic predicate. Free — never calls a model.
19
+
20
+ \b
21
+ Examples:
22
+ tail -f app.log | smartpipe where 'text has "ERROR"'
23
+ cat orders.jsonl | smartpipe where 'total > 1000'
24
+ smartpipe where 'level == "error" and not text contains "retry"'
25
+
26
+ \b
27
+ Operators: has (word, case-insensitive) · contains · matches /re/
28
+ == != > >= < <= combined with and · or · not · ( )
29
+ FIELD is a record field, or text for the whole line.
30
+
31
+ Put where BEFORE filter/map: it cuts the corpus for free, so the paid
32
+ stages only see what matters. Semantic condition? Use filter.
33
+ """
34
+ code = run_where(WhereRequest(predicate), stdin=sys.stdin, stdout=sys.stdout)
35
+ if code is not ExitCode.OK: # pragma: no cover — where currently always OKs
36
+ raise SystemExit(int(code))
@@ -0,0 +1,5 @@
1
+ """Configuration: file location, load/store, and value precedence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
@@ -0,0 +1,118 @@
1
+ """The OAuth token store (plan/decisions.md D19) — ``auth.json`` beside the config.
2
+
3
+ The narrowest amendment to "nothing stored": one file, mode 0600, one purpose
4
+ (login tokens that must persist to refresh), removable with ``smartpipe auth
5
+ logout``. API keys never live here. Unknown provider entries are preserved on
6
+ rewrite (forward compatibility, same spirit as the config store).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import tempfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from smartpipe.config.paths import human_path
20
+ from smartpipe.core.errors import SetupFault
21
+ from smartpipe.core.jsontools import as_record, as_str
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping
25
+
26
+ __all__ = ["OAuthCredential", "credentials_path", "load_oauth", "remove_oauth", "save_oauth"]
27
+
28
+ _MODE = 0o600
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class OAuthCredential:
33
+ access: str
34
+ refresh: str
35
+ expires_ms: int # epoch milliseconds, matching the wire's convention
36
+ account_id: str | None = None
37
+
38
+
39
+ def credentials_path(env: Mapping[str, str] | None = None, platform: str | None = None) -> Path:
40
+ from smartpipe.config.paths import config_path
41
+
42
+ return config_path(env, platform).parent / "auth.json"
43
+
44
+
45
+ def load_oauth(path: Path, provider: str) -> OAuthCredential | None:
46
+ record = _read_raw(path).get(provider)
47
+ entry = as_record(record)
48
+ if entry is None or entry.get("type") != "oauth":
49
+ return None
50
+ access = as_str(entry.get("access"))
51
+ refresh = as_str(entry.get("refresh"))
52
+ expires = entry.get("expires")
53
+ if access is None or refresh is None or not isinstance(expires, int):
54
+ return None
55
+ return OAuthCredential(
56
+ access=access,
57
+ refresh=refresh,
58
+ expires_ms=expires,
59
+ account_id=as_str(entry.get("account_id")),
60
+ )
61
+
62
+
63
+ def save_oauth(path: Path, provider: str, credential: OAuthCredential) -> None:
64
+ data = _read_raw(path)
65
+ entry: dict[str, object] = {
66
+ "type": "oauth",
67
+ "access": credential.access,
68
+ "refresh": credential.refresh,
69
+ "expires": credential.expires_ms,
70
+ }
71
+ if credential.account_id is not None:
72
+ entry["account_id"] = credential.account_id
73
+ data[provider] = entry
74
+ _write_raw(path, data)
75
+
76
+
77
+ def remove_oauth(path: Path, provider: str) -> bool:
78
+ """Delete one provider's entry; True if something was removed."""
79
+ data = _read_raw(path)
80
+ if provider not in data:
81
+ return False
82
+ del data[provider]
83
+ _write_raw(path, data)
84
+ return True
85
+
86
+
87
+ def _read_raw(path: Path) -> dict[str, object]:
88
+ if not path.exists():
89
+ return {}
90
+ try:
91
+ parsed: object = json.loads(path.read_text(encoding="utf-8"))
92
+ except (json.JSONDecodeError, OSError) as exc:
93
+ raise SetupFault(
94
+ f"error: the login store is unreadable\n"
95
+ f" {human_path(path)}: {exc}\n"
96
+ " Fix the file, or remove it and log in again: smartpipe auth login"
97
+ ) from exc
98
+ record = as_record(parsed)
99
+ return dict(record) if record is not None else {}
100
+
101
+
102
+ def _write_raw(path: Path, data: dict[str, object]) -> None:
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
105
+ try:
106
+ if hasattr(os, "fchmod"): # POSIX: 0600 from the first byte
107
+ os.fchmod(fd, _MODE)
108
+ else: # Windows has no fd-chmod; ACLs scope the profile dir instead
109
+ os.chmod(tmp, _MODE)
110
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
111
+ json.dump(data, handle, indent=2)
112
+ os.replace(tmp, path) # atomic on POSIX and Windows (same volume)
113
+ except BaseException:
114
+ with contextlib.suppress(OSError):
115
+ os.unlink(tmp)
116
+ raise
117
+ with contextlib.suppress(OSError): # replace preserves the temp's mode, but be sure
118
+ os.chmod(path, _MODE)
@@ -0,0 +1,70 @@
1
+ """Rendering ``config show`` — pure, so the exact layout is golden-testable.
2
+
3
+ The point of the origin column (plan/decisions.md D09): precedence is never a
4
+ mystery. Each effective value is shown with where it came from — env var,
5
+ config file, or built-in default.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from smartpipe.io.text import display_width
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping, Sequence
17
+
18
+ from smartpipe.config.store import Config
19
+
20
+ __all__ = ["Setting", "render_show", "settings_with_origin"]
21
+
22
+ _DEFAULTS = {
23
+ "model": "(auto-detect)",
24
+ "embed-model": "nomic-embed-text",
25
+ "concurrency": "4",
26
+ "output": "auto",
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Setting:
32
+ key: str
33
+ value: str
34
+ origin: str # "env" | "config file" | "default"
35
+
36
+
37
+ def settings_with_origin(env: Mapping[str, str], config: Config) -> tuple[Setting, ...]:
38
+ return (
39
+ _resolve("model", env.get("SMARTPIPE_MODEL"), config.model),
40
+ _resolve("embed-model", env.get("SMARTPIPE_EMBED_MODEL"), config.embed_model),
41
+ _resolve("concurrency", env.get("SMARTPIPE_CONCURRENCY"), config.concurrency),
42
+ _resolve("output", env.get("SMARTPIPE_OUTPUT"), config.output),
43
+ )
44
+
45
+
46
+ def render_show(settings: Sequence[Setting], config_file: str) -> str:
47
+ key_width = max(display_width(s.key) for s in (*settings, Setting("config file", "", ""))) + 2
48
+ value_width = max(display_width(s.value) for s in settings) + 2
49
+ from smartpipe.cli.screens import tint
50
+
51
+ lines = [
52
+ f"{tint(_pad(s.key, key_width), '2')}{_pad(s.value, value_width)}"
53
+ f"{tint(f'({s.origin})', '2')}"
54
+ for s in settings
55
+ ]
56
+ lines.append(f"{tint(_pad('config file', key_width), '2')}{config_file}")
57
+ return "\n".join(lines)
58
+
59
+
60
+ def _pad(text: str, width: int) -> str:
61
+ """Pad to ``width`` terminal cells (DEFER-2) — f-string ``<`` pads code points."""
62
+ return text + " " * max(0, width - display_width(text))
63
+
64
+
65
+ def _resolve(key: str, env_value: str | None, config_value: object) -> Setting:
66
+ if env_value is not None and env_value.strip():
67
+ return Setting(key, env_value.strip(), "env")
68
+ if config_value is not None:
69
+ return Setting(key, str(config_value), "config file")
70
+ return Setting(key, _DEFAULTS[key], "default")
@@ -0,0 +1,58 @@
1
+ """``smartpipe doctor`` report — the pure half (D18).
2
+
3
+ Check *gathering* is the CLI's job (it touches env, disk, and the local Ollama
4
+ probe); this module owns the result type, the rendering, and the exit rule so the
5
+ exact screen is golden-testable. Doctor never makes a paid model call — its whole
6
+ point is telling you the run will work *before* anything costs money.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Literal
13
+
14
+ from smartpipe.core.errors import ExitCode
15
+ from smartpipe.io.text import display_width
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable, Sequence
19
+
20
+ __all__ = ["CheckResult", "doctor_exit_code", "render_report"]
21
+
22
+ _MARKS: dict[str, str] = {"ok": "✓", "fail": "✗", "skip": "–"} # noqa: RUF001 — the en-dash skip mark is the ux.md pin
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class CheckResult:
27
+ section: str # "config", "ollama", "chat", "embed", "keys", "login", "extras", "terminal"
28
+ status: Literal["ok", "fail", "skip"]
29
+ detail: str # the rest of the line, including any "— fix: …"
30
+
31
+
32
+ def render_report(results: Sequence[CheckResult]) -> str:
33
+ """One line per check: padded section, mark, detail (the ux.md doctor screen)."""
34
+ from smartpipe.cli.screens import bad, good, tint
35
+
36
+ width = max(display_width(result.section) for result in results) + 2
37
+
38
+ def dim_mark(mark: str) -> str:
39
+ return tint(mark, "2")
40
+
41
+ paint: dict[str, Callable[[str], str]] = {"ok": good, "fail": bad, "skip": dim_mark}
42
+ lines = (
43
+ f"{tint(_pad(result.section, width), '2')}"
44
+ f"{paint[result.status](_MARKS[result.status])} {result.detail}"
45
+ for result in results
46
+ )
47
+ return "\n".join(lines)
48
+
49
+
50
+ def doctor_exit_code(results: Sequence[CheckResult]) -> ExitCode:
51
+ """0 all green (skips are fine); 1 if anything needs fixing."""
52
+ if any(result.status == "fail" for result in results):
53
+ return ExitCode.PARTIAL
54
+ return ExitCode.OK
55
+
56
+
57
+ def _pad(text: str, width: int) -> str:
58
+ return text + " " * max(0, width - display_width(text))
@@ -0,0 +1,38 @@
1
+ """Where the config file lives (plan/decisions.md D09).
2
+
3
+ ``~/.config/smartpipe/config.toml`` on every Unix including macOS — the spec
4
+ names this path and terminal users expect it — and ``%APPDATA%\\smartpipe`` on
5
+ Windows. Environment and platform are parameters so the logic is pure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
18
+ __all__ = ["config_path", "human_path"]
19
+
20
+
21
+ def config_path(env: Mapping[str, str] | None = None, platform: str | None = None) -> Path:
22
+ resolved_env = os.environ if env is None else env
23
+ resolved_platform = sys.platform if platform is None else platform
24
+ if resolved_platform == "win32":
25
+ appdata = resolved_env.get("APPDATA")
26
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
27
+ else:
28
+ xdg = resolved_env.get("XDG_CONFIG_HOME")
29
+ base = Path(xdg) if xdg else Path.home() / ".config"
30
+ return base / "smartpipe" / "config.toml"
31
+
32
+
33
+ def human_path(path: Path) -> str:
34
+ """``~/.config/smartpipe/config.toml`` beats an absolute path in messages."""
35
+ try:
36
+ return f"~/{path.relative_to(Path.home()).as_posix()}"
37
+ except ValueError:
38
+ return str(path)