sourcecode 0.26.0__py3-none-any.whl → 0.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.26.0"
3
+ __version__ = "0.28.0"
sourcecode/cli.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import time
4
5
  from pathlib import Path
5
6
  from typing import Any, Optional, cast
6
7
 
@@ -8,12 +9,173 @@ import typer
8
9
 
9
10
  from sourcecode import __version__
10
11
 
12
+ _HELP = """\
13
+ Deterministic codebase context for AI coding agents.
14
+
15
+ [bold]Usage:[/bold]
16
+ sourcecode [dim]# analyze current directory[/dim]
17
+ sourcecode /path/to/repo [dim]# analyze specific path[/dim]
18
+ sourcecode --agent [dim]# structured output for AI agents[/dim]
19
+
20
+ [bold]Subcommands:[/bold]
21
+ prepare-context TASK [PATH] [dim]# task-specific context[/dim]
22
+ telemetry status|enable|disable
23
+ version
24
+ config
25
+ """
26
+
27
+ # Known subcommand names — tokens matching these are routed as subcommands,
28
+ # not consumed as a repository path.
29
+ _SUBCOMMANDS: frozenset[str] = frozenset(
30
+ {"telemetry", "prepare-context", "version", "config", "analyze"}
31
+ )
32
+
33
+ # Mutable container holding the path extracted by _preprocess_argv().
34
+ # Default "." means "current directory" when no path is given.
35
+ _detected_path: list[str] = ["."]
36
+
37
+
38
+ # Options that take a value token — their next arg must not be treated as a path.
39
+ _OPTIONS_WITH_VALUE: frozenset[str] = frozenset({
40
+ "--format", "-f",
41
+ "--output", "-o",
42
+ "--graph-detail",
43
+ "--graph-edges",
44
+ "--max-nodes",
45
+ "--docs-depth",
46
+ "--depth",
47
+ "--git-depth",
48
+ "--git-days",
49
+ "--since",
50
+ "--path", "-p",
51
+ })
52
+
53
+
54
+ def _preprocess_args(args: list[str]) -> list[str]:
55
+ """Extract a repository path token from an args list and store it in _detected_path.
56
+
57
+ Returns the modified args list (path token removed).
58
+ Correctly skips option values (e.g. ``yaml`` in ``--format yaml``).
59
+ If the first non-flag, non-value positional token is a known subcommand name,
60
+ args are returned unchanged so Click can dispatch the subcommand.
61
+ """
62
+ result = list(args)
63
+ skip_next = False
64
+ for i, arg in enumerate(result):
65
+ if skip_next:
66
+ skip_next = False
67
+ continue
68
+ if arg.startswith("-"):
69
+ # Does this option consume the next token as its value?
70
+ flag_name = arg.split("=")[0]
71
+ if flag_name in _OPTIONS_WITH_VALUE and "=" not in arg:
72
+ skip_next = True
73
+ continue
74
+ if arg in _SUBCOMMANDS:
75
+ return result # known subcommand — leave for Click to dispatch
76
+ # First genuine positional: treat as repository path
77
+ _detected_path[0] = arg
78
+ result.pop(i)
79
+ return result
80
+ return result
81
+
82
+
83
+ def _preprocess_argv() -> None:
84
+ """Apply _preprocess_args to sys.argv in-place (used by main_entry)."""
85
+ import sys as _sys
86
+ modified = _preprocess_args(_sys.argv[1:])
87
+ _sys.argv = _sys.argv[:1] + modified
88
+
89
+
11
90
  app = typer.Typer(
12
91
  name="sourcecode",
13
- help="Genera un mapa de contexto estructurado del proyecto para agentes IA.",
92
+ help=_HELP,
14
93
  add_completion=False,
94
+ rich_markup_mode="rich",
95
+ no_args_is_help=False,
15
96
  )
16
97
 
