code-context-control 2.28.3__py3-none-any.whl → 2.30.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
@@ -60,7 +60,7 @@ from cli.commands.common import cmd_stats as common_cmd_stats
60
60
  from cli.commands.common import cmd_ui as common_cmd_ui
61
61
  from cli.commands.parser import build_parser
62
62
  from core import count_tokens, format_token_count
63
- from core.config import AGENT_DEFAULTS, DELEGATE_DEFAULTS, PROXY_DEFAULTS, load_delegate_config
63
+ from core.config import AGENT_DEFAULTS, BITBUCKET_DEFAULTS, DELEGATE_DEFAULTS, PROXY_DEFAULTS, load_delegate_config
64
64
  from core.config import DEFAULTS as HYBRID_DEFAULTS
65
65
  from core.ide import PROFILES, detect_ide, get_profile, load_ide_config, normalize_ide_name
66
66
  from services.compressor import CodeCompressor
@@ -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.28.3"
88
+ __version__ = "2.30.0"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
@@ -164,6 +164,7 @@ def _build_init_config(project_path: str) -> dict:
164
164
  "proxy": deepcopy(PROXY_DEFAULTS),
165
165
  "delegate": deepcopy(DELEGATE_DEFAULTS),
166
166
  "agents": deepcopy(AGENT_DEFAULTS),
167
+ "bitbucket": deepcopy(BITBUCKET_DEFAULTS),
167
168
  }
168
169
  merged = _deep_merge_dict(defaults, existing if isinstance(existing, dict) else {})
169
170
  # Always persist current path/version on init/update.
@@ -237,7 +238,7 @@ _C3_MCP_ALLOW = [
237
238
  "mcp__c3__c3_session", "mcp__c3__c3_status", "mcp__c3__c3_filter",
238
239
  "mcp__c3__c3_memory", "mcp__c3__c3_validate", "mcp__c3__c3_edit",
239
240
  "mcp__c3__c3_agent", "mcp__c3__c3_delegate", "mcp__c3__c3_edits",
240
- "mcp__c3__c3_impact", "mcp__c3__c3_shell",
241
+ "mcp__c3__c3_impact", "mcp__c3__c3_shell", "mcp__c3__c3_bitbucket",
241
242
  ]
242
243
 
243
244
  # Obsolete MCP tool names from earlier C3 versions. `c3 permissions clean`
@@ -563,6 +564,25 @@ def _check_c3_health(project_path: str) -> dict:
563
564
  facts_dir = c3_dir / "facts"
564
565
  info["facts"] = len(list(facts_dir.glob("*.json"))) if facts_dir.exists() else 0
565
566
 
567
+ # Bitbucket integration (v2.30.0+) — informational
568
+ bb_section = config.get("bitbucket") if isinstance(config, dict) else None
569
+ if isinstance(bb_section, dict):
570
+ active = bb_section.get("active") or {}
571
+ accounts = bb_section.get("accounts") or []
572
+ info["bitbucket_accounts"] = len(accounts) if isinstance(accounts, list) else 0
573
+ info["bitbucket_active_account"] = (
574
+ f"{active.get('username', '')}@{active.get('base_url', '')}"
575
+ if active.get("base_url") and active.get("username") else ""
576
+ )
577
+ info["bitbucket_default_repo"] = (
578
+ f"{bb_section.get('default_project', '')}/{bb_section.get('default_repo', '')}"
579
+ if bb_section.get("default_project") and bb_section.get("default_repo") else ""
580
+ )
581
+ else:
582
+ info["bitbucket_accounts"] = 0
583
+ info["bitbucket_active_account"] = ""
584
+ info["bitbucket_default_repo"] = ""
585
+
566
586
  info["issues"] = issues
567
587
  info["healthy"] = len(issues) == 0
568
588
  return info
@@ -827,6 +847,11 @@ def cmd_init(args):
827
847
  if not c3_dir.exists() or not (c3_dir / "config.json").exists():
828
848
  print_header(f"Initializing C3 for: {project_path}")
829
849
  _do_init(project_path, ide_name=requested_ide)
850
+ try:
851
+ from services.project_manager import ProjectManager
852
+ ProjectManager().add_project(project_path)
853
+ except Exception as _e:
854
+ print(f" [warn] Could not register project with hub: {_e}")
830
855
  if getattr(args, "force", False):
831
856
  if git_requested:
832
857
  _init_local_git_repo(project_path)
@@ -941,6 +966,15 @@ def cmd_init(args):
941
966
  except Exception:
942
967
  print(" Gemini: unknown")
943
968
 
