code-context-control 2.35.0__py3-none-any.whl → 2.37.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.
cli/c3.py CHANGED
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
85
85
  # Config
86
86
  CONFIG_DIR = ".c3"
87
87
  CONFIG_FILE = ".c3/config.json"
88
- __version__ = "2.35.0"
88
+ __version__ = "2.37.0"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
@@ -379,6 +379,44 @@ def _build_permission_tier(tier: str, include_mcp_wildcard: bool = False) -> dic
379
379
  }}
380
380
 
381
381
 
382
+ def _c3_managed_permission_entries() -> tuple[set, set]:
383
+ """Return (allow, deny) sets of every entry any C3 tier can emit.
384
+
385
+ Used to tell C3-managed permission rules apart from user-added ones so a
386
+ tier change replaces only the former and preserves the latter.
387
+ """
388
+ managed_allow: set = set()
389
+ managed_deny: set = set()
390
+ for _tier in PERMISSION_TIERS:
391
+ perms = _build_permission_tier(_tier, include_mcp_wildcard=True)["permissions"]
392
+ managed_allow.update(perms.get("allow", []))
393
+ managed_deny.update(perms.get("deny", []))
394
+ return managed_allow, managed_deny
395
+
396
+
397
+ def _merge_permission_tier(existing: dict, tier_perms: dict) -> dict:
398
+ """Merge a tier's permissions into existing ones, preserving user rules.
399
+
400
+ C3 owns every entry a tier can emit: those are replaced by the chosen tier.
401
+ Any other allow/deny entry the user added is kept, and non-list permission
402
+ keys (e.g. ``ask``, ``defaultMode``, ``additionalDirectories``) are left
403
+ untouched. Mirrors how hooks and .mcp.json preserve non-C3 content.
404
+ """
405
+ existing = existing if isinstance(existing, dict) else {}
406
+ managed = dict(zip(("allow", "deny"), _c3_managed_permission_entries()))
407
+ merged = dict(existing) # preserve unknown sub-keys (ask, defaultMode, ...)
408
+ for key in ("allow", "deny"):
409
+ user_custom = [e for e in (existing.get(key) or []) if e not in managed[key]]
410
+ out: list = []
411
+ seen: set = set()
412
+ for entry in user_custom + list(tier_perms.get(key) or []):
413
+ if entry not in seen:
414
+ seen.add(entry)
415
+ out.append(entry)
416
+ merged[key] = out
417
+ return merged
418
+
419
+
382
420
  def _detect_current_tier(settings_path) -> str | None:
383
421
  """Detect which permission tier is active in settings_path, or None.
384
422
 
@@ -456,9 +494,12 @@ def _apply_permission_tier(project_path: str, tier: str,
456
494
  settings_path = Path(project_path) / ".claude" / "settings.local.json"
457
495
  settings_path.parent.mkdir(parents=True, exist_ok=True)
458
496
  settings = _safe_read_json(settings_path, str(settings_path))
459
- settings["permissions"] = _build_permission_tier(
497
+ tier_perms = _build_permission_tier(
460
498
  tier, include_mcp_wildcard=include_mcp_wildcard
461
499
  )["permissions"]
500
+ settings["permissions"] = _merge_permission_tier(
501
+ settings.get("permissions") or {}, tier_perms
502
+ )
462
503
  # Persist chosen tier in .c3/config.json
463
504
  c3_config_path = Path(project_path) / ".c3" / "config.json"
464
505
  c3_config = _safe_read_json(c3_config_path, str(c3_config_path))
@@ -912,6 +953,12 @@ def cmd_init(args):
912
953
  health["instructions_file"] + " missing" not in " ".join(health["issues"])
913
954
  else " [MISSING]"))
914
955
 
956
+ # Version-skew notice: this project's .c3 was written by an older C3.
957
+ stored_version = _safe_read_json(c3_dir / "config.json", "config").get("version")
958
+ if stored_version and _version_tuple(str(stored_version)) < _version_tuple(__version__):
959
+ print(f"\n [upgrade] Set up with C3 v{stored_version}; now running v{__version__}.")
960
+ print(" Run 'c3 init . --force' to re-apply MCP config, hooks, and docs.")
961
+
915
962
  # Permission status (Claude Code only) — surface tier + stale-tool drift
916
963
  try:
917
964
  from core.ide import load_ide_config as _load_ide
@@ -3872,8 +3919,8 @@ _AGENTS_MD_CONTENT = _C3_COMPACT_WORKFLOW + """
3872
3919
  This project uses project-scoped MCP servers. Ensure your `.codex/config.toml` includes:
