devguard-scanner 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 (101) hide show
  1. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/CHANGELOG.md +10 -0
  2. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/PKG-INFO +1 -1
  3. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/__init__.py +1 -1
  4. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/registry.py +8 -1
  5. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/cli.py +44 -0
  6. devguard_scanner-0.4.0/src/devguard/core/config_init.py +167 -0
  7. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_report_cli.py +51 -0
  8. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/.gitignore +0 -0
  9. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/LICENSE +0 -0
  10. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/README.md +0 -0
  11. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/docs/README.md +0 -0
  12. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/pyproject.toml +0 -0
  13. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/__init__.py +0 -0
  14. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/investigator.py +0 -0
  15. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/prompts.py +0 -0
  16. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/providers/__init__.py +0 -0
  17. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/providers/anthropic.py +0 -0
  18. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/providers/openai_compat.py +0 -0
  19. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/run.py +0 -0
  20. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/sdk.py +0 -0
  21. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/testing.py +0 -0
  22. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/ai/tools.py +0 -0
  23. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/__init__.py +0 -0
  24. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/base.py +0 -0
  25. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/carriers.py +0 -0
  26. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/command.py +0 -0
  27. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/content.py +0 -0
  28. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/filesystem.py +0 -0
  29. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/hidden.py +0 -0
  30. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/imports.py +0 -0
  31. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/jsonc.py +0 -0
  32. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/source.py +0 -0
  33. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/__init__.py +0 -0
  34. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/ai_assistants.py +0 -0
  35. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/autoload.py +0 -0
  36. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/common.py +0 -0
  37. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/deps.py +0 -0
  38. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/devcontainer.py +0 -0
  39. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/env.py +0 -0
  40. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/git.py +0 -0
  41. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/npm.py +0 -0
  42. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/python_pkg.py +0 -0
  43. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/vscode.py +0 -0
  44. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/__init__.py +0 -0
  45. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/app.py +0 -0
  46. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/executor.py +0 -0
  47. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/limits.py +0 -0
  48. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/local.py +0 -0
  49. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/schemas.py +0 -0
  50. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/settings.py +0 -0
  51. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/traffic.py +0 -0
  52. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/api/worker.py +0 -0
  53. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/__init__.py +0 -0
  54. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/config.py +0 -0
  55. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/filetype.py +0 -0
  56. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/models.py +0 -0
  57. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/redact.py +0 -0
  58. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/safefs.py +0 -0
  59. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/scoring.py +0 -0
  60. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/core/service.py +0 -0
  61. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/correlate/__init__.py +0 -0
  62. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/correlate/chains.py +0 -0
  63. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/mcp/__init__.py +0 -0
  64. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/mcp/server.py +0 -0
  65. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/remote/__init__.py +0 -0
  66. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/remote/archive.py +0 -0
  67. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/remote/errors.py +0 -0
  68. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/remote/fetch.py +0 -0
  69. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/remote/github.py +0 -0
  70. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/remote/url.py +0 -0
  71. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/report/__init__.py +0 -0
  72. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/report/escape.py +0 -0
  73. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/report/markdown.py +0 -0
  74. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/report/sarif.py +0 -0
  75. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/report/terminal.py +0 -0
  76. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/report/writer.py +0 -0
  77. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/__init__.py +0 -0
  78. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/catalog.py +0 -0
  79. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/ai_instructions.yaml +0 -0
  80. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/cross_language.yaml +0 -0
  81. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/docs.yaml +0 -0
  82. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/javascript.yaml +0 -0
  83. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/obfuscation.yaml +0 -0
  84. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/python.yaml +0 -0
  85. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/shell.yaml +0 -0
  86. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/windows.yaml +0 -0
  87. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/src/devguard/rules/data/vscode_settings.yaml +0 -0
  88. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/__init__.py +0 -0
  89. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/conftest.py +0 -0
  90. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/corpus/__init__.py +0 -0
  91. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/corpus/samples.py +0 -0
  92. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/github_fakes.py +0 -0
  93. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_ai.py +0 -0
  94. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_api.py +0 -0
  95. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_corpus.py +0 -0
  96. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_mcp.py +0 -0
  97. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_parsers.py +0 -0
  98. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_remote.py +0 -0
  99. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_rules.py +0 -0
  100. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_safefs.py +0 -0
  101. {devguard_scanner-0.3.0 → devguard_scanner-0.4.0}/tests/test_safety.py +0 -0