969
+ # Bitbucket integration (v2.30.0+)
970
+ bb_n = int(health.get("bitbucket_accounts") or 0)
971
+ if bb_n:
972
+ active = health.get("bitbucket_active_account") or "(no active)"
973
+ repo = health.get("bitbucket_default_repo") or "(no default repo)"
974
+ print(f" Bitbkt: {bb_n} account(s), active={active}, repo={repo}")
975
+ else:
976
+ print(" Bitbkt: not configured (run 'c3 bitbucket login --url <URL>')")
977
+
944
978
  if health["healthy"]:
945
979
  print("\n Status: healthy — no issues detected.")
946
980
  else:
@@ -4457,6 +4491,7 @@ back to native tools as the task progresses.
4457
4491
  - **Shell**: `c3_shell(cmd, timeout=60)` — structured shell exec (tests/git/build). Auto-filters output, logs git mutations to the ledger. Native Bash for interactive/TTY only
4458
4492
  - **Memory**: `c3_memory(action='recall')` — full recall. `index` + `fetch` for token-efficient two-step retrieval
4459
4493
  - **Delegate**: `c3_delegate(task, backend='ollama|codex|gemini|claude|auto')` — offload to other models
4494
+ - **Bitbucket** (v2.30.0+, when `c3 bitbucket login` has run): `c3_bitbucket(action='list_prs|get_pr|merge_pr|...')` — self-hosted Bitbucket Data Center / Server. Token in OS keyring; mutating actions auto-log to the edit ledger.
4460
4495
 
4461
4496
  ## Self-Check
4462
4497
  If you haven't called a c3_* tool in several turns during active development, re-engage
@@ -5383,6 +5418,144 @@ def cmd_hub(args):
5383
5418
  run_hub(port=port, open_browser=open_browser, silent=silent, quiet=quiet)
5384
5419
 
5385
5420
 