3873
3920
  ```toml
3874
3921
  [mcp_servers.c3]
3875
- command = "python"
3876
- args = ["<path-to-c3>/cli/mcp_server.py", "--project", "."]
3922
+ command = "c3-mcp"
3923
+ args = ["--project", "."]
3877
3924
  enabled = true
3878
3925
  ```
3879
3926
  """
@@ -3886,8 +3933,8 @@ This project uses project-scoped MCP servers. Ensure your `.gemini/settings.json
3886
3933
  {
3887
3934
  "mcpServers": {
3888
3935
  "c3": {
3889
- "command": "python",
3890
- "args": ["<path-to-c3>/cli/mcp_server.py", "--project", "."]
3936
+ "command": "c3-mcp",
3937
+ "args": ["--project", "."]
3891
3938
  }
3892
3939
  }
3893
3940
  }
@@ -4215,11 +4262,17 @@ def _upsert_json_mcp_server(config_path: Path, config_key: str, server_name: str
4215
4262
  return "updated" if previous_entry is not None else "written"
4216
4263
 
4217
4264
 
4218
- def _ensure_project_session_configs(target: Path, server_script: str, primary_profile: str | None = None) -> None:
4265
+ def _ensure_project_session_configs(target: Path, server_script: str, primary_profile: str | None = None,
4266
+ c3_mcp_exe: str | None = None) -> None:
4219
4267
  """Keep project-local Codex and Gemini MCP configs in sync for new sessions."""
4220
4268
  # Ensure forward slashes for config portability and avoid Windows path-splitting issues
4221
4269
  server_script_posix = Path(server_script).as_posix()
4222
- server_args = [server_script_posix, "--project", target.as_posix()]
4270
+ if c3_mcp_exe:
4271
+ mcp_command = c3_mcp_exe
4272
+ server_args = ["--project", target.as_posix()]
4273
+ else:
4274
+ mcp_command = "python"
4275
+ server_args = [server_script_posix, "--project", target.as_posix()]
4223
4276
 
4224
4277
  if primary_profile != "codex":
4225
4278
  codex_path = target / ".codex" / "config.toml"
@@ -4228,7 +4281,7 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr
4228
4281
  codex_path,
4229
4282
  "mcp_servers.c3",
4230
4283
  {
4231
- "command": "python",
4284
+ "command": mcp_command,
4232
4285
  "args": server_args,
4233
4286
  "enabled": True,
4234
4287
  },
@@ -4242,14 +4295,14 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr
4242
4295
  "mcpServers",
4243
4296
  "c3",
4244
4297
  {
4245
- "command": "python",
4298
+ "command": mcp_command,
4246
4299
  "args": server_args,
4247
4300
  },
4248
4301
  )
4249
4302
  print(f"{gemini_state.capitalize()} {gemini_path}")
4250
4303
 
4251
4304
 
4252
- def _ensure_global_session_fallbacks(server_script: str) -> None:
4305
+ def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None = None) -> None:
4253
4306
  """Keep user-global Codex/Gemini MCP configs pointing at C3.
4254
4307
 
4255
4308
  These fallback entries omit `--project` so the MCP server can resolve the
@@ -4257,7 +4310,9 @@ def _ensure_global_session_fallbacks(server_script: str) -> None:
4257
4310
  does not yet have project-local Codex/Gemini config files.
