jdsl 0.6.2__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 (73) hide show
  1. jdsl/__init__.py +116 -0
  2. jdsl/cli.py +387 -0
  3. jdsl/config.py +80 -0
  4. jdsl/context.py +159 -0
  5. jdsl/dsl.py +174 -0
  6. jdsl/ir/__init__.py +63 -0
  7. jdsl/ir/expr.py +193 -0
  8. jdsl/ir/lower.py +160 -0
  9. jdsl/ir/schema.py +293 -0
  10. jdsl/ir/validate.py +134 -0
  11. jdsl/package/__init__.py +38 -0
  12. jdsl/package/authoring.py +244 -0
  13. jdsl/package/export.py +129 -0
  14. jdsl/package/load.py +155 -0
  15. jdsl/package/manifest.py +120 -0
  16. jdsl/package/proposal.py +344 -0
  17. jdsl/provider.py +214 -0
  18. jdsl/render.py +35 -0
  19. jdsl/router.py +31 -0
  20. jdsl/trace/__init__.py +41 -0
  21. jdsl/trace/blobs.py +56 -0
  22. jdsl/trace/events.py +180 -0
  23. jdsl/trace/jsonl.py +72 -0
  24. jdsl/trace/redaction.py +89 -0
  25. jdsl/trace/replay.py +117 -0
  26. jdsl/trace/sink.py +110 -0
  27. jdsl/tree.py +753 -0
  28. jdsl/tui/__init__.py +6 -0
  29. jdsl/tui/app.py +164 -0
  30. jdsl/tui/commands.py +60 -0
  31. jdsl/tui/definitions.py +157 -0
  32. jdsl/tui/dialogs.py +56 -0
  33. jdsl/tui/editors.py +420 -0
  34. jdsl/tui/run_inputs.py +113 -0
  35. jdsl/tui/run_panel.py +69 -0
  36. jdsl/tui/screens.py +189 -0
  37. jdsl/tui/settings.py +67 -0
  38. jdsl/tui/signature_editor.py +325 -0
  39. jdsl/tui/templates.py +387 -0
  40. jdsl/tui/theme.py +33 -0
  41. jdsl/tui/workbench.py +1457 -0
  42. jdsl/tui/workbench.tcss +181 -0
  43. jdsl/tui_skill.py +5 -0
  44. jdsl-0.6.2.dist-info/METADATA +231 -0
  45. jdsl-0.6.2.dist-info/RECORD +73 -0
  46. jdsl-0.6.2.dist-info/WHEEL +4 -0
  47. jdsl-0.6.2.dist-info/entry_points.txt +2 -0
  48. jdsl-0.6.2.dist-info/licenses/LICENSE +8 -0
  49. jdsl_harness/__init__.py +8 -0
  50. jdsl_harness/adapters/__init__.py +6 -0
  51. jdsl_harness/adapters/claude_code.py +144 -0
  52. jdsl_harness/adapters/correlation.py +88 -0
  53. jdsl_harness/adapters/gemini_cli.py +70 -0
  54. jdsl_harness/adapters/generic_mcp.py +53 -0
  55. jdsl_harness/adapters/import_jsonl.py +86 -0
  56. jdsl_harness/adapters/opencode.py +110 -0
  57. jdsl_harness/capture.py +129 -0
  58. jdsl_harness/compiler/__init__.py +48 -0
  59. jdsl_harness/compiler/candidates.py +131 -0
  60. jdsl_harness/compiler/consolidate.py +141 -0
  61. jdsl_harness/compiler/lineage.py +106 -0
  62. jdsl_harness/compiler/model.py +175 -0
  63. jdsl_harness/compiler/normalize.py +157 -0
  64. jdsl_harness/compiler/package.py +125 -0
  65. jdsl_harness/compiler/residualize.py +31 -0
  66. jdsl_harness/compiler/staticize.py +258 -0
  67. jdsl_harness/compiler/verify.py +127 -0
  68. jdsl_harness/gateway.py +107 -0
  69. jdsl_harness/mcp_proxy.py +277 -0
  70. jdsl_harness/metrics.py +125 -0
  71. jdsl_harness/server.py +260 -0
  72. jdsl_harness/state.py +64 -0
  73. jdsl_harness/store.py +222 -0