5421
+ def cmd_bitbucket(args):
5422
+ """Bitbucket Data Center / Server credential + workspace management."""
5423
+ sub = getattr(args, "bitbucket_cmd", None)
5424
+ if not sub:
5425
+ print("Usage: c3 bitbucket {login,logout,status,use,set-default} [args]")
5426
+ return
5427
+
5428
+ project_path = getattr(args, "project_path", ".") or "."
5429
+
5430
+ if sub == "login":
5431
+ _bb_cmd_login(args, project_path)
5432
+ elif sub == "logout":
5433
+ _bb_cmd_logout(args, project_path)
5434
+ elif sub == "status":
5435
+ _bb_cmd_status(args, project_path)
5436
+ elif sub == "use":
5437
+ _bb_cmd_use(args, project_path)
5438
+ elif sub == "set-default":
5439
+ _bb_cmd_set_default(args, project_path)
5440
+ else:
5441
+ print(f"Unknown bitbucket subcommand: {sub}")
5442
+
5443
+
5444
+ def _bb_cmd_login(args, project_path: str) -> None:
5445
+ import getpass
5446
+
5447
+ from services import bitbucket_credentials as bb_creds
5448
+ from services.bitbucket_client import BitbucketDataCenterClient, BitbucketError
5449
+
5450
+ base_url = (args.url or "").rstrip("/")
5451
+ username = args.username or input(f"Username for {base_url}: ").strip()
5452
+ if not username:
5453
+ print("Login cancelled — username required.")
5454
+ return
5455
+ token = args.token or getpass.getpass(f"Personal Access Token for {username}: ").strip()
5456
+ if not token:
5457
+ print("Login cancelled — token required.")
5458
+ return
5459
+
5460
+ try:
5461
+ bb_creds.save_credentials(
5462
+ base_url, username, token,
5463
+ project_path=project_path,
5464
+ set_active=not getattr(args, "no_set_active", False),
5465
+ )
5466
+ except bb_creds.BitbucketCredentialError as exc:
5467
+ print(f"[error] {exc}")
5468
+ return
5469
+
5470
+ if getattr(args, "insecure", False):
5471
+ bb_creds.set_verify_tls(False, project_path=project_path)
5472
+
5473
+ print(f"[OK] Stored credentials for {username}@{base_url}")
5474
+
5475
+ # Connection probe — non-fatal if it fails (token might be valid but
5476
+ # network blocked at this moment).
5477
+ try:
5478
+ client = BitbucketDataCenterClient(
5479
+ base_url=base_url, token=token,
5480
+ verify_tls=not getattr(args, "insecure", False),
5481
+ )
5482
+ props = client.application_properties()
5483
+ version = props.get("version", "?")
5484
+ user = client.whoami()
5485
+ print(f" Server: {version} ({base_url})")
5486
+ print(f" Auth as: {user.get('displayName', username)} <{user.get('emailAddress', '?')}>")
5487
+ except BitbucketError as exc:
5488
+ print(f"[warn] Connection probe failed: {exc}")
5489
+ print(" Token saved anyway — re-test with `c3 bitbucket status`.")
5490
+
5491
+
5492
+ def _bb_cmd_logout(args, project_path: str) -> None:
5493
+ from services import bitbucket_credentials as bb_creds
5494
+
5495
+ base_url = (getattr(args, "url", "") or "").rstrip("/")
5496
+ username = getattr(args, "username", "") or ""
5497
+ if not base_url or not username:
5498
+ active = bb_creds.get_active_account(project_path)
5499
+ base_url = base_url or active.get("base_url", "")
5500
+ username = username or active.get("username", "")
5501
+ if not base_url or not username:
5502
+ print("[error] No account specified and no active account configured.")
5503
+ return
5504
+ removed = bb_creds.delete_credentials(base_url, username, project_path=project_path)
5505
+ if removed:
5506
+ print(f"[OK] Removed {username}@{base_url}")
5507
+ else:
5508
+ print(f"[warn] Nothing to remove for {username}@{base_url}")
5509
+
5510
+
5511
+ def _bb_cmd_status(args, project_path: str) -> None:
5512
+ from core.config import load_bitbucket_config
5513
+ from services import bitbucket_credentials as bb_creds
5514
+ from services.bitbucket_client import BitbucketDataCenterClient, BitbucketError
5515
+
5516
+ cfg = load_bitbucket_config(project_path)
5517
+ active = cfg.get("active") or {}
5518
+ accounts = cfg.get("accounts") or []
5519
+
5520
+ print("[bitbucket:status]")
5521
+ print(f" Active : {active.get('username') or '-'}@{active.get('base_url') or '-'}")
5522
+ print(f" Defaults: project={cfg.get('default_project') or '-'} repo={cfg.get('default_repo') or '-'}")
5523
+ print(f" Verify TLS: {cfg.get('verify_tls', True)}")
5524
+ print(f" Accounts ({len(accounts)}):")
5525
+ for a in accounts:
5526
+ marker = "*" if a == active else " "
5527
+ print(f" {marker} {a.get('username','?')}@{a.get('base_url','?')}")
5528
+
5529
+ if not active.get("base_url") or not active.get("username"):
5530
+ print(" Connection: (no active account)")
5531
+ return
5532
+ token = bb_creds.load_token(active["base_url"], active["username"])
5533
+ if not token:
5534
+ print(" Connection: FAIL — no token in keyring")
5535
+ return
5536
+ try:
5537
+ client = BitbucketDataCenterClient(
5538
+ base_url=active["base_url"], token=token,
5539
+ verify_tls=bool(cfg.get("verify_tls", True)),
5540
+ )
5541
+ props = client.application_properties()
5542
+ print(f" Connection: OK (version {props.get('version','?')})")
5543
+ except BitbucketError as exc:
5544
+ print(f" Connection: FAIL — {exc}")
5545
+
5546
+
5547
+ def _bb_cmd_use(args, project_path: str) -> None:
5548
+ from services import bitbucket_credentials as bb_creds
5549
+ bb_creds.set_active_account(args.url, args.username, project_path=project_path)
5550
+ print(f"[OK] Active account: {args.username}@{args.url.rstrip('/')}")
5551
+
5552
+
5553
+ def _bb_cmd_set_default(args, project_path: str) -> None:
5554
+ from services import bitbucket_credentials as bb_creds
5555
+ bb_creds.set_default_repo(args.project, args.repo, project_path=project_path)
5556
+ print(f"[OK] Default repo: {args.project}/{args.repo}")
5557
+
5558
+
5386
5559
  def cmd_projects(args):
5387
5560
  """Manage the global C3 project registry."""
5388
5561
  from services.project_manager import ProjectManager
@@ -6134,6 +6307,7 @@ def main():
6134
6307
  "ui": cmd_ui,
6135
6308
  "projects": cmd_projects,
6136
6309
  "hub": cmd_hub,
6310
+ "bitbucket": cmd_bitbucket,
6137
6311
  }
6138
6312
 
6139
6313
  cmd_func = commands.get(args.command)
cli/commands/parser.py CHANGED
@@ -283,4 +283,43 @@ def build_parser(version: str, parse_cli_ide_arg):
283
283
  p_bext.add_argument("--dry-run", action="store_true",
284
284
  help="Validate setup (CLIs, datasets) without running the agent")
285
285
 