98
+ # ── Hook preprocessing into the Click command layer ───────────────────────────
99
+ # Typer's CliRunner (and app() itself) calls typer.main.get_command(app) to
100
+ # create a Click Group, then calls click_group.main(args=...).
101
+ # We patch get_command so the returned Click Group always preprocesses args —
102
+ # this covers both main_entry() (sys.argv path) and runner.invoke(app, args).
103
+ import typer.main as _typer_main_module # noqa: E402
104
+
105
+ _orig_get_command = _typer_main_module.get_command
106
+
107
+
108
+ def _get_command_with_preprocessing(typer_instance: Any) -> Any:
109
+ cmd = _orig_get_command(typer_instance)
110
+ if typer_instance is not app:
111
+ return cmd # only wrap the root app, not telemetry_app etc.
112
+ _orig_cmd_main = cmd.main
113
+
114
+ def _cmd_main(args: Optional[list[str]] = None, **kwargs: Any) -> Any:
115
+ if args is not None:
116
+ # CliRunner / programmatic call: preprocess the explicit args list.
117
+ _detected_path[0] = "."
118
+ args = _preprocess_args(list(args))
119
+ # args=None → Click reads sys.argv; _preprocess_argv() in main_entry handled it.
120
+ return _orig_cmd_main(args=args, **kwargs)
121
+
122
+ cmd.main = _cmd_main
123
+ return cmd
124
+
125
+
126
+ _typer_main_module.get_command = _get_command_with_preprocessing
127
+
128
+ # typer.testing imports get_command as a private alias _get_command at module
129
+ # load time; patch that reference too so CliRunner.invoke uses our version.
130
+ try:
131
+ import typer.testing as _typer_testing_module
132
+ _typer_testing_module._get_command = _get_command_with_preprocessing # type: ignore[attr-defined]
133
+ except Exception:
134
+ pass
135
+
136
+ telemetry_app = typer.Typer(help="Manage anonymous telemetry (opt-in).", rich_markup_mode="rich")
137
+ app.add_typer(telemetry_app, name="telemetry")
138
+
139
+
140
+ def _maybe_ask_consent() -> None:
141
+ """Show first-run consent prompt once, on interactive TTYs only."""
142
+ try:
143
+ from sourcecode.telemetry.config import has_been_asked, mark_asked, set_enabled
144
+ from sourcecode.telemetry.consent import ask_for_consent
145
+ if not has_been_asked():
146
+ enabled = ask_for_consent()
147
+ set_enabled(enabled)
148
+ if enabled:
149
+ typer.echo("Telemetry enabled. Thank you. Disable: sourcecode telemetry disable", err=True)
150
+ else:
151
+ typer.echo("Telemetry disabled. Enable anytime: sourcecode telemetry enable", err=True)
152
+ except Exception:
153
+ pass
154
+
155
+
156
+ def _active_flags(
157
+ dependencies: bool, graph_modules: bool, docs: bool, full_metrics: bool,
158
+ semantics: bool, architecture: bool, git_context: bool, env_map: bool,
159
+ code_notes: bool, agent: bool, compact: bool, tree: bool, no_redact: bool,
160
+ fmt: str,
161
+ ) -> list[str]:
162
+ flags: list[str] = []
163
+ if agent: flags.append("--agent")
164
+ if compact: flags.append("--compact")
165
+ if dependencies: flags.append("--dependencies")
166
+ if graph_modules: flags.append("--graph-modules")
167
+ if docs: flags.append("--docs")
168
+ if full_metrics: flags.append("--full-metrics")
169
+ if semantics: flags.append("--semantics")
170
+ if architecture: flags.append("--architecture")
171
+ if git_context: flags.append("--git-context")
172
+ if env_map: flags.append("--env-map")
173
+ if code_notes: flags.append("--code-notes")
174
+ if tree: flags.append("--tree")
175
+ if no_redact: flags.append("--no-redact")
176
+ if fmt != "json": flags.append("--format")
177
+ return flags
178
+
17
179
  FORMAT_CHOICES = ["json", "yaml"]
18
180
  GRAPH_DETAIL_CHOICES = ["high", "medium", "full"]
19
181
  GRAPH_EDGE_CHOICES = {"imports", "calls", "contains", "extends"}
@@ -29,7 +191,6 @@ def version_callback(value: bool) -> None:
29
191
  @app.callback(invoke_without_command=True)