@@ -3,6 +3,16 @@
3
3
  All notable changes are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/) and
4
4
  the project uses [semantic versioning](https://semver.org).
5
5
 
6
+ ## 0.4.0
7
+
8
+ - `devguard config init` writes the user config with an AI profile: numbered choices on a terminal (Enter takes
9
+ the default), or the defaults and flags with `-y` or without a terminal (`--provider anthropic|openai|local`,
10
+ `--model`, `--key-env`, `--base-url`, `--path`, `--force`). `devguard config path` prints the file location.
11
+ - With a single AI profile, `--ai` uses it without `default_profile`. Without any profile, the error points to
12
+ `devguard config init`.
13
+ - Container images are published on every push to `main` (`:main`, `:sha-<commit>`), and the hosted API on Vercel
14
+ is deployed from CI with that exact image digest.
15
+
6
16
  ## 0.3.0
7
17
 
8
18
  - The default drop folder for local scans (`DEVGUARD_API_LOCAL_ROOT`, and the folder `compose.yaml` mounts) is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devguard-scanner
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Scan untrusted code repositories for malware before you open, install, or run them. Files are analyzed as data and never executed.
5
5
  Project-URL: Homepage, https://github.com/yilmazhasan/devguard-scanner
6
6
  Project-URL: Documentation, https://github.com/yilmazhasan/devguard-scanner/tree/main/docs
@@ -1,5 +1,5 @@
1
1
  """DevGuard: pre-open security scanner for untrusted code workspaces."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
  RULESET_VERSION = "2026.10.0"
5
5
  SCHEMA_VERSION = "1"
@@ -101,8 +101,15 @@ def load_provider(
101
101
  ) -> LoadedProvider:
102
102
  """Load a profile's provider. `secrets` supplies already-resolved values (an API caller's own key)."""
103
103
  name = profile_name or cfg.default_profile
104
+ if not name and len(cfg.profiles) == 1:
105
+ name = next(iter(cfg.profiles))
104
106
  if not name:
105
- raise ProviderError("no AI profile selected: pass --ai-profile or set ai.default_profile in your user config")
107
+ if not cfg.profiles:
108
+ raise ProviderError("no AI profile configured: run `devguard config init` to create one")
109
+ raise ProviderError(
110
+ f"several AI profiles ({', '.join(sorted(cfg.profiles))}) and no default: pass --ai-profile NAME "
111
+ "or set ai.default_profile in your config"
112
+ )
106
113
  profile = cfg.profiles.get(name)
107
114
  if profile is None:
108
115
  raise ProviderError(f"unknown AI profile {name!r}; configured: {', '.join(sorted(cfg.profiles)) or 'none'}")
@@ -108,6 +108,21 @@ def _parser() -> argparse.ArgumentParser:
108
108
  sv.add_argument("--port", type=int, help="Port (default: $PORT or 8000).")
109
109
  sv.add_argument("--profile", choices=["public", "selfhost", "dev"], help="Limits profile (default: selfhost).")
110
110
 
111
+ c = sub.add_parser("config", help="Create or locate the user config file.")
112
+ csub = c.add_subparsers(dest="config_command", required=True)
113
+ ci = csub.add_parser(
114
+ "init",
115
+ help="Write a config with an AI profile (asks on a terminal; -y or no terminal: defaults/flags).",
116
+ )
117
+ ci.add_argument("--provider", choices=["anthropic", "openai", "local"])
118
+ ci.add_argument("--model", help="Model id (default: claude-opus-5-5, gpt-4o, or llama3.1).")
119
+ ci.add_argument("--key-env", help="Environment variable that holds the API key (default: the provider's usual).")
120
+ ci.add_argument("--base-url", help="Server URL for --provider local (default: Ollama on localhost).")
121
+ ci.add_argument("--path", type=Path, help="Where to write it (default: the user config file).")
122
+ ci.add_argument("--force", action="store_true", help="Overwrite an existing file (kept as .bak).")
123
+ ci.add_argument("-y", "--yes", action="store_true", help="Do not ask; use the flags and defaults.")
124
+ csub.add_parser("path", help="Print the user config file path.")
125
+
111
126
  sub.add_parser("doctor", help="Check the installation and configuration.")
112
127
  return p
113
128
 
@@ -131,6 +146,8 @@ def main(argv: list[str] | None = None) -> int:
131
146
  return serve(roots, expose_investigation=args.expose_investigation_tools, config=cfg)
132
147
  if args.command == "serve":
133
148
  return _serve(args)
149
+ if args.command == "config":
150
+ return _config(args)
134
151
  if args.command == "doctor":
135
152
  return _doctor()
136
153
  except ConfigError as exc:
@@ -337,6 +354,33 @@ def _ai(args: argparse.Namespace) -> int:
337
354
  return EXIT_OK if report.ok else EXIT_FINDINGS
338
355
 
339
356
 
357
+ def _config(args: argparse.Namespace) -> int:
358
+ from devguard.core.config import user_config_file
359
+
360
+ if args.config_command == "path":
361
+ path = user_config_file()
362
+ print(path if path.is_file() else f"{path} (not created yet: run `devguard config init`)")
363
+ return EXIT_OK
364
+ from devguard.core.config_init import run_init
365
+
366
+ result = run_init(
367
+ path=args.path,
368
+ provider=args.provider,
369
+ model=args.model,
370
+ key_env=args.key_env,
371
+ base_url=args.base_url,
372
+ force=args.force,
373
+ assume_defaults=args.yes,
374
+ )
375
+ print(f"\nWrote {result.path} (profile {result.choice.profile!r}, {result.choice.label}).")
376
+ print("Next:")
377
+ if result.key_env and not os.environ.get(result.key_env):
378
+ print(f" export {result.key_env}=... # your API key; it is not stored in the file")
379
+ print(" devguard ai test # checks the profile with synthetic data only")
380
+ print(" devguard scan ./repo --ai")
381
+ return EXIT_OK
382
+
383
+
340
384
  def _doctor() -> int:
341
385
  import importlib.util
342
386
  import platform
@@ -0,0 +1,167 @@
1
+ """`devguard config init`: write a user config with one AI profile.
2
+
3
+ Interactive on a terminal (numbered choices, Enter takes the default); with `--yes` or without a terminal it
4
+ writes the defaults, or whatever the flags say. The API key itself is never written: the profile references
5
+ an environment variable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from devguard.core.config import ConfigError, user_config_dir
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Choice:
23
+ key: str
24
+ label: str
25
+ provider: str
26
+ profile: str
27
+ model: str
28
+ key_env: str | None = None
29
+ base_url: str | None = None
30
+
31
+
32
+ CHOICES = [
33
+ Choice("anthropic", "Claude (Anthropic)", "anthropic", "claude", "claude-opus-5-5", key_env="ANTHROPIC_API_KEY"),
34
+ Choice("openai", "OpenAI", "openai", "gpt", "gpt-4o", key_env="OPENAI_API_KEY"),
35
+ Choice(
36
+ "local",
37
+ "Local model via Ollama (code stays on this machine)",
38
+ "openai-compatible",
39
+ "local",
40
+ "llama3.1",
41
+ base_url="http://localhost:11434/v1",
42
+ ),
43
+ ]
44
+ BY_KEY = {c.key: c for c in CHOICES}
45
+ _MODEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,99}$")
46
+ _ENV = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
47
+
48
+
49
+ def default_choice() -> Choice:
50
+ """Prefer the provider whose key is already in the environment."""
51
+ for c in CHOICES:
52
+ if c.key_env and os.environ.get(c.key_env):
53
+ return c
54
+ return CHOICES[0]
55
+
56
+
57
+ def render(choice: Choice, model: str, key_env: str | None, base_url: str | None) -> str:
58
+ q = json.dumps # a JSON string of these validated values is a valid TOML basic string
59
+ lines = [
60
+ "# DevGuard user configuration, written by `devguard config init`.",
61
+ "# All options: https://github.com/yilmazhasan/devguard-scanner/blob/main/docs/ai.md",
62
+ "",
63
+ "[ai]",
64
+ f"default_profile = {q(choice.profile)}",
65
+ "# max_cost_usd = 1.0 # per scan; enforced when the cost is known (see price_* below)",
66
+ "# send_excerpts = true # false: send finding metadata only, no code excerpts",
67
+ "",
68
+ f"[ai.profiles.{choice.profile}]",
69
+ f"provider = {q(choice.provider)}",
70
+ f"model = {q(model)}",
71
+ ]
72
+ if base_url:
73
+ lines.append(f"base_url = {q(base_url)}")
74
+ if key_env:
75
+ lines.append(f"secrets = {{ api_key = {q('env:' + key_env)} }} # the key is read from ${key_env}")
76
+ if choice.key == "local":
77
+ lines.append("data_stays_local = true # no consent prompt; nothing leaves this machine")
78
+ if choice.provider == "openai":
79
+ lines += [
80
+ "# USD per 1M tokens for this model, so max_cost_usd is enforced:",
81
+ "# price_input_per_mtok = 0.0",
82
+ "# price_output_per_mtok = 0.0",
83
+ ]
84
+ return "\n".join(lines) + "\n"
85
+
86
+
87
+ @dataclass
88
+ class InitResult:
89
+ path: Path
90
+ choice: Choice
91
+ key_env: str | None
92
+
93
+
94
+ def run_init(
95
+ *,
96
+ path: Path | None,
97
+ provider: str | None,
98
+ model: str | None,
99
+ key_env: str | None,
100
+ base_url: str | None,
101
+ force: bool,
102
+ assume_defaults: bool,
103
+ ask: Callable[[str], str] = input,
104
+ out: Callable[[str], None] = print,
105
+ ) -> InitResult:
106
+ interactive = not assume_defaults and sys.stdin.isatty()
107
+ target = (path or user_config_dir() / "config.toml").expanduser()
108
+
109
+ def prompt(question: str, default: str) -> str:
110
+ if not interactive:
111
+ return default
112
+ try:
113
+ answer = ask(f"{question} [{default}]: ").strip()
114
+ except EOFError:
115
+ answer = ""
116
+ return answer or default
117
+
118
+ if target.is_dir():
119
+ raise ConfigError(
120
+ f"{target} is a directory (Docker creates one when you mount a config file that does not exist yet). "
121
+ "Remove it, then run this again."
122
+ )
123
+ if target.exists() and not force:
124
+ if not interactive:
125
+ raise ConfigError(f"{target} already exists; pass --force to overwrite it")
126
+ answer = prompt(f"{target} exists. Overwrite it (the old file is kept as {target.name}.bak)? y/n", "n")
127
+ if answer.lower() not in ("y", "yes"):
128
+ raise ConfigError(f"{target} was not changed")
129
+
130
+ if provider is not None:
131
+ if provider not in BY_KEY:
132
+ raise ConfigError(f"unknown provider {provider!r}; choose one of {', '.join(BY_KEY)}")
133
+ choice = BY_KEY[provider]
134
+ else:
135
+ default = default_choice()
136
+ choice = default
137
+ if interactive:
138
+ out("Which AI provider?")
139
+ for i, c in enumerate(CHOICES, 1):
140
+ out(f" {i}) {c.label}{' (default)' if c is default else ''}")
141
+ while True:
142
+ picked = prompt(f"Choose 1-{len(CHOICES)}", str(CHOICES.index(default) + 1))
143
+ if picked.isdigit() and 1 <= int(picked) <= len(CHOICES):
144
+ choice = CHOICES[int(picked) - 1]
145
+ break
146
+ out(f" Please enter a number from 1 to {len(CHOICES)}.")
147
+
148
+ model = model or prompt("Model", choice.model)
149
+ if not _MODEL.match(model):
150
+ raise ConfigError(f"invalid model id {model!r}")
151
+ if choice.key_env:
152
+ key_env = key_env or prompt("Environment variable that holds the API key", choice.key_env)
153
+ if not _ENV.match(key_env):
154
+ raise ConfigError(f"invalid environment variable name {key_env!r}")
155
+ else:
156
+ key_env = None
157
+ if choice.base_url:
158
+ base_url = base_url or prompt("Server URL", choice.base_url)
159
+ else:
160
+ base_url = None
161
+
162
+ text = render(choice, model, key_env, base_url)
163
+ target.parent.mkdir(parents=True, exist_ok=True)
164
+ if target.exists():
165
+ target.with_name(target.name + ".bak").write_bytes(target.read_bytes())
166
+ target.write_text(text, encoding="utf-8")
167
+ return InitResult(target, choice, key_env)
@@ -121,6 +121,7 @@ def test_example_config_is_valid(tmp_path: Path) -> None:
121
121
  def test_user_config_falls_back_to_legacy_dir(tmp_path, monkeypatch) -> None:
122
122
  from devguard.core.config import user_config_file
123
123
 
124
+ monkeypatch.setattr("sys.platform", "linux") # XDG_CONFIG_HOME only applies there
124
125
  monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
125
126
  legacy = tmp_path / "devguard" / "config.toml"
126
127
  legacy.parent.mkdir()
@@ -130,3 +131,53 @@ def test_user_config_falls_back_to_legacy_dir(tmp_path, monkeypatch) -> None:
130
131
  current.parent.mkdir()
131
132
  current.write_text("[scan]\n")
132
133
  assert user_config_file() == current
134
+
135
+
136
+ def test_config_init_defaults_write_a_loadable_profile(tmp_path, monkeypatch) -> None:
137
+ import tomllib
138
+
139
+ from devguard.ai.registry import AIConfig
140
+
141
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
142
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test") # the provider with a key set is the default
143
+ path = tmp_path / "cfg" / "config.toml"
144
+ assert main(["config", "init", "-y", "--path", str(path)]) == 0
145
+ cfg = AIConfig.model_validate(tomllib.loads(path.read_text())["ai"])
146
+ assert cfg.default_profile == "gpt"
147
+ assert cfg.profiles["gpt"].secrets == {"api_key": "env:OPENAI_API_KEY"}
148
+ assert "sk-test" not in path.read_text()
149
+ # an existing file is kept unless --force; --force leaves a backup
150
+ assert main(["config", "init", "-y", "--path", str(path), "--provider", "local"]) == 2
151
+ assert main(["config", "init", "-y", "--path", str(path), "--provider", "local", "--force"]) == 0
152
+ assert "data_stays_local = true" in path.read_text()
153
+ assert "OPENAI_API_KEY" in (tmp_path / "cfg" / "config.toml.bak").read_text()
154
+
155
+
156
+ def test_config_init_interactive_choices(tmp_path, monkeypatch) -> None:
157
+ from devguard.core.config_init import run_init
158
+
159
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
160
+ answers = iter(["9", "1", "", "MY_KEY"]) # invalid pick, Claude, default model, custom key variable
161
+ result = run_init(
162
+ path=tmp_path / "config.toml",
163
+ provider=None,
164
+ model=None,
165
+ key_env=None,
166
+ base_url=None,
167
+ force=False,
168
+ assume_defaults=False,
169
+ ask=lambda _q: next(answers),
170
+ out=lambda _s: None,
171
+ )
172
+ text = result.path.read_text()
173
+ assert 'model = "claude-opus-5-5"' in text and '"env:MY_KEY"' in text
174
+
175
+
176
+ def test_single_profile_needs_no_default() -> None:
177
+ from devguard.ai.registry import AIConfig, ProviderError, load_provider
178
+
179
+ one = AIConfig.model_validate({"profiles": {"only": {"provider": "nonexistent-provider"}}})
180
+ with pytest.raises(ProviderError, match="nonexistent-provider"): # got past profile selection
181
+ load_provider(one, None, None)
182
+ with pytest.raises(ProviderError, match="devguard config init"):
183
+ load_provider(AIConfig(), None, None)