286
+ # ── Bitbucket Data Center / Server (v2.30.0) ─────────────────────────
287
+ p_bitbucket = subparsers.add_parser(
288
+ "bitbucket",
289
+ help="Bitbucket Data Center / Server credential + workspace management",
290
+ )
291
+ bb_subs = p_bitbucket.add_subparsers(dest="bitbucket_cmd")
292
+
293
+ bb_login = bb_subs.add_parser(
294
+ "login",
295
+ help="Authenticate with a Bitbucket Data Center server (interactive PAT prompt)",
296
+ )
297
+ bb_login.add_argument("--url", required=True, help="Bitbucket server base URL (e.g. https://bitbucket.example.com)")
298
+ bb_login.add_argument("--username", help="Bitbucket username (prompted if omitted)")
299
+ bb_login.add_argument("--token", help="Personal Access Token (prompted via getpass if omitted — preferred)")
300
+ bb_login.add_argument("--no-set-active", action="store_true", help="Do not switch the active account to this one")
301
+ bb_login.add_argument("--insecure", action="store_true", help="Disable TLS verification (self-signed certs)")
302
+ bb_login.add_argument("project_path", nargs="?", default=".")
303
+
304
+ bb_logout = bb_subs.add_parser("logout", help="Remove a Bitbucket account from keyring + config")
305
+ bb_logout.add_argument("--url", help="Bitbucket server base URL (defaults to active account)")
306
+ bb_logout.add_argument("--username", help="Username to log out (defaults to active account)")
307
+ bb_logout.add_argument("project_path", nargs="?", default=".")
308
+
309
+ bb_status = bb_subs.add_parser("status", help="Show configured Bitbucket accounts and connectivity")
310
+ bb_status.add_argument("project_path", nargs="?", default=".")
311
+
312
+ bb_use = bb_subs.add_parser("use", help="Switch the active Bitbucket account")
313
+ bb_use.add_argument("--url", required=True)
314
+ bb_use.add_argument("--username", required=True)
315
+ bb_use.add_argument("project_path", nargs="?", default=".")
316
+
317
+ bb_default = bb_subs.add_parser(
318
+ "set-default",
319
+ help="Set the default project key + repo slug for this C3 project",
320
+ )
321
+ bb_default.add_argument("--project", required=True, help="Bitbucket project key (e.g. PROJ)")
322
+ bb_default.add_argument("--repo", required=True, help="Repository slug")
323
+ bb_default.add_argument("project_path", nargs="?", default=".")
324
+
286
325
  return parser
cli/docs.html CHANGED
@@ -751,6 +751,7 @@
751
751
 
752
752
  <div class="sidebar-section">MCP Server</div>
753
753
  <a href="#mcp-tools">MCP Tools</a>
754
+ <a href="#bitbucket-tool">Bitbucket Integration</a>
754
755
  <a href="#context-tools">Context Manager</a>
755
756
  <a href="#claudemd-tools">Instructions Tools</a>
756
757
  <a href="#agent-tools">Background Agents</a>
@@ -1147,7 +1148,7 @@ python cli/c3.py install-mcp . gemini</code></pre>
1147
1148
 
1148
1149
  <!-- ─── MCP Tools ───────────────────── -->
1149
1150
  <h2 id="mcp-tools">MCP Tools Reference</h2>
1150
- <p>C3 exposes 9 MCP tools. All core tools work without Ollama; delegate requires it.</p>
1151
+ <p>C3 exposes 15 MCP tools. All core tools work without Ollama; delegate requires it. The Bitbucket integration is optional and activated via <code>c3 bitbucket login</code>.</p>
1151
1152
 
1152
1153
  <h3>Discovery &amp; Compression</h3>
1153
1154
  <table>
@@ -1298,6 +1299,41 @@ python cli/c3.py install-mcp . gemini</code></pre>
1298
1299
  View the full timeline at <code>/edits</code>.
1299
1300
  </div>
1300
1301
 