30
192
  def main(
31
193
  ctx: typer.Context,
32
- path: Path = typer.Argument(Path("."), help="Directorio a analizar (default: directorio actual)"),
33
194
  format: str = typer.Option(
34
195
  "json",
35
196
  "--format",
@@ -167,11 +328,25 @@ def main(
167
328
  help="Modo agente: output estructurado y sin ruido para consumo por IA. Incluye identidad, entrypoints, arquitectura, dependencias clave, señales operacionales y gaps. Sin arbol de ficheros ni secciones vacias.",
168
329
  ),
169
330
  ) -> None:
170
- """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
171
- # When a subcommand (e.g. prepare-context) is invoked, skip the main analysis.
331
+ """Analyze a repository and produce structured context for AI coding agents.
332
+
333
+ \b
334
+ Examples:
335
+ sourcecode analyze current directory
336
+ sourcecode /path/to/repo analyze specific path
337
+ sourcecode --agent agent-optimized output
338
+ sourcecode --agent --git-context include git activity signals
339
+ """
340
+ # First-run consent (skip for telemetry/version/config subcommands)
341
+ if ctx.invoked_subcommand not in ("telemetry", "version", "config"):
342
+ _maybe_ask_consent()
343
+
344
+ # When a subcommand is invoked, skip the main analysis.
172
345
  if ctx.invoked_subcommand is not None:
173
346
  return
174
347
 
348
+ _t0 = time.monotonic()
349
+
175
350
  # Validar formato
176
351
  if format not in FORMAT_CHOICES:
177
352
  typer.echo(
@@ -192,13 +367,13 @@ def main(
192
367
  )
193
368
  raise typer.Exit(code=1)
194
369
 
195
- # Resolver y validar path
196
- target = path.resolve()
370
+ # Path was extracted from argv by _preprocess_argv() before Click ran.
371
+ target = Path(_detected_path[0]).resolve()
197
372
  if not target.exists():
198
- typer.echo(f"Error: el directorio '{target}' no existe.", err=True)
373
+ typer.echo(f"Error: directory '{target}' does not exist.", err=True)
199
374
  raise typer.Exit(code=1)
200
375
  if not target.is_dir():
201
- typer.echo(f"Error: '{target}' no es un directorio.", err=True)
376
+ typer.echo(f"Error: '{target}' is not a directory.", err=True)
202
377
  raise typer.Exit(code=1)
203
378
 
204
379
  # --- Importar modulos de logica ---
@@ -672,7 +847,26 @@ def main(
672
847
  else:
673
848
  content = json.dumps(raw_dict, indent=2, ensure_ascii=False)
674
849
 
675
- # 5. Escribir output (CLI-04)
850
+ # 5. Telemetry (fire-and-forget, never blocks)
851
+ try:
852
+ from sourcecode import telemetry as _tel
853
+ _tel.record(
854
+ "execution_completed",
855
+ cmd="analyze",
856
+ flags=_active_flags(
857
+ dependencies, graph_modules, docs, full_metrics,
858
+ semantics, architecture, git_context, env_map,
859
+ code_notes, agent, compact, tree, no_redact, format,
860
+ ),
861
+ output_fmt=format,
862
+ file_count=len(sm.file_paths),
863
+ duration_s=time.monotonic() - _t0,
864
+ success=True,
865
+ )
866
+ except Exception:
867
+ pass
868
+
869
+ # 6. Escribir output (CLI-04)
676
870
  write_output(content, output=output)
677
871
 
678
872
 
@@ -682,15 +876,14 @@ def prepare_context_cmd(
682
876
  None,
683
877
  help="Task: explain | fix-bug | refactor | generate-tests | onboard | review-pr | delta",
684
878
  ),
685
- path: Path = typer.Option(
879
+ path: Path = typer.Argument(
686
880
  Path("."),
687
- "--path", "-p",
688
- help="Project directory to analyze (default: current directory)",
881
+ help="Repository path to analyze (default: current directory)",
689
882
  ),
690
883
  since: Optional[str] = typer.Option(
691
884
  None,
692
885
  "--since",
693
- help="Git ref for delta task: show files changed since this ref (e.g. HEAD~3, main)",
886
+ help="Git ref for delta task (e.g. HEAD~3, main)",
694
887
  ),
695
888
  llm_prompt: bool = typer.Option(
696
889
  False,
@@ -708,25 +901,26 @@ def prepare_context_cmd(
708
901
  help="Show what would be analyzed without running it",
709
902
  ),
710
903
  ) -> None:
711
- """Compile task-aware context for AI coding agents.
904
+ """Task-specific context for AI coding agents.
712
905
 
713
906
  \b
714
907
  Tasks:
715
- explain Project overview: structure, entry points, dependencies
716
- fix-bug Risk-ranked files, suspected areas, code annotations
908
+ explain Architecture, entry points, key dependencies
909
+ fix-bug Risk-ranked files, suspected areas, annotations
717
910
  refactor Structural issues, improvement opportunities
718
911
  generate-tests Untested source files, test gap analysis
719
- onboard Full project context for a new agent or developer
720
- review-pr PR review context: changed files + architecture
912
+ onboard Full project context for new agents/developers
913
+ review-pr Changed files + architectural impact
721
914
  delta Incremental context: git-changed files only
722
915
 
723
916
  \b
724
917
  Examples:
725
- sourcecode . prepare-context explain
726
- sourcecode . prepare-context fix-bug --path /my/project
727
- sourcecode . prepare-context delta --since main
728
- sourcecode . prepare-context onboard --llm-prompt
729
- sourcecode . prepare-context --task-help
918
+ sourcecode prepare-context explain
919
+ sourcecode prepare-context explain /path/to/repo
920
+ sourcecode prepare-context fix-bug
921
+ sourcecode prepare-context delta --since main
922
+ sourcecode prepare-context onboard --llm-prompt
923
+ sourcecode prepare-context --task-help
730
924
  """
731
925
  from sourcecode.prepare_context import TASKS, TaskContextBuilder
732
926
 
@@ -804,3 +998,96 @@ def prepare_context_cmd(
804
998
  out["llm_prompt"] = builder.render_prompt(output)
805
999
 
806
1000
  typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
1001
+
1002
+
1003
+ # ── Telemetry commands ────────────────────────────────────────────────────────
1004
+
1005
+ @telemetry_app.command("status")
1006
+ def telemetry_status() -> None:
1007
+ """Show current telemetry setting."""
1008
+ from sourcecode.telemetry.config import config_file_path, has_been_asked, is_enabled
1009
+ enabled = is_enabled()
1010
+ asked = has_been_asked()
1011
+ status = "enabled" if enabled else "disabled"
1012
+ typer.echo(f"Telemetry: {status}")
1013
+ if not asked:
1014
+ typer.echo(" (consent not yet shown — will prompt on next run)")
1015
+ typer.echo(f" Config: {config_file_path()}")
1016
+ typer.echo(" Disable permanently: sourcecode telemetry disable")
1017
+ typer.echo(" Or set env var: SOURCECODE_TELEMETRY=0")
1018
+
1019
+
1020
+ @telemetry_app.command("enable")
1021
+ def telemetry_enable() -> None:
1022
+ """Opt in to anonymous telemetry."""
1023
+ from sourcecode.telemetry.config import set_enabled
1024
+ from sourcecode import telemetry as _tel
1025
+ set_enabled(True)
1026
+ typer.echo("Telemetry enabled. Thank you — this helps improve sourcecode.")
1027
+ typer.echo("What is collected: version, OS, commands, flags, duration, repo size range, errors.")
1028
+ typer.echo("What is never collected: source code, paths, secrets, or any output content.")
1029
+ typer.echo("Disable at any time: sourcecode telemetry disable")
1030
+ _tel.record("telemetry_enabled", cmd="telemetry")
1031
+
1032
+
1033
+ @telemetry_app.command("disable")
1034
+ def telemetry_disable() -> None:
1035
+ """Opt out of anonymous telemetry."""
1036
+ from sourcecode.telemetry.config import set_enabled
1037
+ set_enabled(False)
1038
+ typer.echo("Telemetry disabled. No data will be collected or sent.")
1039
+ typer.echo("Re-enable at any time: sourcecode telemetry enable")
1040
+
1041
+
1042
+ # ── version ───────────────────────────────────────────────────────────────────
1043
+
1044
+ @app.command("version")
1045
+ def version_cmd() -> None:
1046
+ """Show version and exit."""
1047
+ typer.echo(f"sourcecode {__version__}")
1048
+
1049
+
1050
+ # ── config ────────────────────────────────────────────────────────────────────
1051
+
1052
+ @app.command("config")
1053
+ def config_cmd() -> None:
1054
+ """Show current configuration."""
1055
+ from sourcecode.telemetry.config import config_file_path, is_enabled
1056
+ typer.echo(f"sourcecode {__version__}")
1057
+ typer.echo(f"Config: {config_file_path()}")
1058
+ typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'}")
1059
+ typer.echo("")
1060
+ typer.echo("Manage telemetry:")
1061
+ typer.echo(" sourcecode telemetry enable")
1062
+ typer.echo(" sourcecode telemetry disable")
1063
+ typer.echo(" sourcecode telemetry status")
1064
+
1065
+
1066
+ # ── analyze (legacy alias) ────────────────────────────────────────────────────
1067
+
1068
+ @app.command("analyze", hidden=True)
1069
+ def analyze_cmd(
1070
+ path: Path = typer.Argument(Path("."), help="Repository path to analyze"),
1071
+ ) -> None:
1072
+ """[deprecated] Use: sourcecode [PATH]"""
1073
+ typer.echo(
1074
+ "Warning: 'analyze' subcommand is deprecated.\n"
1075
+ "Use: sourcecode .\n"
1076
+ " sourcecode /path/to/repo",
1077
+ err=True,
1078
+ )
1079
+ raise typer.Exit(code=1)
1080
+
1081
+
1082
+ # ── Entry point ───────────────────────────────────────────────────────────────
1083
+
1084
+ def main_entry() -> None:
1085
+ """CLI entry point.
1086
+
1087
+ Calls _preprocess_argv() before Typer/Click parses sys.argv so that
1088
+ repository path tokens are extracted before Click's Group callback
1089
+ can consume them as positional arguments (which would prevent subcommand
1090
+ routing for tokens like 'version' or 'config').
1091
+ """
1092
+ _preprocess_argv()
1093
+ app()
@@ -0,0 +1,111 @@
1
+ """sourcecode telemetry — opt-in anonymous usage metrics.
2
+
3
+ Public API:
4
+ is_enabled() → bool
5
+ record(event, **kw) → None (fire-and-forget)
6
+ session_id() → str (ephemeral 8-char hex, new each process)
7
+
8
+ Telemetry is strictly opt-in. It is disabled by default and can be disabled
9
+ at any time via `sourcecode telemetry disable` or SOURCECODE_TELEMETRY=0.
10
+
11
+ Nothing sensitive (code, paths, secrets, output) is ever collected.
12
+ See docs/privacy.md for full details.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import platform
18
+ import sys
19
+ import uuid
20
+ from typing import Any, Optional
21
+
22
+ from sourcecode.telemetry.config import is_enabled
23
+ from sourcecode.telemetry.events import (
24
+ TelemetryEvent,
25
+ duration_bucket,
26
+ file_count_bucket,
27
+ )
28
+ from sourcecode.telemetry.filters import sanitize
29
+ from sourcecode.telemetry.transport import send
30
+
31
+ # Ephemeral session identifier — new random value each process start.
32
+ # 8 hex chars, never persisted, used only to correlate events within one run.
33
+ _SESSION: str = uuid.uuid4().hex[:8]
34
+
35
+
36
+ def session_id() -> str:
37
+ return _SESSION
38
+
39
+
40
+ def _platform_os() -> str:
41
+ system = platform.system().lower()
42
+ if system == "darwin":
43
+ return "macos"
44
+ if system in ("linux", "windows"):
45
+ return system
46
+ return "other"
47
+
48
+
49
+ def _platform_arch() -> str:
50
+ machine = platform.machine().lower()
51
+ if machine in ("x86_64", "amd64", "i386", "i686"):
52
+ return "x64"
53
+ if machine in ("arm64", "aarch64", "armv8l"):
54
+ return "arm64"
55
+ return "other"
56
+
57
+
58
+ def _python_version() -> str:
59
+ return f"{sys.version_info.major}.{sys.version_info.minor}"
60
+
61
+
62
+ def _sourcecode_version() -> str:
63
+ try:
64
+ from sourcecode import __version__
65
+ return __version__
66
+ except Exception:
67
+ return "unknown"
68
+
69
+
70
+ def record(
71
+ event: str,
72
+ *,
73
+ cmd: str = "analyze",
74
+ flags: Optional[list[str]] = None,
75
+ output_fmt: str = "json",
76
+ file_count: Optional[int] = None,
77
+ duration_s: Optional[float] = None,
78
+ success: bool = True,
79
+ error_kind: Optional[str] = None,
80
+ ) -> None:
81
+ """Record a telemetry event. Fire-and-forget — never blocks or raises.
82
+
83
+ All data is privacy-filtered before transmission. If telemetry is disabled,
84
+ this function returns immediately without doing anything.
85
+ """
86
+ if not is_enabled():
87
+ return
88
+
89
+ try:
90
+ ev = TelemetryEvent(
91
+ event=event,
92
+ v=_sourcecode_version(),
93
+ py=_python_version(),
94
+ os=_platform_os(),
95
+ arch=_platform_arch(),
96
+ cmd=cmd,
97
+ flags=flags or [],
98
+ output_fmt=output_fmt,
99
+ repo_size=file_count_bucket(file_count) if file_count is not None else "unknown",
100
+ duration=duration_bucket(duration_s) if duration_s is not None else "unknown",
101
+ success=success,
102
+ error_kind=error_kind,
103
+ session=_SESSION,
104
+ )
105
+ payload = sanitize(ev)
106
+ send(payload)
107
+ except Exception:
108
+ pass # telemetry must never affect the main process
109
+
110
+
111
+ __all__ = ["is_enabled", "record", "session_id"]
@@ -0,0 +1,67 @@
1
+ """Persistent telemetry configuration.
2
+
3
+ Config file: ~/.config/sourcecode/config.json
4
+ Env override: SOURCECODE_TELEMETRY=0 (disable) or =1 (enable)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ _ENV_VAR = "SOURCECODE_TELEMETRY"
15
+ _CONFIG_FILE = Path.home() / ".config" / "sourcecode" / "config.json"
16
+
17
+
18
+ def _load() -> dict[str, Any]:
19
+ try:
20
+ return json.loads(_CONFIG_FILE.read_text(encoding="utf-8")) # type: ignore[no-any-return]
21
+ except Exception:
22
+ return {}
23
+
24
+
25
+ def _save(data: dict[str, Any]) -> None:
26
+ try:
27
+ _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
28
+ _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
29
+ except Exception:
30
+ pass # config write failure is non-fatal
31
+
32
+
33
+ def is_enabled() -> bool:
34
+ """True only when telemetry is explicitly opted in.
35
+
36
+ Env var takes absolute precedence over config file.
37
+ Default is always disabled — telemetry is strictly opt-in.
38
+ """
39
+ env = os.environ.get(_ENV_VAR, "").strip()
40
+ if env == "0":
41
+ return False
42
+ if env == "1":
43
+ return True
44
+ return bool(_load().get("telemetry", {}).get("enabled", False))
45
+
46
+
47
+ def has_been_asked() -> bool:
48
+ """True if the consent prompt has already been shown."""
49
+ return bool(_load().get("telemetry", {}).get("asked", False))
50
+
51
+
52
+ def set_enabled(value: bool) -> None:
53
+ data = _load()
54
+ data.setdefault("telemetry", {})["enabled"] = value
55
+ data["telemetry"]["asked"] = True
56
+ _save(data)
57
+
58
+
59
+ def mark_asked() -> None:
60
+ """Record that the consent prompt was shown (regardless of answer)."""
61
+ data = _load()
62
+ data.setdefault("telemetry", {})["asked"] = True
63
+ _save(data)
64
+
65
+
66
+ def config_file_path() -> Path:
67
+ return _CONFIG_FILE
@@ -0,0 +1,63 @@
1
+ """First-run consent prompt.
2
+
3
+ Shown exactly once, only on interactive TTYs, only when the user hasn't been
4
+ asked before. Default answer is NO — the user must explicitly type 'y' to opt in.
5
+
6
+ Prompt is written to stderr so it doesn't pollute stdout output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+
14
+ _PROMPT = """\
15
+ \033[2m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16
+ sourcecode — optional anonymous telemetry
17
+
18
+ Help improve sourcecode by sharing anonymous usage metrics.
19
+
20
+ Collected: tool version, Python version, OS, commands used,
21
+ flags used, approximate repo size, execution duration, errors.
22
+
23
+ Never collected: source code, file paths, file names, secrets,
24
+ tokens, environment variables, or any repository content.
25
+
26
+ You can change this at any time:
27
+ sourcecode telemetry disable
28
+ export SOURCECODE_TELEMETRY=0
29
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m
30
+ """
31
+
32
+
33
+ def _is_interactive() -> bool:
34
+ """True when running in an interactive terminal (not CI, not piped)."""
35
+ if not sys.stdin.isatty() or not sys.stderr.isatty():
36
+ return False
37
+ # Common CI environment variables
38
+ ci_vars = {
39
+ "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "CIRCLECI",
40
+ "TRAVIS", "JENKINS_URL", "BUILDKITE", "GITLAB_CI", "TF_BUILD",
41
+ "TEAMCITY_VERSION", "DRONE", "SEMAPHORE",
42
+ }
43
+ return not any(os.environ.get(v) for v in ci_vars)
44
+
45
+
46
+ def ask_for_consent() -> bool:
47
+ """Show the consent prompt and return the user's choice.
48
+
49
+ Returns True if the user opted in, False otherwise.
50
+ Never raises. Default (Enter / non-y input) is False.
51
+ """
52
+ if not _is_interactive():
53
+ return False
54
+
55
+ try:
56
+ sys.stderr.write(_PROMPT)
57
+ sys.stderr.write(" Enable anonymous telemetry? [y/N]: ")
58
+ sys.stderr.flush()
59
+ answer = sys.stdin.readline().strip().lower()
60
+ sys.stderr.write("\n")
61
+ return answer == "y"
62
+ except Exception:
63
+ return False