4258
4311
  """
4259
4312
  server_script_posix = Path(server_script).as_posix()
4260
- fallback_args = [server_script_posix]
4313
+ # With the installed entry point, no script path is needed; --project stays
4314
+ # omitted so the server resolves the working directory at session start.
4315
+ fallback_args = [] if c3_mcp_exe else [server_script_posix]
4261
4316
 
4262
4317
  codex_path = Path.home() / ".codex" / "config.toml"
4263
4318
  try:
@@ -4266,7 +4321,7 @@ def _ensure_global_session_fallbacks(server_script: str) -> None:
4266
4321
  codex_path,
4267
4322
  "mcp_servers.c3",
4268
4323
  {
4269
- "command": "python",
4324
+ "command": c3_mcp_exe or "python",
4270
4325
  "args": fallback_args,
4271
4326
  "enabled": True,
4272
4327
  },
@@ -4282,7 +4337,7 @@ def _ensure_global_session_fallbacks(server_script: str) -> None:
4282
4337
  "mcpServers",
4283
4338
  "c3",
4284
4339
  {
4285
- "command": sys.executable,
4340
+ "command": c3_mcp_exe or sys.executable,
4286
4341
  "args": fallback_args,
4287
4342
  },
4288
4343
  )
@@ -4825,13 +4880,25 @@ def cmd_install_mcp(args):
4825
4880
  # Use forward slashes for cross-platform compatibility in config files
4826
4881
  server_script = (cli_dir / server_filename).as_posix()
4827
4882
 
4828
- # Use 'python' for project-scoped IDE configs to be portable in templates,
4829
- # but use sys.executable for the actual config write to be precise.
4830
- # On Windows, Gemini CLI splits command args by space, so we must quote the script path.
4831
- new_entry = {
4832
- "command": "python",
4833
- "args": [server_script, "--project", "."],
4834
- }
4883
+ # Prefer the installed `c3-mcp` console script for direct mode. It survives C3
4884
+ # upgrades (pip/pipx reinstall to the same launcher path) and keeps the source-tree
4885
+ # location out of every project's MCP config, so upgrading no longer requires
4886
+ # re-running install-mcp per project. Fall back to invoking the source script with
4887
+ # `python` when running from a checkout with no installed entry point, or in proxy
4888
+ # mode (which has no console script).
4889
+ import shutil
4890
+ c3_mcp_exe = None
4891
+ if mcp_mode != "proxy":
4892
+ _found = shutil.which("c3-mcp")
4893
+ if _found:
4894
+ c3_mcp_exe = Path(_found).resolve().as_posix()
4895
+
4896
+ # On Windows, Gemini CLI splits command args by space, so the script path stays a
4897
+ # single arg. 'python' keeps the source fallback portable across platforms.
4898
+ if c3_mcp_exe:
4899
+ new_entry = {"command": c3_mcp_exe, "args": ["--project", "."]}
4900
+ else:
4901
+ new_entry = {"command": "python", "args": [server_script, "--project", "."]}
4835
4902
  if profile.needs_type_field:
4836
4903
  new_entry["type"] = "stdio"
4837
4904
 
@@ -4865,7 +4932,10 @@ def cmd_install_mcp(args):
4865
4932
  try:
4866
4933
  if profile.config_format == "toml":
4867
4934
  # Codex uses TOML: [mcp_servers.c3] with command/args
4868
- toml_entries = {"command": sys.executable, "args": [server_script, "--project", str(target)]}
4935
+ if c3_mcp_exe:
4936
+ toml_entries = {"command": c3_mcp_exe, "args": ["--project", str(target)]}
4937
+ else:
4938
+ toml_entries = {"command": sys.executable, "args": [server_script, "--project", str(target)]}
4869
4939
  if profile.name == "codex":
4870
4940
  # Codex supports explicit enable/disable per server.
4871
4941
  toml_entries["enabled"] = True
@@ -4895,8 +4965,8 @@ def cmd_install_mcp(args):
4895
4965
 
4896
4966
  print(f"Wrote {mcp_config_path}")
4897
4967
  if profile.name in {"codex", "gemini"}:
4898
- _ensure_project_session_configs(target, server_script, primary_profile=profile.name)
4899
- _ensure_global_session_fallbacks(server_script)
4968
+ _ensure_project_session_configs(target, server_script, primary_profile=profile.name, c3_mcp_exe=c3_mcp_exe)
4969
+ _ensure_global_session_fallbacks(server_script, c3_mcp_exe=c3_mcp_exe)
4900
4970
 
4901
4971
  # ── Persist IDE choice to .c3/config.json ──
4902
4972
  c3_config_dir = target / ".c3"
@@ -5110,10 +5180,23 @@ def cmd_install_mcp(args):
5110
5180
  },
5111
5181
  ]
5112
5182
  stop_event = "Stop"
5113
- # Replace any existing C3 stop hooks (matcher=""), keep user-added ones
5183
+ # Replace only C3's own stop hooks (identified by our hook scripts) and
5184
+ # keep every user-added stop hook — including matcher-less ones, which
5185
+ # are the normal shape for Stop hooks.
5186
+ _c3_stop_scripts = (
5187
+ "hook_session_stats.py", "hook_auto_snapshot.py", "hook_terse_advisor.py",
5188
+ )
5189
+
5190
+ def _is_c3_stop_hook(entry: dict) -> bool:
5191
+ return any(
5192
+ script in (hk.get("command") or "")
5193
+ for hk in entry.get("hooks", [])
5194
+ for script in _c3_stop_scripts
5195
+ )
5196
+
5114
5197
  existing_stop = [
5115
5198
  h for h in settings.get("hooks", {}).get(stop_event, [])
5116
- if h.get("matcher") # keep entries with a non-empty matcher
5199
+ if not _is_c3_stop_hook(h)
5117
5200
  ]
5118
5201
  existing_stop.extend(desired_stop_hooks)
5119
5202
  settings.setdefault("hooks", {})[stop_event] = existing_stop
@@ -5131,9 +5214,12 @@ def cmd_install_mcp(args):
5131
5214
  include_wildcard = bool(getattr(args, "include_mcp_wildcard", False))
5132
5215
  if perm_tier and profile.name == "claude-code":
5133
5216
  if perm_tier in PERMISSION_TIERS:
5134
- settings["permissions"] = _build_permission_tier(
5135
- perm_tier, include_mcp_wildcard=include_wildcard
5136
- )["permissions"]
5217
+ settings["permissions"] = _merge_permission_tier(
5218
+ settings.get("permissions") or {},
5219
+ _build_permission_tier(
5220
+ perm_tier, include_mcp_wildcard=include_wildcard
5221
+ )["permissions"],
5222
+ )
5137
5223
  # Persist tier choice in .c3/config.json
5138
5224
  _c3cfg = _safe_read_json(c3_config_path, str(c3_config_path))
5139
5225
  _c3cfg["permission_tier"] = perm_tier
@@ -6342,6 +6428,123 @@ def _run_swe_bench_lite(args, project_path):
6342
6428
  pass
6343
6429
 
6344
6430
 
6431
+ def _version_tuple(v: str) -> tuple:
6432
+ """Best-effort numeric version tuple for comparisons ('2.36.0' -> (2, 36, 0))."""
6433
+ parts = []
6434
+ for chunk in str(v or "").split("."):
6435
+ digits = ""
6436
+ for ch in chunk:
6437
+ if ch.isdigit():
6438
+ digits += ch
6439
+ else:
6440
+ break
6441
+ parts.append(int(digits) if digits else 0)
6442
+ return tuple(parts) or (0,)
6443
+
6444
+
6445
+ def _latest_pypi_version(package: str = "code-context-control", timeout: float = 5.0) -> str | None:
6446
+ """Best-effort latest release of `package` on PyPI; None if unreachable."""
6447
+ import urllib.request
6448
+ try:
6449
+ url = f"https://pypi.org/pypi/{package}/json"
6450
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
6451
+ data = json.loads(resp.read().decode("utf-8", "replace"))
6452
+ return (data.get("info") or {}).get("version")
6453
+ except Exception:
6454
+ return None
6455
+
6456
+
6457
+ def _installed_distribution(package: str = "code-context-control"):
6458
+ """Return the installed Distribution for `package`, or None when running from source."""
6459
+ try:
6460
+ from importlib import metadata
6461
+ return metadata.distribution(package)
6462
+ except Exception:
6463
+ return None
6464
+
6465
+
6466
+ def _is_editable_install(package: str = "code-context-control") -> bool:
6467
+ """True when `package` is pip-installed in editable/development mode."""
6468
+ dist = _installed_distribution(package)
6469
+ if dist is None:
6470
+ return False
6471
+ try:
6472
+ text = dist.read_text("direct_url.json")
6473
+ if text:
6474
+ return bool(json.loads(text).get("dir_info", {}).get("editable"))
6475
+ except Exception:
6476
+ pass
6477
+ return False
6478
+
6479
+
6480
+ def cmd_upgrade(args):
6481
+ """Upgrade C3 to the latest PyPI release (or just check with --check)."""
6482
+ current = __version__
6483
+ latest = _latest_pypi_version()
6484
+ if latest is None:
6485
+ print(" Could not reach PyPI to check for updates (offline?).")
6486
+ elif _version_tuple(latest) <= _version_tuple(current):
6487
+ print(f" C3 is up to date (v{current}).")
6488
+ return
6489
+ else:
6490
+ print(f" Update available: v{current} -> v{latest}")
6491
+
6492
+ if getattr(args, "check", False):
6493
+ return
6494
+
6495
+ if _installed_distribution() is None:
6496
+ print(" C3 is running from a source checkout (not pip-installed).")
6497
+ print(" Update with: git pull")
6498
+ return
6499
+ if _is_editable_install():
6500
+ print(" C3 is installed in editable/development mode (pip install -e .).")
6501
+ print(" Update with: git pull")
6502
+ return
6503
+
6504
+ print(" Upgrading via pip (this may take a minute)...")
6505
+ cmd = [sys.executable, "-m", "pip", "install", "-U", "code-context-control[tui]"]
6506
+ try:
6507
+ result = subprocess.run(cmd, capture_output=True, text=True)
6508
+ except Exception as e:
6509
+ print(f" Upgrade failed to launch pip: {e}")
6510
+ sys.exit(1)
6511
+ if result.returncode != 0:
6512
+ print(" pip upgrade failed:")
6513
+ print((result.stderr or result.stdout or "").strip()[-1000:])
6514
+ sys.exit(1)
6515
+ print(" Upgraded to the latest release. Restart your IDE's MCP server to load it.")
6516
+ print(" In each project, run c3 init . --force to apply any migrations.")
6517
+
6518
+
6519
+ def _launch_tui() -> None:
6520
+ """Launch the interactive TUI — what `c3` with no arguments does.
6521
+
6522
+ Runs tui/main.py as a subprocess so its bare `from screens...` imports resolve
6523
+ (its own directory lands on sys.path[0]); the package root goes on PYTHONPATH for
6524
+ cli/services imports. Falls back to help text when the optional [tui] extra
6525
+ (textual) is not installed.
6526
+ """
6527
+ pkg_root = Path(__file__).resolve().parent.parent
6528
+ tui_main = pkg_root / "tui" / "main.py"
6529
+ try:
6530
+ import textual # noqa: F401
6531
+ except Exception:
6532
+ print("The interactive TUI needs the optional 'textual' dependency.")
6533
+ print(' Install it with: pip install "code-context-control[tui]"')
6534
+ print(" Or run c3 --help to see all commands.")
6535
+ return
6536
+ if not tui_main.exists():
6537
+ print("TUI entry point not found. Run c3 --help for commands.")
6538
+ return
6539
+ env = os.environ.copy()
6540
+ existing_pp = env.get("PYTHONPATH")
6541
+ env["PYTHONPATH"] = str(pkg_root) + (os.pathsep + existing_pp if existing_pp else "")
6542
+ try:
6543
+ subprocess.run([sys.executable, str(tui_main)], env=env)
6544
+ except KeyboardInterrupt:
6545
+ pass
6546
+
6547
+
6345
6548
  def main():
6346
6549
  try:
6347
6550
  from services import error_reporting
@@ -6353,7 +6556,8 @@ def main():
6353
6556
  args = parser.parse_args()
6354
6557
 
6355
6558
  if not args.command:
6356
- parser.print_help()
6559
+ # Bare `c3` launches the interactive TUI (replaces the old c3.bat wrapper).
6560
+ _launch_tui()
6357
6561
  return
6358
6562
 
6359
6563
  commands = {
@@ -6382,6 +6586,7 @@ def main():
6382
6586
  "hub": cmd_hub,
6383
6587
  "bitbucket": cmd_bitbucket,
6384
6588
  "oracle": cmd_oracle,
6589
+ "upgrade": cmd_upgrade,
6385
6590
  }
6386
6591
 
6387
6592
  cmd_func = commands.get(args.command)
cli/commands/common.py CHANGED
@@ -200,13 +200,9 @@ def cmd_claudemd(args, deps: CommandDeps):
200
200
  print(content)
201
201
  else:
202
202
  output_path = Path(project_path) / instructions_file
203
- output_path.parent.mkdir(parents=True, exist_ok=True)
204
- if output_path.exists():
205
- existing = output_path.read_text(encoding="utf-8", errors="replace")
206
- if "# User Notes" in existing:
207
- user_section = existing[existing.index("# User Notes"):]
208
- content += f"\n\n{user_section}"
209
- output_path.write_text(content, encoding="utf-8")
203
+ # Wrap in the C3 managed block; preserve user content outside it.
204
+ from services.claude_md import write_c3_instruction_doc
205
+ write_c3_instruction_doc(output_path, content)
210
206
  print(f"{instructions_file} saved to {output_path} ({tokens} tokens)")
211
207
 
212
208
  elif args.claudemd_cmd == "check":
cli/commands/parser.py CHANGED
@@ -23,6 +23,10 @@ def build_parser(version: str, parse_cli_ide_arg):
23
23
  p_init.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only, used with --force)")
24
24
  p_init.add_argument("--include-mcp-wildcard", action="store_true", help="Add mcp__* wildcard so non-C3 MCP servers don't prompt per-call")
25
25
 
26
+ p_upgrade = subparsers.add_parser("upgrade", help="Upgrade C3 to the latest PyPI release")
27
+ p_upgrade.add_argument("--check", action="store_true",
28
+ help="Only report whether a newer version exists; don't install")
29
+
26
30
  p_index = subparsers.add_parser("index", help="Rebuild code index")
27
31
  p_index.add_argument("--max-files", type=int, default=500)
28
32