jdsl/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """jdsl — behavior-tree agents with DSPy-style signatures, run by a tree-walking
2
+ interpreter. Authoring surface: tool, root, seq, sel, repeat, act, check, predict, ref, store."""
3
+
4
+ from jdsl.context import Blackboard, ContextWindow, ModelTurn, Ref, RunContext, ToolCall, Write
5
+ from jdsl.dsl import (
6
+ act,
7
+ check,
8
+ guard,
9
+ guard_call,
10
+ invert,
11
+ oneshot,
12
+ optional,
13
+ predict,
14
+ react,
15
+ ref,
16
+ repeat,
17
+ root,
18
+ sel,
19
+ seq,
20
+ store,
21
+ timeout,
22
+ tool,
23
+ )
24
+ from jdsl.provider import LanguageModel
25
+ from jdsl.render import render
26
+ from jdsl.trace import (
27
+ BlobStore,
28
+ EventKind,
29
+ EventSource,
30
+ JsonlTraceSink,
31
+ ListTraceSink,
32
+ NullTraceSink,
33
+ Redactor,
34
+ SafeSink,
35
+ TraceEvent,
36
+ read_events,
37
+ segment_episodes,
38
+ )
39
+ from jdsl.tree import (
40
+ Action,
41
+ Check,
42
+ Decorator,
43
+ Guard,
44
+ GuardCall,
45
+ Invert,
46
+ Node,
47
+ OneShot,
48
+ Optional,
49
+ Predict,
50
+ React,
51
+ Repeat,
52
+ Root,
53
+ Selector,
54
+ Sequence,
55
+ Status,
56
+ Timeout,
57
+ assign_runtime_ids,
58
+ )
59
+
60
+ __all__ = [
61
+ "tool",
62
+ "act",
63
+ "seq",
64
+ "sel",
65
+ "repeat",
66
+ "check",
67
+ "guard",
68
+ "guard_call",
69
+ "predict",
70
+ "react",
71
+ "invert",
72
+ "optional",
73
+ "timeout",
74
+ "oneshot",
75
+ "root",
76
+ "store",
77
+ "ref",
78
+ "render",
79
+ "Ref",
80
+ "Status",
81
+ "Node",
82
+ "Action",
83
+ "Check",
84
+ "Guard",
85
+ "GuardCall",
86
+ "Repeat",
87
+ "Sequence",
88
+ "Selector",
89
+ "Predict",
90
+ "React",
91
+ "Decorator",
92
+ "Invert",
93
+ "Optional",
94
+ "Timeout",
95
+ "OneShot",
96
+ "Root",
97
+ "LanguageModel",
98
+ "RunContext",
99
+ "Blackboard",
100
+ "ContextWindow",
101
+ "ToolCall",
102
+ "ModelTurn",
103
+ "Write",
104
+ "assign_runtime_ids",
105
+ "TraceEvent",
106
+ "EventKind",
107
+ "EventSource",
108
+ "NullTraceSink",
109
+ "ListTraceSink",
110
+ "JsonlTraceSink",
111
+ "SafeSink",
112
+ "BlobStore",
113
+ "Redactor",
114
+ "read_events",
115
+ "segment_episodes",
116
+ ]
jdsl/cli.py ADDED
@@ -0,0 +1,387 @@
1
+ """The jdsl CLI: runtime (`config`, `run`, `show`) plus the harness/compiler
2
+ control surface (`capture`, `compile`, `package`, `harness`) from design §37.
3
+
4
+ Harness commands import `jdsl_harness` lazily, so the dependency-light runtime core
5
+ keeps working when the harness extra is not installed."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import json
11
+ import os
12
+ import shutil
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import typer
17
+
18
+ from jdsl import config
19
+ from jdsl.render import render
20
+ from jdsl.tree import Root
21
+
22
+ app = typer.Typer(
23
+ add_completion=False,
24
+ help=("Declarative behavior-tree agents. Read docs at "
25
+ "https://cantor-industries.github.io/jdsl-py/ to understand what jdsl is."),
26
+ )
27
+ config_app = typer.Typer(help="Manage provider API keys (~/.local/share/recon/auth.json).")
28
+ app.add_typer(config_app, name="config")
29
+
30
+ package_app = typer.Typer(help="Inspect, verify, and run compiled .jdsl behavior packages.")
31
+ capture_app = typer.Typer(help="List and inspect captured trace sets.")
32
+ harness_app = typer.Typer(help="Run the local harness daemon (ingest + control plane).")
33
+ app.add_typer(package_app, name="package")
34
+ app.add_typer(capture_app, name="capture")
35
+ app.add_typer(harness_app, name="harness")
36
+
37
+
38
+ def _store_root() -> Path:
39
+ """Where the harness keeps its store; overridable via JDSL_HARNESS_HOME."""
40
+ return Path(os.environ.get("JDSL_HARNESS_HOME") or (Path.home() / ".local/share/jdsl-harness"))
41
+
42
+
43
+ @config_app.command("add")
44
+ def config_add(
45
+ keys: list[str] = typer.Argument(..., help="One or more API keys to store."),
46
+ provider: str = typer.Option("anthropic", "-p", "--provider", help="Provider name."),
47
+ ) -> None:
48
+ """Store API keys for a provider (de-duplicated, merged)."""
49
+ try:
50
+ merged = config.add_keys(provider, keys)
51
+ except ValueError as err:
52
+ typer.secho(str(err), fg=typer.colors.RED, err=True)
53
+ raise typer.Exit(1) from None
54
+ typer.secho(
55
+ f"Stored {len(keys)} key(s) for {provider!r} ({len(merged)} total) at {config.auth_path()}.",
56
+ fg=typer.colors.GREEN,
57
+ )
58
+
59
+
60
+ @config_app.command("list")
61
+ def config_list() -> None:
62
+ """Print the current auth config with keys masked."""
63
+ current = config.load()
64
+ if not current:
65
+ typer.echo("No stored credentials.")
66
+ return
67
+ for provider, entry in current.items():
68
+ typer.secho(provider, fg=typer.colors.CYAN, bold=True)
69
+ for key in entry.get("api_keys", []):
70
+ typer.echo(f" {_mask(key)}")
71
+
72
+
73
+ @app.command("run")
74
+ def run(
75
+ file: Path = typer.Argument(..., exists=True, readable=True, help="A .py file defining skills."),
76
+ inputs: list[str] = typer.Option(
77
+ [], "-i", "--input", help="Seed a blackboard input as key=value (repeatable)."
78
+ ),
79
+ ) -> None:
80
+ """Import ``file`` and execute every ``root()`` skill defined at module level."""
81
+ seed = dict(_parse_kv(item) for item in inputs)
82
+ module = _load_module(file)
83
+ roots = [v for v in vars(module).values() if isinstance(v, Root)]
84
+ if not roots:
85
+ typer.secho(f"No skills (root(...)) found in {file}.", fg=typer.colors.YELLOW)
86
+ raise typer.Exit(1)
87
+ for skill in roots:
88
+ typer.secho(f"▶ running {skill.name!r}", fg=typer.colors.CYAN, bold=True)
89
+ ctx = skill.run(**seed)
90
+ if ctx.blackboard:
91
+ typer.echo(f" blackboard: {dict(ctx.blackboard)}")
92
+ for w in ctx.blackboard.clobbers():
93
+ typer.secho(f" ⚠ {w.key!r} overwritten by {w.writer} (was set by another node)",
94
+ fg=typer.colors.YELLOW)
95
+
96
+
97
+ @app.command("show")
98
+ def show(file: Path = typer.Argument(..., exists=True, readable=True, help="A .py file defining skills.")) -> None:
99
+ """Print each skill's structure as an ASCII tree (no execution, no model)."""
100
+ module = _load_module(file)
101
+ roots = [v for v in vars(module).values() if isinstance(v, Root)]
102
+ if not roots:
103
+ typer.secho(f"No skills (root(...)) found in {file}.", fg=typer.colors.YELLOW)
104
+ raise typer.Exit(1)
105
+ for skill in roots:
106
+ typer.echo(render(skill))
107
+
108
+
109
+ def _parse_kv(item: str) -> tuple[str, str]:
110
+ if "=" not in item: raise typer.BadParameter(f"--input must be key=value, got {item!r}.")
111
+ key, value = item.split("=", 1)
112
+ return key.strip(), value
113
+
114
+
115
+ def _load_module(file: Path):
116
+ spec = importlib.util.spec_from_file_location(file.stem, file)
117
+ if spec is None or spec.loader is None: raise typer.BadParameter(f"Cannot import {file}.")
118
+ module = importlib.util.module_from_spec(spec)
119
+ sys.modules[spec.name] = module
120
+ spec.loader.exec_module(module)
121
+ return module
122
+
123
+
124
+ def _mask(key: str) -> str:
125
+ return "*" * len(key) if len(key) <= 8 else f"{key[:4]}…{key[-4:]}"
126
+
127
+
128
+ # -- package commands (§37) ---------------------------------------------------
129
+
130
+ @package_app.command("inspect")
131
+ def package_inspect(path: Path = typer.Argument(..., exists=True, help="A .jdsl or package dir.")) -> None:
132
+ """Show a package's manifest, capabilities, and burden metrics."""
133
+ from jdsl.package import load_package
134
+ pkg = load_package(path)
135
+ m = pkg.manifest
136
+ typer.secho(f"{m.name} v{m.version} [{m.format}]", fg=typer.colors.CYAN, bold=True)
137
+ typer.echo(f" task family: {m.task_family or 'n/a'}")
138
+ typer.echo(f" verification: {m.verification.get('status', 'unverified')} "
139
+ f"(replay coverage {m.verification.get('replay_coverage', 'n/a')})")
140
+ typer.echo(f" fidelity: {m.source.get('capture_fidelity', 'n/a')}, "
141
+ f"episodes: {m.source.get('episode_count', 'n/a')}")
142
+ perms = pkg.permissions()
143
+ typer.secho(" reads: " + (", ".join(perms["reads"]) or "(none)"), fg=typer.colors.GREEN)
144
+ typer.secho(" writes: " + (", ".join(perms["writes"]) or "(none)"),
145
+ fg=typer.colors.RED if perms["writes"] else typer.colors.GREEN)
146
+
147
+
148
+ @package_app.command("verify")
149
+ def package_verify(path: Path = typer.Argument(..., exists=True, help="A .jdsl or package dir.")) -> None:
150
+ """Structurally verify a package and its digests; exit non-zero on failure."""
151
+ from jdsl.package import PackageError, load_package
152
+ try:
153
+ load_package(path) # load_package runs digest + structural verification
154
+ except PackageError as err:
155
+ typer.secho(f"✗ invalid package: {err}", fg=typer.colors.RED, err=True)
156
+ raise typer.Exit(1) from None
157
+ typer.secho("✓ package is structurally valid and digests match.", fg=typer.colors.GREEN)
158
+
159
+
160
+ @package_app.command("run")
161
+ def package_run(
162
+ path: Path = typer.Argument(..., exists=True, help="A .jdsl or package dir."),
163
+ tools: Path = typer.Option(..., "-t", "--tools", exists=True,
164
+ help="A .py file exposing TOOLS (dict) and optional PREDICATES."),
165
+ model: str = typer.Option(None, "-m", "--model", help="Model id for residual leaves."),
166
+ inputs: list[str] = typer.Option([], "-i", "--input", help="Seed blackboard key=value."),
167
+ ) -> None:
168
+ """Bind a package to host tools and run it with a (small) model."""
169
+ from jdsl.package import load_package
170
+ pkg = load_package(path)
171
+ mod = _load_module(tools)
172
+ tool_map = getattr(mod, "TOOLS", None)
173
+ if not isinstance(tool_map, dict):
174
+ typer.secho(f"{tools} must define a TOOLS dict (logical_id -> callable).", fg=typer.colors.RED)
175
+ raise typer.Exit(1)
176
+ predicates = getattr(mod, "PREDICATES", {})
177
+ seed = dict(_parse_kv(item) for item in inputs)
178
+ root = pkg.as_root(tool_map, predicates, model_id=model)
179
+ ctx = root.run(**seed)
180
+ typer.echo(f" blackboard: {dict(ctx.blackboard)}")
181
+
182
+
183
+ # -- capture commands (§37) ---------------------------------------------------
184
+
185
+ @capture_app.command("start")
186
+ def capture_start(
187
+ host: str = typer.Option("host", help="Source host name."),
188
+ adapter: str = typer.Option("hook", help="Capture adapter name."),
189
+ note: str = typer.Option("", help="Optional capture note."),
190
+ ) -> None:
191
+ """Start a token-protected capture and print hook credentials."""
192
+ from jdsl_harness.capture import CaptureCoordinator
193
+ from jdsl_harness.store import HarnessStore
194
+ session = CaptureCoordinator(HarnessStore(_store_root())).start_session(
195
+ host=host, adapter=adapter, note=note)
196
+ typer.echo(f"capture_id={session.capture_id}")
197
+ typer.echo(f"capture_token={session.token}")
198
+ typer.echo("Export both values to the host hook environment.")
199
+
200
+ @capture_app.command("list")
201
+ def capture_list() -> None:
202
+ """List captures in the local harness store."""
203
+ from jdsl_harness.store import HarnessStore
204
+ for c in HarnessStore(_store_root()).list_captures():
205
+ typer.echo(f"{c['capture_id']} {c['status']:9} episodes={c['episodes']} {c.get('note','')}")
206
+
207
+
208
+ @capture_app.command("import")
209
+ def capture_import(
210
+ file: Path = typer.Argument(..., exists=True, readable=True,
211
+ help="JSONL file: one episode per line {episode_id, steps:[...], outcome}."),
212
+ capture_id: str = typer.Option(..., "-c", "--capture", help="Capture id to import into."),
213
+ ) -> None:
214
+ """Import foreign agent logs into a capture (Tier C, §8.3)."""
215
+ from jdsl_harness.adapters.import_jsonl import import_records_file
216
+ from jdsl_harness.store import HarnessStore
217
+ store = HarnessStore(_store_root())
218
+ events = import_records_file(file, capture_id=capture_id)
219
+ for event in events:
220
+ store.ingest(event)
221
+ typer.secho(f"imported {len(events)} events into {capture_id}.", fg=typer.colors.GREEN)
222
+
223
+
224
+ @capture_app.command("inspect")
225
+ def capture_inspect(capture_id: str = typer.Argument(..., help="Capture id.")) -> None:
226
+ """Print the exact-lineage report for a capture (§51)."""
227
+ from jdsl_harness.capture import CaptureCoordinator
228
+ from jdsl_harness.store import HarnessStore
229
+ coord = CaptureCoordinator(HarnessStore(_store_root()))
230
+ typer.echo(json.dumps(coord.lineage_report(capture_id), indent=2, default=str))
231
+
232
+
233
+ @app.command("compile")
234
+ def compile_cmd(
235
+ capture_id: str = typer.Argument(..., help="Capture id to compile."),
236
+ name: str = typer.Option("behavior", "-n", "--name", help="Package name."),
237
+ out: Path = typer.Option(None, "-o", "--out", help="Write a .jdsl to this path."),
238
+ ) -> None:
239
+ """Compile a capture into a verified behavior package (§24)."""
240
+ from jdsl.package import export_jdsl, package_digest
241
+ from jdsl_harness.compiler import compile_behavior
242
+ from jdsl_harness.store import HarnessStore
243
+ store = HarnessStore(_store_root())
244
+ result = compile_behavior(store.capture_episodes(capture_id), name=name)
245
+ typer.echo(json.dumps(result.report(), indent=2, default=str))
246
+ if out is not None:
247
+ path = export_jdsl(result.package, out)
248
+ store.record_package(name, result.package.manifest.version, package_digest(result.package),
249
+ status="built", path=str(path))
250
+ typer.secho(f"→ wrote {path}", fg=typer.colors.GREEN)
251
+
252
+
253
+ # -- harness daemon (§37) -----------------------------------------------------
254
+
255
+ @harness_app.command("serve")
256
+ def harness_serve(
257
+ host: str = typer.Option("127.0.0.1", help="Bind host (loopback by default)."),
258
+ port: int = typer.Option(8848, help="Ingest port."),
259
+ ) -> None:
260
+ """Run the local ingest server (data plane). Ctrl-C to stop."""
261
+ from jdsl_harness.server import IngestServer
262
+ from jdsl_harness.store import HarnessStore
263
+ server = IngestServer(HarnessStore(_store_root()), host=host, port=port).start()
264
+ typer.secho(f"jdsl harness ingest listening on {server.url} (store: {_store_root()})",
265
+ fg=typer.colors.CYAN)
266
+ try:
267
+ import time
268
+ while True:
269
+ time.sleep(3600)
270
+ except KeyboardInterrupt:
271
+ server.stop()
272
+ typer.echo("stopped.")
273
+
274
+
275
+ @app.command("tui")
276
+ def tui() -> None:
277
+ """Open the local skill-authoring workbench."""
278
+ try:
279
+ from jdsl.tui import run
280
+ except ModuleNotFoundError as error:
281
+ if error.name == "textual":
282
+ raise typer.BadParameter("install the TUI with `pip install jdsl[tui]`") from error
283
+ raise
284
+ run()
285
+
286
+
287
+ def _print_install_banner():
288
+ green = "\033[0;32m"
289
+ muted = "\033[0;2m"
290
+ nc = "\033[0m"
291
+ typer.echo(f"{green} _ _ _ _ {nc}")
292
+ typer.echo(f"{green} /\\ \\ /\\ \\ / /\\ _\\ \\ {nc}")
293
+ typer.echo(f"{green} \\ \\ \\ / \\ \\____ / / \\ /\\__ \\ {nc}")
294
+ typer.echo(f"{green} /\\ \\_\\ / /\\ \\_____\\ / / /\\ \\__ / /_ \\_\\ {nc}")
295
+ typer.echo(f"{green} / /\\_// / /\\/___ // / /\\ \\___\\ / / /\\/_/ {nc}")
296
+ typer.echo(f"{green} _ / / / / / / / / / \\ \\ \\ \\/___// / / {nc}")
297
+ typer.echo(f"{green} /\\ \\ / / / / / / / / / \\ \\ \\ / / / {nc}")
298
+ typer.echo(f"{green} \\ \\_\\/ / / / / / / / /_ \\ \\ \\ / / / ____ {nc}")
299
+ typer.echo(f"{green} / / /_/ / / \\ \\__/ / //_\\__/ / / / /_/_/ ___/\\ {nc}")
300
+ typer.echo(f"{green} / / /__\\/ / \\ \\___\\/ / \\ \\/___/ / /_______/\\__/ {nc}")
301
+ typer.echo(f"{green}\\/_______/ \\/_____/ \\_____\\/ \\_______\\/ {nc}")
302
+ typer.echo(f"{muted}Quick start:{nc}")
303
+ typer.echo(" jdsl run examples/greeter.py")
304
+ typer.echo("")
305
+ typer.echo(f"{muted}For API keys:{nc}")
306
+ typer.echo(" echo 'ANTHROPIC_API_KEY=sk-...' >> .env")
307
+ typer.echo("")
308
+ typer.echo(f"{muted}Docs: {nc}https://cantor-industries.github.io/jdsl-py/")
309
+ typer.echo("")
310
+
311
+
312
+ @app.callback()
313
+ def main():
314
+ """Declarative behavior-tree agents.
315
+
316
+ Read docs at https://cantor-industries.github.io/jdsl-py/.
317
+ """
318
+ # After any subcommand runs, show the banner once on first run or via --about
319
+ pass
320
+
321
+
322
+ @app.command("about")
323
+ def about():
324
+ """Show jdsl info and quickstart."""
325
+ typer.secho("jdsl — Declarative behavior-tree agents", fg=typer.colors.CYAN, bold=True)
326
+ _print_install_banner()
327
+
328
+
329
+ @app.command("uninstall")
330
+ def uninstall(
331
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
332
+ ) -> None:
333
+ """Remove the curl-installed CLI while preserving captures and credentials."""
334
+ install_root = Path.home() / ".jdsl"
335
+ if not install_root.exists():
336
+ typer.echo("No curl-installed jdsl CLI found.")
337
+ return
338
+ if not yes and not typer.confirm(f"Remove {install_root} and its PATH entry?", default=False):
339
+ typer.echo("Cancelled.")
340
+ return
341
+
342
+ shutil.rmtree(install_root)
343
+ removed = _remove_install_path_entries()
344
+ typer.secho("Removed the curl-installed jdsl CLI.", fg=typer.colors.GREEN)
345
+ if removed:
346
+ typer.echo("Removed its PATH entry from: " + ", ".join(removed))
347
+ typer.echo("Harness captures and provider credentials were preserved.")
348
+
349
+
350
+ def _remove_install_path_entries() -> list[str]:
351
+ install_dir = Path.home() / ".jdsl" / "bin"
352
+ config_home = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
353
+ shell = Path(os.environ.get("SHELL", "sh")).name
354
+ if shell == "fish":
355
+ files = [Path.home() / ".config/fish/config.fish"]
356
+ elif shell == "zsh":
357
+ zdotdir = Path(os.environ.get("ZDOTDIR") or Path.home())
358
+ files = [zdotdir / ".zshrc", zdotdir / ".zshenv", config_home / "zsh/.zshrc", config_home / "zsh/.zshenv"]
359
+ elif shell == "bash":
360
+ files = [Path.home() / ".bashrc", Path.home() / ".bash_profile", Path.home() / ".profile",
361
+ config_home / "bash/.bashrc", config_home / "bash/.bash_profile"]
362
+ else:
363
+ files = [Path.home() / ".bashrc", Path.home() / ".bash_profile", config_home / "bash/.bashrc",
364
+ config_home / "bash/.bash_profile"]
365
+
366
+ removed: list[str] = []
367
+ path_line = f"export PATH={install_dir}:$PATH"
368
+ fish_line = f"fish_add_path {install_dir}"
369
+ for path in dict.fromkeys(files):
370
+ if not path.is_file():
371
+ continue
372
+ lines = path.read_text(encoding="utf-8").splitlines()
373
+ filtered: list[str] = []
374
+ for line in lines:
375
+ if line.strip() in (path_line, fish_line):
376
+ if filtered and filtered[-1].strip() == "# jdsl":
377
+ filtered.pop()
378
+ continue
379
+ filtered.append(line)
380
+ if filtered != lines:
381
+ path.write_text("\n".join(filtered).rstrip() + "\n", encoding="utf-8")
382
+ removed.append(str(path))
383
+ return removed
384
+
385
+
386
+ if __name__ == "__main__": # pragma: no cover
387
+ app()
jdsl/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Credential storage + provider inference. Keys live in
2
+ ~/.local/share/recon/auth.json as {"<provider>": {"api_keys": [...]}}, and a
3
+ .env in the working directory is loaded on import."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv() # so DEEPSEEK_API_KEY etc. are picked up without exporting
14
+
15
+ SUPPORTED_PROVIDERS = ("anthropic", "openai", "deepseek", "google", "tinker")
16
+
17
+ ENV_KEYS = {
18
+ "anthropic": "ANTHROPIC_API_KEY",
19
+ "openai": "OPENAI_API_KEY",
20
+ "deepseek": "DEEPSEEK_API_KEY",
21
+ "google": "GOOGLE_API_KEY",
22
+ "tinker": "TINKER_API_KEY",
23
+ }
24
+
25
+ # OpenAI-compatible base URLs (Anthropic uses its own SDK, so it's absent).
26
+ BASE_URLS = {
27
+ "deepseek": "https://api.deepseek.com",
28
+ "openai": None,
29
+ "tinker": "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1",
30
+ }
31
+
32
+
33
+ def provider_for_model(model_id: str) -> str:
34
+ name = model_id.lower()
35
+ if name.startswith("claude"): return "anthropic"
36
+ if name.startswith("deepseek"): return "deepseek"
37
+ if name.startswith(("gpt", "o1", "o3", "o4")): return "openai"
38
+ if name.startswith("gemini"): return "google"
39
+ if name.startswith(("thinkingmachines/", "inkling")): return "tinker"
40
+ return "anthropic"
41
+
42
+
43
+ def config_dir() -> Path:
44
+ base = os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")
45
+ return Path(base) / "recon"
46
+
47
+
48
+ def auth_path() -> Path: return config_dir() / "auth.json"
49
+
50
+
51
+ def load() -> dict[str, dict[str, list[str]]]:
52
+ path = auth_path()
53
+ return json.loads(path.read_text()) if path.exists() else {}
54
+
55
+
56
+ def save(config: dict[str, dict[str, list[str]]]) -> None:
57
+ path = auth_path()
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text(json.dumps(config, indent=2))
60
+
61
+
62
+ def add_keys(provider: str, keys: list[str]) -> list[str]:
63
+ """Merge keys into provider's list, de-duplicating. Returns the merged list."""
64
+ if provider not in SUPPORTED_PROVIDERS:
65
+ raise ValueError(f"Unknown provider {provider!r}. Supported: {', '.join(SUPPORTED_PROVIDERS)}.")
66
+ config = load()
67
+ merged = list(config.get(provider, {}).get("api_keys", []))
68
+ for key in keys:
69
+ if key not in merged: merged.append(key)
70
+ config[provider] = {"api_keys": merged}
71
+ save(config)
72
+ return merged
73
+
74
+
75
+ def keys_for(provider: str) -> list[str]:
76
+ """Stored keys for provider, else the provider's env var."""
77
+ stored = load().get(provider, {}).get("api_keys", [])
78
+ if stored: return list(stored)
79
+ env_value = os.environ.get(ENV_KEYS.get(provider, ""))
80
+ return [env_value] if env_value else []