1302
+ <!-- Bitbucket Integration (v2.30.0) -->
1303
+ <h2 id="bitbucket-tool">Bitbucket Data Center / Server</h2>
1304
+ <p>C3 v2.30.0 ships a dedicated <code>c3_bitbucket</code> tool for self-hosted enterprise Bitbucket. It supports read browsing (PRs, branches, builds, activity), PR write actions (create / comment / approve / decline / merge), branch writes, and repository administration (settings, webhooks, permissions). PATs are stored in the OS keyring — never in <code>.c3/config.json</code>.</p>
1305
+
1306
+ <table>
1307
+ <tr><th>Surface</th><th>What it provides</th></tr>
1308
+ <tr><td><code>c3_bitbucket</code> MCP tool</td><td>Action-dispatched Bitbucket calls from Claude Code (27 actions across read / PR-write / branch / admin)</td></tr>
1309
+ <tr><td><code>c3 bitbucket {login,logout,status,use,set-default}</code></td><td>CLI for credential and default-repo management. Login uses <code>getpass</code> for masked PAT entry.</td></tr>
1310
+ <tr><td>Hub UI Bitbucket tab</td><td>Per-project visual browser: Overview, Pull Requests, Branches, Activity, Admin</td></tr>
1311
+ <tr><td><code>/api/bitbucket/*</code> REST endpoints</td><td>Project session-server endpoints proxying to <code>BitbucketDataCenterClient</code></td></tr>
1312
+ </table>
1313
+
1314
+ <div class="tip">
1315
+ <div class="tip-title">Where the token lives</div>
1316
+ Tokens are stored exclusively in the <strong>OS keyring</strong> (Windows Credential Manager / macOS Keychain / Linux Secret Service) under service <code>c3-bitbucket</code>. Only a non-secret index of <code>(base_url, username)</code> pairs and the active-account pointer is written to <code>.c3/config.json</code>. <code>.c3/</code> is gitignored.
1317
+ </div>
1318
+
1319
+ <p><strong>Quick start</strong></p>
1320
+ <pre><code>c3 bitbucket login --url https://bitbucket.example.com
1321
+ c3 bitbucket set-default --project PROJ --repo my-service
1322
+ c3 bitbucket status</code></pre>
1323
+
1324
+ <p>From Claude Code:</p>
1325
+ <pre><code>c3_bitbucket(action='list_prs', state='OPEN')
1326
+ c3_bitbucket(action='get_pr', pr_id=42)
1327
+ c3_bitbucket(action='approve_pr', pr_id=42)
1328
+ c3_bitbucket(action='merge_pr', pr_id=42) # version auto-fetched</code></pre>
1329
+
1330
+ <div class="tip">
1331
+ <div class="tip-title">Audit trail</div>
1332
+ PR merges, branch deletes, webhook writes, and other mutating actions are appended to the C3 edit ledger under the virtual path <code>bitbucket://&lt;project&gt;/&lt;repo&gt;</code> so the local audit trail covers platform-side state changes too. Token text is stripped from logged kwargs before persisting.
1333
+ </div>
1334
+
1335
+ <p>Full reference (action tables, security model, configuration shape, examples, troubleshooting): <a href="/guide/bitbucket.html">Bitbucket integration guide</a>. Currently <strong>Bitbucket Cloud</strong> (<code>bitbucket.org</code>) is not supported — only Data Center / Server.</p>
1336
+
1301
1337
  <h3 id="agent-tools">Background Agents</h3>
1302
1338
  <p>C3 runs 9 autonomous daemon threads that perform periodic analysis and surface findings via a notification
1303
1339
  queue. Notifications are automatically prepended to the next MCP tool response so Claude sees them naturally.
cli/hook_ghost_files.py CHANGED
@@ -35,6 +35,16 @@ _PYTHON_TYPE_NAMES = {
35
35
  "Awaitable", "Coroutine", "AsyncIterator", "AsyncGenerator",
36
36
  }
37
37
 
38
+ # Common heredoc / here-string end-markers that leak as filenames when
39
+ # Bash or PowerShell misparses a `<<EOF` / `@'...'@` block. Match is
40
+ # size-agnostic: a non-empty `EOF` file is still a ghost.
41
+ _HEREDOC_MARKERS = {
42
+ "EOF", "EOM", "EOL", "END", "STOP", "DONE",
43
+ "MARK", "MARKER", "DELIM", "DELIMITER",
44
+ "INPUT", "OUTPUT", "DATA", "BLOCK", "HEREDOC",
45
+ "'@", "@'", # PowerShell here-string fragments
46
+ }
47
+
38
48
  # Max file size to consider a ghost (bytes). Genuine files are usually larger.
39
49
  _MAX_GHOST_SIZE = 4096
40
50
 
@@ -108,6 +118,10 @@ def _is_ghost_file(path: Path) -> bool:
108
118
  if name in _PYTHON_TYPE_NAMES:
109
119
  return True
110
120
 
121
+ # HEREDOC end-marker leaked as filename (e.g., "EOF", "'@", "END")
122
+ if name in _HEREDOC_MARKERS and not real_suffix:
123
+ return True
124
+
111
125
  # Partial type annotation (e.g., "tuple[float", "dict[str")
112
126
  if "[" in name and not real_suffix:
113
127
  return True
@@ -154,7 +168,9 @@ def scan_ghost_files(project_root: Path) -> list[dict]:
154
168
  name = entry.name
155
169
 
156
170
  # Determine reason
157
- if name in _PYTHON_TYPE_NAMES:
171
+ if name in _HEREDOC_MARKERS:
172
+ reason = "heredoc end-marker leak"
173
+ elif name in _PYTHON_TYPE_NAMES:
158
174
  reason = "Python type name"
159
175
  elif "[" in name:
160
176
  reason = "partial type annotation"
cli/hub.html CHANGED
@@ -995,6 +995,49 @@
995
995
  </div>
996
996
  </div>
997
997
 
998
+ <!-- Merge Modal -->
999
+ <div class="modal-backdrop hidden" id="merge-modal">
1000
+ <div class="modal">
1001
+ <div class="modal-header">
1002
+ <h3>Merge Projects</h3>
1003
+ <button class="modal-close" onclick="closeModal('merge-modal')">×</button>
1004
+ </div>
1005
+ <div class="modal-body">
1006
+ <div class="form-group">
1007
+ <span class="form-label">Merging from</span>
1008
+ <div class="form-path" id="merge-modal-source-path"></div>
1009
+ </div>
1010
+ <div class="form-group">
1011
+ <label class="form-label" for="merge-target-select">Merge into</label>
1012
+ <select class="form-input" id="merge-target-select"></select>
1013
+ </div>
1014
+ <div class="form-group">
1015
+ <span class="form-label">After merge</span>
1016
+ <div style="display:flex;flex-direction:column;gap:.4rem;margin-top:.3rem;">
1017
+ <label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;">
1018
+ <input type="radio" name="merge-cleanup" value="keep" checked onchange="updateMergeWarning()">
1019
+ <span>Keep source project intact</span>
1020
+ </label>
1021
+ <label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;">
1022
+ <input type="radio" name="merge-cleanup" value="clear" onchange="updateMergeWarning()">
1023
+ <span>Clear source (wipe .c3/, MCP configs &amp; instruction docs)</span>
1024
+ </label>
1025
+ </div>
1026
+ </div>
1027
+ <div id="merge-warning" style="display:none;border:1px solid #b13c3c;background:rgba(177,60,60,.1);padding:.6rem .75rem;border-radius:4px;margin-top:.5rem;color:var(--text);font-size:.85rem;">
1028
+ ⚠ The source project's <code>.c3/</code> directory, MCP configs (<code>.mcp.json</code>, <code>.claude/settings.local.json</code>, <code>.codex/</code>) and instruction docs (CLAUDE.md, GEMINI.md, AGENTS.md) will be deleted. The source directory itself stays in place.
1029
+ </div>
1030
+ <p style="font-size:.8rem;color:var(--text-muted);margin-top:.5rem;">
1031
+ Combines memory facts, conversation sessions, and edit-ledger entries into the target. File-memory and indices are not merged.
1032
+ </p>
1033
+ </div>
1034
+ <div class="modal-footer">
1035
+ <button class="btn btn-ghost" onclick="closeModal('merge-modal')">Cancel</button>
1036
+ <button class="btn btn-primary" id="merge-save-btn" onclick="saveMerge()">Merge</button>
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+
998
1041
  <!-- Settings Modal -->
999
1042
  <div class="modal-backdrop hidden" id="settings-modal">
1000
1043
  <div class="modal">
@@ -1186,6 +1229,8 @@ let sessionPollTimer = null;
1186
1229
  let modalPath = null;
1187
1230
  let editPath = null;
1188
1231
  let transferPath = null;
1232
+ let mergeSourcePath = null;
1233
+ let mergeSourceName = '';
1189
1234
  let idePath = null;
1190
1235
  let ideSelected = null;
1191
1236
  let mcpModalPath = null;
@@ -1261,7 +1306,7 @@ document.addEventListener('DOMContentLoaded', async () => {
1261
1306
  );
1262
1307
  document.addEventListener('keydown', e => {
1263
1308
  if (e.key === 'Escape') {
1264
- ['mcp-modal','edit-modal','transfer-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
1309
+ ['mcp-modal','edit-modal','transfer-modal','merge-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
1265
1310
  if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
1266
1311
  });
1267
1312
  }
@@ -1712,6 +1757,7 @@ function projectCard(p) {
1712
1757
  const editBtn = `<button class="btn btn-xs btn-ghost" onclick="openEditModal('${jsq(p.path)}','${jsq(p.name)}','${jsq((p.tags||[]).join(','))}')">✏ Edit</button>`;
1713
1758
  const ideBtn = `<button class="btn btn-xs btn-primary" onclick="openIdeModal('${jsq(p.path)}','${jsq(p.ide)}')">&#9654; IDE</button>`;
1714
1759
  const transferBtn = !active ? `<button class="btn btn-xs btn-ghost" onclick="openTransferModal('${jsq(p.path)}','${jsq(p.name)}')">Transfer</button>` : '';
1760
+ const mergeBtn = !active ? `<button class="btn btn-xs btn-ghost" onclick="openMergeModal('${jsq(p.path)}','${jsq(p.name)}')" title="Merge memory, sessions and edit ledger into another project">⇄ Merge</button>` : '';
1715
1761
  const removeBtn = !active ? `<button class="btn btn-xs btn-danger" onclick="removeProject('${jsq(p.path)}','${jsq(p.name)}')">✕ Remove</button>` : '';
1716
1762
 
1717
1763
  const isLaunching = !active && launchingPaths.has(p.path);
@@ -1741,7 +1787,7 @@ function projectCard(p) {
1741
1787
  ${startBtn}${startUiBtn}${openBtn}${restartBtn}${stopBtn}${autostartBtn}${folderBtn}${logBtn}${ledgerBtn}${setupBtn}${ideBtn}
1742
1788
  </div>
1743
1789
  <div class="card-actions-secondary">
1744
- ${clearNotifBtn}${editBtn}${transferBtn}${removeBtn}
1790
+ ${clearNotifBtn}${editBtn}${transferBtn}${mergeBtn}${removeBtn}
1745
1791
  </div>
1746
1792
  </div>
1747
1793
  </div>`;
@@ -2977,6 +3023,84 @@ async function saveTransfer() {
2977
3023
  }
2978
3024
  }
2979
3025
 
3026
+ // ── Merge Modal ───────────────────────────────────────────────────────────
3027
+
3028
+ function openMergeModal(path, name) {
3029
+ mergeSourcePath = path;
3030
+ mergeSourceName = name;
3031
+ document.getElementById('merge-modal-source-path').textContent = `${name} (${path})`;
3032
+ // Populate target dropdown with idle projects (exclude source and active sessions).
3033
+ const sel = document.getElementById('merge-target-select');
3034
+ sel.innerHTML = '';
3035
+ const candidates = (allProjects || []).filter(p => p.path !== path && !p.session_active && p.port == null);
3036
+ if (!candidates.length) {
3037
+ const opt = document.createElement('option');
3038
+ opt.value = '';
3039
+ opt.textContent = '— No eligible target projects —';
3040
+ sel.appendChild(opt);
3041
+ document.getElementById('merge-save-btn').disabled = true;
3042
+ } else {
3043
+ candidates.forEach(p => {
3044
+ const opt = document.createElement('option');
3045
+ opt.value = p.path;
3046
+ opt.textContent = `${p.name} (${p.path})`;
3047
+ sel.appendChild(opt);
3048
+ });
3049
+ document.getElementById('merge-save-btn').disabled = false;
3050
+ }
3051
+ // Reset cleanup radio + warning
3052
+ document.querySelectorAll('input[name="merge-cleanup"]').forEach(r => { r.checked = (r.value === 'keep'); });
3053
+ updateMergeWarning();
3054
+ openModal('merge-modal');
3055
+ }
3056
+
3057
+ function updateMergeWarning() {
3058
+ const cleanup = (document.querySelector('input[name="merge-cleanup"]:checked') || {}).value || 'keep';
3059
+ document.getElementById('merge-warning').style.display = cleanup === 'clear' ? 'block' : 'none';
3060
+ }
3061
+
3062
+ async function saveMerge() {
3063
+ if (!mergeSourcePath) return;
3064
+ const target = document.getElementById('merge-target-select').value;
3065
+ if (!target) { toast('Pick a target project', 'err'); return; }
3066
+ const cleanup = (document.querySelector('input[name="merge-cleanup"]:checked') || {}).value || 'keep';
3067
+ if (cleanup === 'clear') {
3068
+ const ok = await confirmDialog({
3069
+ title: 'Clear source after merge?',
3070
+ message: `This will permanently delete .c3/, MCP configs and instruction docs from "${mergeSourceName}". The merged data will live on inside the target. Continue?`,
3071
+ confirmText: 'Merge & clear',
3072
+ danger: true,
3073
+ });
3074
+ if (!ok) return;
3075
+ }
3076
+ const btn = document.getElementById('merge-save-btn');
3077
+ btn.disabled = true;
3078
+ try {
3079
+ const r = await fetch('/api/projects/merge', {
3080
+ method: 'POST',
3081
+ headers: {'Content-Type':'application/json'},
3082
+ body: JSON.stringify({source_path: mergeSourcePath, target_path: target, cleanup}),
3083
+ });
3084
+ const d = await r.json();
3085
+ if (d.error) throw new Error(d.error);
3086
+ if (!d.merged) throw new Error('Merge did not complete');
3087
+ const s = d.stats || {};
3088
+ const summary = `Merged ${s.facts||0} facts, ${s.sessions||0} sessions, ${s.ledger_entries||0} ledger entries`;
3089
+ toast(summary + (cleanup === 'clear' ? ' — source cleared' : ''), 'ok');
3090
+ if (d.warnings && d.warnings.length) {
3091
+ console.warn('merge warnings:', d.warnings);
3092
+ }
3093
+ closeModal('merge-modal');
3094
+ delete detailsCache[mergeSourcePath];
3095
+ delete detailsCache[target];
3096
+ loadProjects();
3097
+ } catch(e) {
3098
+ toast('Merge error: ' + e.message, 'err');
3099
+ } finally {
3100
+ btn.disabled = false;
3101
+ }
3102
+ }
3103
+
2980
3104
  // ── Settings Modal ─────────────────────────────────────────────────────────
2981
3105
  async function openSettings() {
2982
3106
  try {
@@ -3166,11 +3290,12 @@ function closeModal(id) {
3166
3290
  if (id === 'update-modal') { modalPath = null; }
3167
3291
  if (id === 'edit-modal') { editPath = null; }
3168
3292
  if (id === 'transfer-modal') { transferPath = null; }
3293
+ if (id === 'merge-modal') { mergeSourcePath = null; mergeSourceName = ''; }
3169
3294
  if (id === 'c3-setup-modal') { c3SetupPath = null; }
3170
3295
  }
3171
3296
 
3172
3297
  // Close modals on backdrop click
3173
- ['mcp-modal','edit-modal','transfer-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
3298
+ ['mcp-modal','edit-modal','transfer-modal','merge-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
3174
3299
  document.getElementById(id).addEventListener('click', e => {
3175
3300
  if (e.target === e.currentTarget) closeModal(id);
3176
3301
  });
cli/hub_server.py CHANGED
@@ -699,6 +699,29 @@ def api_projects_transfer():
699
699
  return jsonify({"error": str(e)}), 500
700
700
 
701
701
 
702
+ @app.route("/api/projects/merge", methods=["POST"])
703
+ def api_projects_merge():
704
+ """Merge source project's memory/sessions/ledger into target.
705
+
706
+ Body: {source_path, target_path, cleanup: 'keep'|'clear'}
707
+ """
708
+ data = request.get_json(force=True) or {}
709
+ src = (data.get("source_path") or "").strip()
710
+ tgt = (data.get("target_path") or "").strip()
711
+ cleanup = (data.get("cleanup") or "keep").strip().lower()
712
+ if not src or not tgt:
713
+ return jsonify({"error": "source_path and target_path are required"}), 400
714
+ if cleanup not in ("keep", "clear"):
715
+ return jsonify({"error": "cleanup must be 'keep' or 'clear'"}), 400
716
+ try:
717
+ result = _pm().merge_projects(src, tgt, cleanup=cleanup)
718
+ if result.get("error"):
719
+ return jsonify(result), 400
720
+ return jsonify(result)
721
+ except Exception as e:
722
+ return jsonify({"error": str(e)}), 500
723
+
724
+
702
725
  @app.route("/api/projects/details", methods=["POST"])
703
726
  def api_projects_details():
704
727
  data = request.get_json(force=True) or {}
cli/mcp_server.py CHANGED
@@ -649,6 +649,56 @@ async def c3_shell(cmd: str, cwd: str = "", timeout: int = 60,
649
649
  return await handle_shell(cmd, cwd, timeout, filter_output, log, svc, finalize)
650
650
 
651
651
 
652
+ @mcp.tool()
653
+ async def c3_bitbucket(
654
+ action: str,
655
+ project: str = "",
656
+ repo: str = "",
657
+ pr_id: int = 0,
658
+ branch: str = "",
659
+ state: str = "OPEN",
660
+ title: str = "",
661
+ body: str = "",
662
+ from_branch: str = "",
663
+ to_branch: str = "",
664
+ description: str = "",
665
+ reviewers: str = "",
666
+ name: str = "",
667
+ url: str = "",
668
+ events: str = "",
669
+ start_point: str = "",
670
+ commit: str = "",
671
+ settings: str = "",
672
+ webhook_id: int = 0,
673
+ limit: int = 50,
674
+ ctx: Context = None,
675
+ ) -> str:
676
+ """BITBUCKET (Data Center / Server) — see and act on PRs, branches, builds, repo admin.
677
+ actions: status, whoami, list_projects, list_repos, get_repo,
678
+ list_prs, get_pr, get_pr_diff, get_pr_activities,
679
+ create_pr, comment_pr, approve_pr, unapprove_pr, decline_pr, merge_pr,
680
+ list_branches, create_branch, delete_branch,
681
+ list_commits, list_activity, build_status,
682
+ repo_settings, update_repo_settings, list_webhooks, create_webhook, delete_webhook,
683
+ list_permissions.
684
+ project/repo fall back to bitbucket.default_project/default_repo from .c3/config.json.
685
+ Tokens live in the OS keyring — `c3 bitbucket login` to set them up first."""
686
+ svc = _svc(ctx)
687
+
688
+ def finalize(fname, fargs, fresp, fsumm, **kw):
689
+ return _finalize_response(ctx, fname, fargs, fresp, fsumm, **kw)
690
+
691
+ from cli.tools.bitbucket import handle_bitbucket
692
+ return await asyncio.to_thread(
693
+ handle_bitbucket, action, svc, finalize,
694
+ project=project, repo=repo, pr_id=pr_id, branch=branch, state=state,
695
+ title=title, body=body, from_branch=from_branch, to_branch=to_branch,
696
+ description=description, reviewers=reviewers, name=name, url=url,
697
+ events=events, start_point=start_point, commit=commit,
698
+ settings=settings, webhook_id=webhook_id, limit=limit,
699
+ )
700
+
701
+
652
702
  def main() -> None:
653
703
  """Entry-point for the ``c3-mcp`` console script."""
654
704
  from services import error_reporting