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.
- {osintkit-0.3.0 → osintkit-0.4.0}/CHANGELOG.md +9 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/PKG-INFO +12 -8
- {osintkit-0.3.0 → osintkit-0.4.0}/README.md +11 -7
- {osintkit-0.3.0 → osintkit-0.4.0}/docs/REFERENCE.md +13 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/examples/README.md +7 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/pyproject.toml +1 -1
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/__init__.py +1 -1
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/cli.py +116 -3
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/config.py +7 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/doctor.py +7 -0
- osintkit-0.4.0/src/osintkit/userconfig.py +94 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/conftest.py +1 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_cli.py +110 -0
- osintkit-0.4.0/tests/test_userconfig.py +59 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.editorconfig +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/CODEOWNERS +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/dependabot.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/pull_request_template.md +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/ci.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/codeql.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/publish.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/release.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.github/workflows/smoke.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.gitignore +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.mailmap +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/.pre-commit-config.yaml +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/CONTRIBUTING.md +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/LICENSE +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/SECURITY.md +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/docs/architecture.md +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/docs/demo.svg +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/docs/sample-output.md +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/docs/social-preview.png +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/examples/targets.csv +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/__init__.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/accounts.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/base.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/breaches.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/ct.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/dns.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/github.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/pastes.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/ports.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/subdomains.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/collectors/whois_lookup.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/diff.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/errors.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/http.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/llm.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/logging_store.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/models.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/normalise.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/offline.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/pipeline.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/py.typed +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/render.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/retention.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/synthesis.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/src/osintkit/targets.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/fixtures/model_reply.json +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_accounts.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_breaches.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_collectors.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_config.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_ct.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_diff.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_doctor.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_e2e.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_http.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_llm.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_normalise.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_offline.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_pastes.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_pipeline.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_render.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_retention.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_synthesis.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_targets.py +0 -0
- {osintkit-0.3.0 → osintkit-0.4.0}/tests/test_whois.py +0 -0
- {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
|
+
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|