osintkit 0.3.0__tar.gz → 0.4.0__tar.gz

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 (84) hide show
  1. {osintkit-0.3.0 → osintkit-0.4.0}/CHANGELOG.md +9 -0
  2. {osintkit-0.3.0 → osintkit-0.4.0}/PKG-INFO +12 -8
  3. {osintkit-0.3.0 → osintkit-0.4.0}/README.md +11 -7
  4. {osintkit-0.3.0 → osintkit-0.4.0}/docs/REFERENCE.md +13 -0
  5. {osintkit-0.3.0 → osintkit-0.4.0}/examples/README.md +7 -0
  6. {osintkit-0.3.0 → osintkit-0.4.0}/pyproject.toml +1 -1
  7. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/__init__.py +1 -1
  8. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/cli.py +116 -3
  9. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/config.py +7 -0
  10. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/doctor.py +7 -0
  11. osintkit-0.4.0/src/osintkit/userconfig.py +94 -0
  12. {osintkit-0.3.0 → osintkit-0.4.0}/tests/conftest.py +1 -0
  13. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_cli.py +110 -0
  14. osintkit-0.4.0/tests/test_userconfig.py +59 -0
  15. {osintkit-0.3.0 → osintkit-0.4.0}/.editorconfig +0 -0
  16. {osintkit-0.3.0 → osintkit-0.4.0}/.github/CODEOWNERS +0 -0
  17. {osintkit-0.3.0 → osintkit-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {osintkit-0.3.0 → osintkit-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {osintkit-0.3.0 → osintkit-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {osintkit-0.3.0 → osintkit-0.4.0}/.github/dependabot.yml +0 -0
  21. {osintkit-0.3.0 → osintkit-0.4.0}/.github/pull_request_template.md +0 -0
  22. {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/ci.yml +0 -0
  23. {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/codeql.yml +0 -0
  24. {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/publish.yml +0 -0
  25. {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/release.yml +0 -0
  26. {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/smoke.yml +0 -0
  27. {osintkit-0.3.0 → osintkit-0.4.0}/.gitignore +0 -0
  28. {osintkit-0.3.0 → osintkit-0.4.0}/.mailmap +0 -0
  29. {osintkit-0.3.0 → osintkit-0.4.0}/.pre-commit-config.yaml +0 -0
  30. {osintkit-0.3.0 → osintkit-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  31. {osintkit-0.3.0 → osintkit-0.4.0}/CONTRIBUTING.md +0 -0
  32. {osintkit-0.3.0 → osintkit-0.4.0}/LICENSE +0 -0
  33. {osintkit-0.3.0 → osintkit-0.4.0}/SECURITY.md +0 -0
  34. {osintkit-0.3.0 → osintkit-0.4.0}/docs/architecture.md +0 -0
  35. {osintkit-0.3.0 → osintkit-0.4.0}/docs/demo.svg +0 -0
  36. {osintkit-0.3.0 → osintkit-0.4.0}/docs/sample-output.md +0 -0
  37. {osintkit-0.3.0 → osintkit-0.4.0}/docs/social-preview.png +0 -0
  38. {osintkit-0.3.0 → osintkit-0.4.0}/examples/targets.csv +0 -0
  39. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/__init__.py +0 -0
  40. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/accounts.py +0 -0
  41. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/base.py +0 -0
  42. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/breaches.py +0 -0
  43. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/ct.py +0 -0
  44. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/dns.py +0 -0
  45. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/github.py +0 -0
  46. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/pastes.py +0 -0
  47. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/ports.py +0 -0
  48. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/subdomains.py +0 -0
  49. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  50. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/diff.py +0 -0
  51. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/errors.py +0 -0
  52. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/http.py +0 -0
  53. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/llm.py +0 -0
  54. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/logging_store.py +0 -0
  55. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/models.py +0 -0
  56. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/normalise.py +0 -0
  57. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/offline.py +0 -0
  58. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/pipeline.py +0 -0
  59. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/py.typed +0 -0
  60. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/render.py +0 -0
  61. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/retention.py +0 -0
  62. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/synthesis.py +0 -0
  63. {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/targets.py +0 -0
  64. {osintkit-0.3.0 → osintkit-0.4.0}/tests/fixtures/model_reply.json +0 -0
  65. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_accounts.py +0 -0
  66. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_breaches.py +0 -0
  67. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_collectors.py +0 -0
  68. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_config.py +0 -0
  69. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_ct.py +0 -0
  70. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_diff.py +0 -0
  71. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_doctor.py +0 -0
  72. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_e2e.py +0 -0
  73. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_http.py +0 -0
  74. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_llm.py +0 -0
  75. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_normalise.py +0 -0
  76. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_offline.py +0 -0
  77. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_pastes.py +0 -0
  78. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_pipeline.py +0 -0
  79. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_render.py +0 -0
  80. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_retention.py +0 -0
  81. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_synthesis.py +0 -0
  82. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_targets.py +0 -0
  83. {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_whois.py +0 -0
  84. {osintkit-0.3.0 → osintkit-0.4.0}/uv.lock +0 -0
@@ -6,6 +6,15 @@ All notable changes are recorded here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.4.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `osintkit init` stores a provider and key once, verifies them, and needs no flags afterwards
14
+ - `osintkit config` shows values masked, reveals the key, unsets keys, and prints the path
15
+ - stored config participates in provider/key precedence below flags and environment
16
+ - `doctor` reports whether a config file exists
17
+
9
18
  ## 0.3.0 — 2026-11-02
10
19
 
11
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Public-data OSINT aggregator for emails, usernames, domains, and companies
5
5
  Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/osintkit
6
6
  Project-URL: Issues, https://github.com/sjdsjadjajdjsa/osintkit/issues
@@ -74,20 +74,24 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
74
74
  ## Quick start
75
75
 
76
76
  ```bash
77
- export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
78
- osintkit doctor --deep # prove the key and endpoint work
77
+ osintkit init # pick a provider, paste a key once
79
78
  osintkit lookup alice@example.com
80
79
  ```
81
80
 
81
+ `init` stores the provider and key in `~/.config/osintkit/config.toml` (mode 600), checks them
82
+ with a one-token request, and afterwards needs no flags and no exports. `osintkit config` shows
83
+ what is stored, masked; `osintkit lookup` just works.
84
+
85
+ Environment variables still win over the file, so `DEEPSEEK_API_KEY=... osintkit lookup ...`
86
+ overrides the stored provider for one run.
87
+
82
88
  No key? `osintkit lookup example.com --offline` builds the whole report locally from rule checks,
83
89
  no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
84
90
  would receive.
85
91
 
86
- Any OpenAI-compatible endpoint works: export the provider's own variable (`DEEPSEEK_API_KEY`,
87
- `MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
88
- `TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
89
- `ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
90
- preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
92
+ Any OpenAI-compatible endpoint works. `init --provider ollama` needs no key for a local server;
93
+ `--provider`, `--base-url`, and `--model` cover everything else. The preset table, key variables,
94
+ and precedence order are in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
91
95
 
92
96
  Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
93
97
  5000 requests/hour. Everything else needs no credentials.
@@ -42,20 +42,24 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
42
42
  ## Quick start
43
43
 
44
44
  ```bash
45
- export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
46
- osintkit doctor --deep # prove the key and endpoint work
45
+ osintkit init # pick a provider, paste a key once
47
46
  osintkit lookup alice@example.com
48
47
  ```
49
48
 
49
+ `init` stores the provider and key in `~/.config/osintkit/config.toml` (mode 600), checks them
50
+ with a one-token request, and afterwards needs no flags and no exports. `osintkit config` shows
51
+ what is stored, masked; `osintkit lookup` just works.
52
+
53
+ Environment variables still win over the file, so `DEEPSEEK_API_KEY=... osintkit lookup ...`
54
+ overrides the stored provider for one run.
55
+
50
56
  No key? `osintkit lookup example.com --offline` builds the whole report locally from rule checks,
51
57
  no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
52
58
  would receive.
53
59
 
54
- Any OpenAI-compatible endpoint works: export the provider's own variable (`DEEPSEEK_API_KEY`,
55
- `MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
56
- `TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
57
- `ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
58
- preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
60
+ Any OpenAI-compatible endpoint works. `init --provider ollama` needs no key for a local server;
61
+ `--provider`, `--base-url`, and `--model` cover everything else. The preset table, key variables,
62
+ and precedence order are in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
59
63
 
60
64
  Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
61
65
  5000 requests/hour. Everything else needs no credentials.
@@ -61,6 +61,19 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
61
61
  Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
62
62
  `--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
63
63
 
64
+ ## Configuration file
65
+
66
+ `osintkit init` writes `~/.config/osintkit/config.toml` (or `$XDG_CONFIG_HOME`) with mode 600 in
67
+ a 700 directory, after checking the key with a one-token request. Keys recognised:
68
+ `provider`, `model`, `base_url`, `api_key`, `resolvers`.
69
+
70
+ `osintkit config` prints them masked, `--reveal` shows the key, `--unset KEY` removes one, and
71
+ `--path` prints the location. Deleting the file is the same as `--unset` everything.
72
+
73
+ Precedence for each value: command-line flag, then the provider-specific environment variable,
74
+ then a key prefix, then the config file, then the built-in default. Stored values apply only when
75
+ the stored provider matches the one in use, so a one-off key export never gets the stored key.
76
+
64
77
  ## Offline mode
65
78
 
66
79
  `--offline` skips the model: breaches, accounts, hosts, and policy gaps are rendered from the
@@ -43,3 +43,10 @@ No key at all:
43
43
  osintkit lookup --domain example.com --offline
44
44
  osintkit batch --file targets.csv --offline --json
45
45
  ```
46
+
47
+ Set a key once and forget flags:
48
+
49
+ ```bash
50
+ osintkit init
51
+ osintkit lookup --domain example.com
52
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Public-data OSINT aggregator for emails, usernames, domains, and companies"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """osintkit: a public-data OSINT aggregator."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -18,14 +18,17 @@ from rich.console import Console
18
18
  from rich.panel import Panel
19
19
  from rich.table import Table
20
20
 
21
- from . import __version__, diff, doctor, pipeline, render, retention
21
+ from . import __version__, diff, doctor, pipeline, render, retention, userconfig
22
22
  from .collectors import REGISTRY
23
23
  from .config import (
24
+ DEFAULT_RESOLVERS,
24
25
  GENERIC_KEY_ENVS,
26
+ KEYLESS_PROVIDERS,
25
27
  PROVIDERS,
26
28
  Settings,
27
29
  cache_clear,
28
30
  cache_stats,
31
+ env_key_for,
29
32
  provider_from_env,
30
33
  provider_from_key,
31
34
  )
@@ -90,19 +93,24 @@ def root(
90
93
  ) -> None:
91
94
  if provider and provider not in PROVIDERS:
92
95
  raise typer.BadParameter(f"unknown provider {provider!r}; choose from {', '.join(PROVIDERS)}")
96
+ stored = userconfig.load()
93
97
  generic_env = next((os.environ[name] for name in GENERIC_KEY_ENVS if os.environ.get(name)), None)
94
98
  chosen = (
95
99
  provider
96
100
  or provider_from_key(api_key)
97
101
  or provider_from_env()
98
102
  or provider_from_key(generic_env)
103
+ or stored.get("provider")
99
104
  or "venice"
100
105
  )
101
106
  preset = PROVIDERS[chosen]
107
+ if not api_key and stored.get("provider") in (None, chosen):
108
+ api_key = stored.get("api_key")
109
+ config_applies = stored.get("provider") in (None, chosen)
102
110
  settings = Settings(
103
111
  provider=chosen,
104
- base_url=base_url or preset[0],
105
- model=model or preset[1],
112
+ base_url=base_url or (stored.get("base_url") if config_applies else None) or preset[0],
113
+ model=model or (stored.get("model") if config_applies else None) or preset[1],
106
114
  api_key=api_key,
107
115
  temperature=temperature,
108
116
  max_tokens=max_tokens,
@@ -112,6 +120,7 @@ def root(
112
120
  use_cache=not no_cache,
113
121
  cache_ttl=cache_ttl,
114
122
  verbose=verbose,
123
+ resolvers=stored.get("resolvers") or list(DEFAULT_RESOLVERS),
115
124
  )
116
125
  if resolver:
117
126
  for entry in resolver:
@@ -547,6 +556,110 @@ def list_collectors(
547
556
  render.console().print(table)
548
557
 
549
558
 
559
+ @app.command()
560
+ def init(
561
+ provider: str | None = typer.Option(None, "--provider", help="Provider to store."),
562
+ api_key: str | None = typer.Option(None, "--api-key", help="Key to store."),
563
+ model: str | None = typer.Option(None, "--model", help="Default model to store."),
564
+ base_url: str | None = typer.Option(None, "--base-url", help="Custom endpoint to store."),
565
+ force: bool = typer.Option(False, "--force", help="Replace an existing config."),
566
+ verify: bool = typer.Option(True, "--verify/--no-verify", help="Check the key afterwards."),
567
+ ) -> None:
568
+ """Store a provider and key once, then just run lookups."""
569
+ out = _console()
570
+ path = userconfig.config_path()
571
+ if path.exists() and not force:
572
+ out.print(f"[yellow]config already exists[/yellow] {path}; pass --force to replace")
573
+ raise typer.Exit(1)
574
+
575
+ stored = userconfig.load()
576
+ detected = (
577
+ provider
578
+ or provider_from_env()
579
+ or provider_from_key(os.environ.get("API_KEY"))
580
+ or stored.get("provider")
581
+ or "venice"
582
+ )
583
+ chosen = provider or typer.prompt("Provider", default=detected).strip()
584
+ if chosen not in PROVIDERS:
585
+ out.print(f"[red]unknown provider {chosen!r}[/red]; choose from {', '.join(PROVIDERS)}")
586
+ raise typer.Exit(2)
587
+
588
+ data: dict[str, object] = {"provider": chosen}
589
+ if base_url:
590
+ data["base_url"] = base_url
591
+ if model:
592
+ data["model"] = model
593
+
594
+ if chosen not in KEYLESS_PROVIDERS:
595
+ key = (
596
+ api_key
597
+ or env_key_for(chosen)
598
+ or (stored.get("api_key") if stored.get("provider") == chosen else None)
599
+ or typer.prompt("API key", hide_input=True).strip()
600
+ )
601
+ if not key:
602
+ out.print("[red]no key given[/red]")
603
+ raise typer.Exit(2)
604
+ data["api_key"] = key
605
+
606
+ written = userconfig.save(data)
607
+ out.print(f"[green]stored[/green] {written} (mode 600)")
608
+ out.print(f"provider: {chosen}")
609
+ if data.get("api_key"):
610
+ out.print(f"key: {userconfig.mask(str(data['api_key']))}")
611
+
612
+ if verify:
613
+ probe = Settings(
614
+ provider=chosen,
615
+ base_url=str(data.get("base_url") or PROVIDERS[chosen][0]),
616
+ model=str(data.get("model") or PROVIDERS[chosen][1]),
617
+ api_key=str(data["api_key"]) if data.get("api_key") else None,
618
+ use_cache=False,
619
+ )
620
+ checks = asyncio.run(doctor.diagnose(probe, deep=True))
621
+ endpoint = checks[-1]
622
+ if doctor.failed(checks):
623
+ out.print(f"[red]{endpoint.detail}[/red]")
624
+ out.print("[dim]config kept; fix it with osintkit init --force[/dim]")
625
+ raise typer.Exit(1)
626
+ out.print(f"[green]{endpoint.detail}[/green]")
627
+ out.print("[dim]try: osintkit lookup --domain example.com[/dim]")
628
+
629
+
630
+ @app.command("config")
631
+ def config_cmd(
632
+ reveal: bool = typer.Option(False, "--reveal", help="Show the key in full."),
633
+ unset: list[str] | None = typer.Option(None, "--unset", help="Remove a key."),
634
+ path_only: bool = typer.Option(False, "--path", help="Print the config path only."),
635
+ ) -> None:
636
+ """Show, reveal, or clear the stored configuration."""
637
+ out = _console()
638
+ if path_only:
639
+ print(userconfig.config_path())
640
+ return
641
+ stored = userconfig.load()
642
+ if unset:
643
+ for name in unset:
644
+ stored.pop(name, None)
645
+ userconfig.save(stored)
646
+ out.print(f"[green]removed[/green] {', '.join(unset)}")
647
+ stored = userconfig.load()
648
+ if not stored:
649
+ out.print(f"[dim]no config at {userconfig.config_path()}[/dim]")
650
+ return
651
+ out.print(f"[dim]{userconfig.config_path()}[/dim]")
652
+ table = Table(box=box.SIMPLE_HEAD, title="config")
653
+ table.add_column("key")
654
+ table.add_column("value")
655
+ for name, value in stored.items():
656
+ shown: object = value
657
+ if name == "api_key" and isinstance(value, str) and not reveal:
658
+ shown = userconfig.mask(value)
659
+ table.add_row(name, render.escape(str(shown)))
660
+ out.print(table)
661
+
662
+
550
663
  @app.command("doctor")
551
664
  def doctor_cmd(
552
665
  json_out: bool = typer.Option(False, "--json", help="JSON output."),
@@ -60,6 +60,13 @@ def provider_from_key(api_key: str | None) -> str | None:
60
60
  return None
61
61
 
62
62
 
63
+ def env_key_for(provider: str) -> str | None:
64
+ for variable, name in PROVIDER_ENV.items():
65
+ if name == provider and os.environ.get(variable):
66
+ return os.environ[variable]
67
+ return None
68
+
69
+
63
70
  def provider_from_env() -> str | None:
64
71
  """Infer a provider from whichever provider-specific variable is set."""
65
72
  for variable, provider in PROVIDER_ENV.items():
@@ -7,6 +7,7 @@ import tempfile
7
7
  from dataclasses import dataclass, replace
8
8
  from pathlib import Path
9
9
 
10
+ from . import userconfig
10
11
  from .config import PROVIDER_ENV, Settings, cache_dir, ensure_dirs, log_dir
11
12
  from .http import HttpClient
12
13
 
@@ -102,8 +103,14 @@ def collect_checks(settings: Settings) -> list[Check]:
102
103
  key_check = Check("api key", "ok", f"from {source}")
103
104
  else:
104
105
  key_check = Check("api key", "warn", f"no key for {settings.provider}; set one or use --offline")
106
+ config = userconfig.config_path()
105
107
  checks = [
106
108
  Check("provider", "ok", settings.provider),
109
+ Check(
110
+ "config file",
111
+ "ok" if config.exists() else "warn",
112
+ str(config) if config.exists() else f"none at {config}; run osintkit init",
113
+ ),
107
114
  key_check,
108
115
  Check(
109
116
  "cache dir",
@@ -0,0 +1,94 @@
1
+ """Persistent user configuration at ~/.config/osintkit/config.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ FILENAME = "config.toml"
10
+ KEYS = ("provider", "model", "base_url", "api_key", "resolvers")
11
+ SECRET_KEYS = frozenset({"api_key"})
12
+
13
+
14
+ def config_home() -> Path:
15
+ override = os.environ.get("XDG_CONFIG_HOME")
16
+ base = Path(override).expanduser() if override else Path.home() / ".config"
17
+ return base / "osintkit"
18
+
19
+
20
+ def config_path() -> Path:
21
+ return config_home() / FILENAME
22
+
23
+
24
+ def _parse(text: str) -> dict[str, Any]:
25
+ import tomllib
26
+
27
+ try:
28
+ data = tomllib.loads(text)
29
+ except (tomllib.TOMLDecodeError, ValueError):
30
+ return {}
31
+ if not isinstance(data, dict):
32
+ return {}
33
+ return data
34
+
35
+
36
+ def load() -> dict[str, Any]:
37
+ try:
38
+ raw = config_path().read_text("utf-8")
39
+ except OSError:
40
+ return {}
41
+ data = _parse(raw)
42
+ out: dict[str, Any] = {}
43
+ for key in KEYS:
44
+ value = data.get(key)
45
+ if isinstance(value, str) and value:
46
+ out[key] = value
47
+ elif key == "resolvers" and isinstance(value, list):
48
+ out[key] = [str(item) for item in value if item]
49
+ return out
50
+
51
+
52
+ def _quote(value: str) -> str:
53
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
54
+
55
+
56
+ def render(data: dict[str, Any]) -> str:
57
+ lines = ["# osintkit configuration", ""]
58
+ for key in KEYS:
59
+ value = data.get(key)
60
+ if not value:
61
+ continue
62
+ if isinstance(value, list):
63
+ rendered = ", ".join(_quote(item) for item in value)
64
+ lines.append(f"{key} = [{rendered}]")
65
+ else:
66
+ lines.append(f"{key} = {_quote(str(value))}")
67
+ return "\n".join(lines) + "\n"
68
+
69
+
70
+ def save(data: dict[str, Any]) -> Path:
71
+ home = config_home()
72
+ home.mkdir(parents=True, exist_ok=True)
73
+ try:
74
+ os.chmod(home, 0o700)
75
+ except OSError:
76
+ pass
77
+ path = config_path()
78
+ tmp = path.with_suffix(".tmp")
79
+ tmp.write_text(render(data), "utf-8")
80
+ os.chmod(tmp, 0o600)
81
+ tmp.replace(path)
82
+ return path
83
+
84
+
85
+ def mask(secret: str) -> str:
86
+ if len(secret) <= 8:
87
+ return "*" * len(secret)
88
+ return f"{secret[:6]}…{secret[-4:]}"
89
+
90
+
91
+ def as_settings(data: dict[str, Any]) -> dict[str, Any]:
92
+ """Map config keys onto Settings field names."""
93
+ mapping = {"base_url": "base_url", "model": "model", "resolvers": "resolvers"}
94
+ return {target: data[source] for source, target in mapping.items() if data.get(source)}
@@ -12,6 +12,7 @@ from osintkit.http import HttpClient
12
12
  def isolated_home(tmp_path: Path, monkeypatch):
13
13
  home = tmp_path / "home"
14
14
  monkeypatch.setenv("OSINTKIT_HOME", str(home))
15
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config"))
15
16
  monkeypatch.delenv("API_KEY", raising=False)
16
17
  monkeypatch.delenv("OPENAI_API_KEY", raising=False)
17
18
  monkeypatch.delenv("HIBP_API_KEY", raising=False)
@@ -508,3 +508,113 @@ def test_ollama_needs_no_key(monkeypatch):
508
508
  result = runner.invoke(app, ["--provider", "ollama", "lookup", "--domain", "a.com", "--json"])
509
509
  assert result.exit_code == 0
510
510
  assert jsonish(result.output)["base_url"] == "http://localhost:11434/v1"
511
+
512
+
513
+ @pytest.fixture
514
+ def xdg(monkeypatch, tmp_path):
515
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
516
+
517
+
518
+ def test_init_writes_config_and_verifies(monkeypatch, xdg):
519
+ import httpx
520
+ import respx
521
+
522
+ from osintkit import userconfig
523
+
524
+ with respx.mock:
525
+ respx.get("https://api.deepseek.com/v1/models").mock(return_value=httpx.Response(200, json={}))
526
+ respx.post("https://api.deepseek.com/v1/chat/completions").mock(
527
+ return_value=httpx.Response(200, json={"choices": [{"message": {"content": "p"}}]})
528
+ )
529
+ result = runner.invoke(app, ["init", "--provider", "deepseek", "--api-key", "sk-" + "d" * 30])
530
+ assert result.exit_code == 0
531
+ stored = userconfig.load()
532
+ assert stored["provider"] == "deepseek"
533
+ assert "try: osintkit lookup" in result.output
534
+
535
+
536
+ def test_init_refuses_existing_without_force(xdg):
537
+ from osintkit import userconfig
538
+
539
+ userconfig.save({"provider": "venice"})
540
+ result = runner.invoke(app, ["init", "--api-key", "k" * 30, "--no-verify"])
541
+ assert result.exit_code == 1
542
+ assert "already exists" in result.output
543
+
544
+
545
+ def test_init_force_replaces(xdg):
546
+ from osintkit import userconfig
547
+
548
+ userconfig.save({"provider": "venice", "api_key": "old"})
549
+ result = runner.invoke(
550
+ app, ["init", "--provider", "mistral", "--api-key", "m" * 30, "--force", "--no-verify"]
551
+ )
552
+ assert result.exit_code == 0
553
+ assert userconfig.load()["provider"] == "mistral"
554
+
555
+
556
+ def test_init_unknown_provider(xdg):
557
+ result = runner.invoke(app, ["init", "--provider", "nope", "--api-key", "k", "--no-verify"])
558
+ assert result.exit_code == 2
559
+ assert "unknown provider" in result.output
560
+
561
+
562
+ def test_init_ollama_needs_no_key(xdg, monkeypatch):
563
+ from osintkit import userconfig
564
+
565
+ monkeypatch.delenv("API_KEY", raising=False)
566
+ result = runner.invoke(app, ["init", "--provider", "ollama", "--no-verify"])
567
+ assert result.exit_code == 0
568
+ assert "api_key" not in userconfig.load()
569
+
570
+
571
+ def test_config_masks_and_reveals(xdg):
572
+ from osintkit import userconfig
573
+
574
+ userconfig.save({"provider": "venice", "api_key": "sk-venice-abcdef123456"})
575
+ masked = runner.invoke(app, ["config"])
576
+ assert "sk-ven…3456" in masked.output
577
+ assert "abcdef123456" not in masked.output
578
+ revealed = runner.invoke(app, ["config", "--reveal"])
579
+ assert "sk-venice-abcdef123456" in revealed.output
580
+
581
+
582
+ def test_config_unset_and_path(xdg):
583
+ from osintkit import userconfig
584
+
585
+ userconfig.save({"provider": "venice", "model": "m"})
586
+ removed = runner.invoke(app, ["config", "--unset", "model"])
587
+ assert removed.exit_code == 0
588
+ assert "model" not in userconfig.load()
589
+ path = runner.invoke(app, ["config", "--path"])
590
+ assert str(userconfig.config_path()) in path.output
591
+
592
+
593
+ def test_stored_key_is_used(monkeypatch, xdg):
594
+ from osintkit import userconfig
595
+
596
+ userconfig.save({"provider": "venice", "api_key": "stored-key-123"})
597
+ monkeypatch.delenv("API_KEY")
598
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
599
+ result = runner.invoke(app, ["lookup", "--domain", "a.com", "--json"])
600
+ assert result.exit_code == 0
601
+ assert jsonish(result.output)["base_url"] == "https://api.venice.ai/api/v1"
602
+
603
+
604
+ def test_env_beats_stored_provider(monkeypatch, xdg):
605
+ from osintkit import userconfig
606
+
607
+ userconfig.save({"provider": "venice", "api_key": "stored"})
608
+ monkeypatch.setenv("MISTRAL_API_KEY", "m" * 30)
609
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
610
+ result = runner.invoke(app, ["lookup", "--domain", "a.com", "--json"])
611
+ assert jsonish(result.output)["base_url"] == "https://api.mistral.ai/v1"
612
+
613
+
614
+ def test_flag_beats_stored_config(monkeypatch, xdg):
615
+ from osintkit import userconfig
616
+
617
+ userconfig.save({"provider": "venice", "api_key": "stored", "model": "stored-model"})
618
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
619
+ result = runner.invoke(app, ["--model", "flag-model", "lookup", "--domain", "a.com", "--json"])
620
+ assert jsonish(result.output)["model"] == "flag-model"
@@ -0,0 +1,59 @@
1
+ import os
2
+ import stat
3
+
4
+ import pytest
5
+
6
+ from osintkit import userconfig
7
+
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def xdg(monkeypatch, tmp_path):
11
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
12
+
13
+
14
+ def test_roundtrip_and_permissions():
15
+ path = userconfig.save({"provider": "deepseek", "api_key": "sk-test-123456"})
16
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
17
+ loaded = userconfig.load()
18
+ assert loaded["provider"] == "deepseek"
19
+ assert loaded["api_key"] == "sk-test-123456"
20
+
21
+
22
+ def test_render_quotes_specials():
23
+ text = userconfig.render({"api_key": 'we"ird\\key', "resolvers": ["1.1.1.1"]})
24
+ assert 'api_key = "we\\"ird\\\\key"' in text
25
+ assert 'resolvers = ["1.1.1.1"]' in text
26
+
27
+
28
+ def test_load_missing_is_empty():
29
+ assert userconfig.load() == {}
30
+
31
+
32
+ def test_load_malformed_is_empty():
33
+ path = userconfig.config_path()
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ path.write_text("not toml at all = [", "utf-8")
36
+ assert userconfig.load() == {}
37
+
38
+
39
+ def test_unknown_keys_are_dropped():
40
+ path = userconfig.config_path()
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ path.write_text('provider = "x"\nbogus = "y"\n', "utf-8")
43
+ assert userconfig.load() == {"provider": "x"}
44
+
45
+
46
+ def test_mask_short_and_long():
47
+ assert userconfig.mask("short") == "*****"
48
+ assert userconfig.mask("sk-or-abcdef123456") == "sk-or-…3456"
49
+
50
+
51
+ def test_config_home_follows_xdg(monkeypatch, tmp_path):
52
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
53
+ assert str(tmp_path) in str(userconfig.config_path())
54
+
55
+
56
+ def test_save_creates_private_directory(tmp_path):
57
+ path = userconfig.save({"provider": "venice"})
58
+ assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
59
+ assert os.access(path, os.R_OK)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes