code-context-control 2.58.0__py3-none-any.whl → 2.60.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
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.58.0"
95
+ __version__ = "2.60.0"
96
96
 
97
97
 
98
98
  def _command_deps() -> CommandDeps:
@@ -1313,6 +1313,12 @@ def cmd_claudemd(args):
1313
1313
  return common_cmd_claudemd(args, _command_deps())
1314
1314
 
1315
1315
 
1316
+ def cmd_map(args):
1317
+ """Live repo map (.c3/MAP.md) commands."""
1318
+ from cli.commands.common import cmd_map as common_cmd_map
1319
+ return common_cmd_map(args, _command_deps())
1320
+
1321
+
1316
1322
  def cmd_stats(args):
1317
1323
  """Show comprehensive stats."""
1318
1324
  return common_cmd_stats(args, _command_deps())
@@ -7162,6 +7168,7 @@ def main():
7162
7168
  "decode": cmd_decode,
7163
7169
  "session": cmd_session,
7164
7170
  "claudemd": cmd_claudemd,
7171
+ "map": cmd_map,
7165
7172
  "stats": cmd_stats,
7166
7173
  "benchmark": cmd_benchmark,
7167
7174
  "session-benchmark": cmd_session_benchmark,
cli/commands/common.py CHANGED
@@ -159,6 +159,57 @@ def cmd_session(args, deps: CommandDeps):
159
159
  print(context)
160
160
 
161
161
 
162
+ def cmd_map(args, deps: CommandDeps):
163
+ """Live repo map (.c3/MAP.md): status | ensure | refresh."""
164
+ config = deps.load_config()
165
+ project_path = config.get("project_path", ".")
166
+
167
+ from services.repo_map import RepoMapService
168
+
169
+ sm = deps.SessionManager(project_path)
170
+ svc = RepoMapService(project_path, session_mgr=sm)
171
+
172
+ if args.map_cmd == "status":
173
+ result = svc.status()
174
+ elif args.map_cmd == "ensure":
175
+ result = svc.ensure()
176
+ else:
177
+ result = svc.refresh()
178
+
179
+ if getattr(args, "json", False):
180
+ print(json.dumps(result, indent=2))
181
+ return
182
+
183
+ if args.map_cmd == "status":
184
+ state = "STALE" if result["stale"] else ("FRESH" if result["exists"] else "MISSING")
185
+ deps.print_header(f"Repo Map — {state}")
186
+ print(f" Path: {svc.map_path}")
187
+ if result.get("generated_at"):
188
+ print(f" Generated: {result['generated_at']}")
189
+ if result.get("tokens"):
190
+ print(f" Tokens: {result['tokens']}")
191
+ if result.get("reasons"):
192
+ print(f" Stale reasons: {', '.join(result['reasons'])}")
193
+ if result.get("truncated"):
194
+ print(" [WARN] Map is truncated (file cap or token budget hit)")
195
+ else:
196
+ action = result.get("action", "?")
197
+ label = {
198
+ "regenerated": "Map regenerated",
199
+ "meta_only": "Content unchanged (meta refreshed)",
200
+ "fresh": "Already fresh — nothing to do",
201
+ "locked": "Another process is regenerating — skipped",
202
+ "disabled": "Repo map disabled (map.enabled=false in .c3/config.json)",
203
+ }.get(action, action)
204
+ deps.print_header(f"Repo Map — {label}")
205
+ if result.get("tokens"):
206
+ print(f" Tokens: {result['tokens']}, duration: {result.get('duration_ms', '?')}ms")
207
+ if result.get("reasons"):
208
+ print(f" Triggered by: {', '.join(result['reasons'])}")
209
+ if result.get("truncated"):
210
+ print(" [WARN] Map is truncated (file cap or token budget hit)")
211
+
212
+
162
213
  def cmd_claudemd(args, deps: CommandDeps):
163
214
  """Instructions file generation commands."""
164
215
  config = deps.load_config()
cli/commands/parser.py CHANGED
@@ -61,6 +61,10 @@ def build_parser(version: str, parse_cli_ide_arg):
61
61
  p_claudemd.add_argument("claudemd_cmd", choices=["generate", "save", "check"])
62
62
  p_claudemd.add_argument("--nano", action="store_true", help="Generate nano mode (~250 tokens) instead of full compact mode")
63
63
 
64
+ p_map = subparsers.add_parser("map", help="Live repo map (.c3/MAP.md) management")
65
+ p_map.add_argument("map_cmd", choices=["status", "ensure", "refresh"])
66
+ p_map.add_argument("--json", action="store_true", help="Emit JSON result")
67
+
64
68
  subparsers.add_parser("stats", help="Show statistics")
65
69
 
66
70
  p_benchmark = subparsers.add_parser("benchmark", help="Run with/without-C3 workflow benchmark")
cli/guide/tools.html CHANGED
@@ -125,6 +125,8 @@
125
125
  <div class="sidebar-label">SCM</div>
126
126
  <a href="#c3_task" class="sidebar-link"><span class="icon">✅</span> c3_task</a>
127
127
  <a href="#c3_bitbucket" class="sidebar-link"><span class="icon">🪣</span> c3_bitbucket</a>
128
+ <a href="#c3_credentials" class="sidebar-link"><span class="icon">🔐</span> c3_credentials</a>
129
+ <a href="#c3_jira" class="sidebar-link"><span class="icon">🎫</span> c3_jira</a>
128
130
  </div>
129
131
  <div class="sidebar-section">
130
132
  <div class="sidebar-label">Cross-project</div>
@@ -161,6 +163,8 @@
161
163
  <a href="#c3_task" class="toc-item">c3_task <span class="cat">PM</span></a>
162
164
  <a href="#c3_artifacts" class="toc-item">c3_artifacts <span class="cat">Config</span></a>
163
165
  <a href="#c3_bitbucket" class="toc-item">c3_bitbucket <span class="cat">SCM</span></a>
166
+ <a href="#c3_credentials" class="toc-item">c3_credentials <span class="cat">Secrets</span></a>
167
+ <a href="#c3_jira" class="toc-item">c3_jira <span class="cat">Issues</span></a>
164
168
  <a href="#c3_project" class="toc-item">c3_project <span class="cat">Multi-project</span></a>
165
169
  </div>
166
170
  </div>
@@ -1239,6 +1243,96 @@ c3_bitbucket(action=<span class="str">'merge_pr'</span>, pr_id=<span class="num"
1239
1243
  <p class="tool-desc"><strong>Audit trail:</strong> mutating actions (<code>merge_pr</code>, <code>create_branch</code>, <code>delete_branch</code>, webhook writes, etc.) are appended to the C3 edit ledger so the local audit log covers platform-side state changes too.</p>
1240
1244
  </div>
1241
1245
  </div>
1246
+
1247
+ <!-- c3_credentials -->
1248
+ <div class="tool-card" id="c3_credentials">
1249
+ <div class="tool-card-header">
1250
+ <span class="tool-name">c3_credentials</span>
1251
+ <div class="tag-row">
1252
+ <span class="badge badge-purple">secrets</span>
1253
+ <span class="badge">v2.58.0</span>
1254
+ </div>
1255
+ <span class="tool-tagline">Credential vault — the agent uses secrets by name, never by value</span>
1256
+ </div>
1257
+ <div class="tool-card-body">
1258
+ <p class="tool-desc">A user-managed secret store, <strong>global</strong> (<code>~/.c3</code>, visible in every project) or <strong>per-project</strong> (<code>.c3</code>, shadows the same-named global). Values live in the OS keyring (service <code>c3-creds</code>; values over ~1KB in a Fernet-encrypted <code>.c3/secrets.enc</code> whose random master key is itself a keyring entry) — never in config files, and <em>never in the model's context</em>. <strong>Injection-first:</strong> the agent addresses secrets by name and C3 decodes them only at the subprocess boundary — <code>c3_shell(env_creds='A,B')</code> injects env vars, <code>{{cred:NAME}}</code> inside <code>cmd</code> expands server-side, and <code>inject</code>-flagged entries auto-inject into every shell run; echoed values are redacted back to <code>[cred:NAME]</code>. Resolution is <strong>realm-atomic</strong>: a project-registered name resolves in the project realm or not at all, so a hostile repo's committed config can never siphon your global values (tested).</p>
1259
+
1260
+ <h4>Actions</h4>
1261
+ <table class="params-table">
1262
+ <thead><tr><th>Action</th><th>Group</th><th>Description</th></tr></thead>
1263
+ <tbody>
1264
+ <tr><td class="param-name">list</td><td>Read</td><td class="param-desc">Merged registry (project shadows global) — names, scope, type, flags, usage. Never values</td></tr>
1265
+ <tr><td class="param-name">describe</td><td>Read</td><td class="param-desc">Metadata + storage + live fingerprint (first 8 hex of SHA-256, computed on demand, never persisted)</td></tr>
1266
+ <tr><td class="param-name">check</td><td>Read</td><td class="param-desc">Resolvability probe — does the stored value decode?</td></tr>
1267
+ <tr><td class="param-name">set</td><td>Write</td><td class="param-desc">Create/replace an entry. <code>agent_readable</code> may be set at creation only — raising it on an existing entry is user-only</td></tr>
1268
+ <tr><td class="param-name">delete</td><td>Write</td><td class="param-desc">Remove value + registry entry (owning scope inferred when omitted)</td></tr>
1269
+ <tr><td class="param-name">reveal</td><td>Gated</td><td class="param-desc">The only value-returning action — refused unless the user enabled <code>agent_readable</code> on that entry</td></tr>
1270
+ </tbody>
1271
+ </table>
1272
+
1273
+ <h4>Examples</h4>
1274
+ <pre><code><span class="com"># What can I use? (names and metadata only)</span>
1275
+ c3_credentials(action=<span class="str">'list'</span>)
1276
+ c3_credentials(action=<span class="str">'check'</span>, name=<span class="str">'NPM_TOKEN'</span>)
1277
+
1278
+ <span class="com"># USE a secret without ever seeing it</span>
1279
+ c3_shell(cmd=<span class="str">'npm publish'</span>, env_creds=<span class="str">'NPM_TOKEN'</span>)
1280
+ c3_shell(cmd=<span class="str">'curl -H "Authorization: Bearer {{cred:API_KEY}}" https://api.example.com'</span>)</code></pre>
1281
+
1282
+ <p class="tool-desc"><strong>Surfaces:</strong> the <code>c3 creds</code> CLI (<code>set</code> / <code>get</code> / <code>list</code> / <code>rm</code> / <code>import .env</code>, <code>--global</code> for the shared scope), a per-project dashboard <strong>Credentials</strong> tab, and — since v2.59.0 — a top-level <strong>Credentials</strong> view in the Hub: a Global-vault manager plus a per-project browser with shadowing shown both ways ("shadows global" on project entries, "shadowed in N projects" on globals); the Hub's per-project drill panel manages too. No HTTP route ever returns a stored value (write-only wire contract, endpoint-sweep tested), every mutation is ledger-logged by name, the vault is hard-excluded from the Oracle Discovery API, and cross-project (<code>c3_project</code>) shells run with credentials disabled.</p>
1283
+ </div>
1284
+ </div>
1285
+
1286
+ <!-- c3_jira -->
1287
+ <div class="tool-card" id="c3_jira">
1288
+ <div class="tool-card-header">
1289
+ <span class="tool-name">c3_jira</span>
1290
+ <div class="tag-row">
1291
+ <span class="badge badge-purple">issue tracking</span>
1292
+ <span class="badge">v2.56.0</span>
1293
+ </div>
1294
+ <span class="tool-tagline">Jira Cloud + Data Center — search, create, transition, comment through one tool</span>
1295
+ </div>
1296
+ <div class="tool-card-body">
1297
+ <p class="tool-desc">One tool for both Jira deployments: <strong>Cloud</strong> (REST v3, email + API token, ADF bodies handled transparently) and self-hosted <strong>Data Center / Server</strong> (REST v2, PAT Bearer, plain-text bodies) — normalized to a single DTO surface with an opaque pagination cursor. Transport is stdlib <code>urllib</code> (no new dependencies); reads get one bounded 429 retry honoring <code>Retry-After</code>, mutations are <em>never</em> auto-retried. Tokens live in the OS keyring keyed by <code>(base_url, username)</code>; the <code>jira</code> config section resolves project → home <strong>wholesale from a single file</strong>, so a repository's committed config can never field-override a home account's URL or TLS settings (credential-redirect hardening). HTTPS-only.</p>
1298
+
1299
+ <p class="tool-desc"><strong>Setup:</strong> <code>c3 jira login --url https://yoursite.atlassian.net</code> (Cloud inferred for <code>*.atlassian.net</code>; add <code>--deployment data_center</code> for self-hosted, <code>--ca-bundle</code> for enterprise certs, <code>--global</code> for a home config reusable across projects), then <code>c3 jira set-default --project PROJ</code>. Manage accounts with <code>c3 jira status / use / logout</code>.</p>
1300
+
1301
+ <h4>Actions</h4>
1302
+ <table class="params-table">
1303
+ <thead><tr><th>Action</th><th>Group</th><th>Description</th></tr></thead>
1304
+ <tbody>
1305
+ <tr><td class="param-name">status, whoami</td><td>Read</td><td class="param-desc">Active account, deployment, defaults, server probe / authenticated user</td></tr>
1306
+ <tr><td class="param-name">search</td><td>Read</td><td class="param-desc">Raw JQL, paginated (helper-built JQL elsewhere is always quoted)</td></tr>
1307
+ <tr><td class="param-name">my_issues</td><td>Read</td><td class="param-desc">Open issues assigned to the token's user (statusCategory-aware)</td></tr>
1308
+ <tr><td class="param-name">get_issue, list_projects, list_transitions</td><td>Read</td><td class="param-desc">Issue detail with comments / project discovery / legal transitions for an issue</td></tr>
1309
+ <tr><td class="param-name">get_create_metadata, search_users</td><td>Read</td><td class="param-desc">Required + allowed fields per project/type; user lookup for assignment</td></tr>
1310
+ <tr><td class="param-name">create_issue</td><td>Write</td><td class="param-desc">Pre-validates against create metadata and returns machine-readable missing required fields instead of guessing defaults</td></tr>
1311
+ <tr><td class="param-name">comment, transition, assign</td><td>Write</td><td class="param-desc">Comment on / move (accepts transition id <em>or</em> name) / assign an issue</td></tr>
1312
+ </tbody>
1313
+ </table>
1314
+
1315
+ <h4>Examples</h4>
1316
+ <pre><code><span class="com"># Connection + account probe, then my open work</span>
1317
+ c3_jira(action=<span class="str">'status'</span>)
1318
+ c3_jira(action=<span class="str">'my_issues'</span>)
1319
+
1320
+ <span class="com"># Raw JQL search</span>
1321
+ c3_jira(action=<span class="str">'search'</span>, jql=<span class="str">'project = PROJ AND status != Done ORDER BY updated DESC'</span>)
1322
+
1323
+ <span class="com"># Create — required fields come from metadata, not guesses</span>
1324
+ c3_jira(action=<span class="str">'get_create_metadata'</span>, project=<span class="str">'PROJ'</span>, issue_type=<span class="str">'Task'</span>)
1325
+ c3_jira(action=<span class="str">'create_issue'</span>, project=<span class="str">'PROJ'</span>, issue_type=<span class="str">'Task'</span>,
1326
+ summary=<span class="str">'Fix login flow'</span>, description=<span class="str">'Steps in thread'</span>)
1327
+
1328
+ <span class="com"># Move it along — id or name both work</span>
1329
+ c3_jira(action=<span class="str">'list_transitions'</span>, issue=<span class="str">'PROJ-123'</span>)
1330
+ c3_jira(action=<span class="str">'transition'</span>, issue=<span class="str">'PROJ-123'</span>, transition=<span class="str">'In Progress'</span>)
1331
+ c3_jira(action=<span class="str">'comment'</span>, issue=<span class="str">'PROJ-123'</span>, body=<span class="str">'Deployed to staging'</span>)</code></pre>
1332
+
1333
+ <p class="tool-desc"><strong>Surfaces &amp; audit:</strong> the per-project dashboard gets a <strong>Jira tab</strong> (My Work board grouped by statusCategory, JQL search, an issue drawer with transition buttons + comments, and an Activity view) backed by <code>/api/jira/*</code> routes. Issue keys like <code>PROJ-123</code> are auto-linked from branch names and edit-ledger entries (acronym denylist, so UTF-8 / SHA-256 / CVE-2024 never match), and the issue drawer shows the local ledger activity for the open issue — that Activity view works with no account configured. Mutations (<code>create_issue</code>, <code>comment</code>, <code>transition</code>, <code>assign</code>) are edit-ledger-logged with identifiers only — bodies are never logged. Read actions are safe in plan mode.</p>
1334
+ </div>
1335
+ </div>
1242
1336
  </div>
1243
1337
 
1244
1338
  <!-- ═══════════════════════════════════════ CROSS-PROJECT ═════ -->
cli/guide/workflow.html CHANGED
@@ -250,6 +250,7 @@
250
250
  <a href="#session-restart" class="sidebar-link"><span class="icon">🔄</span> Session Restart</a>
251
251
  <a href="#terse-skill" class="sidebar-link"><span class="icon">⚡</span> /terse Skill</a>
252
252
  <a href="#subprojects" class="sidebar-link"><span class="icon">🌳</span> Sub-projects</a>
253
+ <a href="#repo-map" class="sidebar-link"><span class="icon">🗺️</span> Live Repo Map</a>
253
254
  <a href="#native-fallback" class="sidebar-link"><span class="icon">⚠️</span> Native Fallback</a>
254
255
  </div>
255
256
  </aside>
@@ -984,6 +985,53 @@ c3_memory(action=<span class="str">'recall'</span>, query=<span class="str">'pay
984
985
 
985
986
  <hr class="divider">
986
987
 
988
+ <!-- Live repo map -->
989
+ <section class="section" id="repo-map">
990
+ <div class="section-title"><span class="icon">🗺️</span> Live Repo Map (v2.60.0)</div>
991
+
992
+ <p>Instruction docs (CLAUDE.md / AGENTS.md) no longer embed a frozen project tree. They carry a stable pointer to <code>.c3/MAP.md</code> — a machine-owned map C3 keeps fresh automatically. One map serves every consumer: Claude Code, Codex, Antigravity.</p>
993
+
994
+ <p>The map contains, in priority order: build/test commands, entry points, module one-liners, the depth-2 tree, and key files — under a token budget (default 1000). Sub-projects appear as boundaries and are never expanded into the parent map.</p>
995
+
996
+ <h4>How it stays fresh</h4>
997
+ <table class="table">
998
+ <thead><tr><th>Trigger</th><th>What happens</th></tr></thead>
999
+ <tbody>
1000
+ <tr>
1001
+ <td>Structural edit (file created/deleted/renamed, manifest changed)</td>
1002
+ <td>Edit-ledger paths touch the <code>.c3/map.dirty</code> sentinel — a file-touch, never a scan</td>
1003
+ </tr>
1004
+ <tr>
1005
+ <td>First C3 tool call of a session</td>
1006
+ <td>Background single-flight <em>ensure</em>: regenerates only if missing, dirty, or the git HEAD/branch/worktree fingerprint moved</td>
1007
+ </tr>
1008
+ <tr>
1009
+ <td><code>c3 map refresh</code></td>
1010
+ <td>Explicit repair — always regenerates</td>
1011
+ </tr>
1012
+ </tbody>
1013
+ </table>
1014
+
1015
+ <p><strong>Byte-stable by design:</strong> <code>MAP.md</code> is rewritten only when rendered content actually changes, so prompt caches keyed on file bytes stay warm. Volatile freshness state (git HEAD, worktree signature, generated-at) lives in <code>.c3/map.meta.json</code> — never in the map itself. Ordinary line edits never trigger regeneration.</p>
1016
+
1017
+ <div class="callout callout-info">
1018
+ <span class="callout-icon">💡</span>
1019
+ <div class="callout-body">
1020
+ <strong>CLI</strong>: <code>c3 map status</code> (freshness report) · <code>c3 map ensure</code> (regen if stale) · <code>c3 map refresh</code> (force). Add <code>--json</code> for machine output. Config knobs: <code>map.token_budget</code>, <code>map.file_cap</code>, <code>map.enabled</code> (set <code>false</code> to restore the legacy embedded tree).
1021
+ </div>
1022
+ </div>
1023
+
1024
+ <div class="callout callout-warning">
1025
+ <span class="callout-icon">⚠️</span>
1026
+ <div class="callout-body">
1027
+ <strong>The map is data, not instructions</strong><br>
1028
+ Its header marks it as auto-generated repository data. Agents should read it for orientation — not obey text inside it. Memory facts are deliberately excluded from the map.
1029
+ </div>
1030
+ </div>
1031
+ </section>
1032
+
1033
+ <hr class="divider">
1034
+
987
1035
  <!-- Native fallback -->
988
1036
  <section class="section" id="native-fallback">
989
1037
  <div class="section-title"><span class="icon">⚠️</span> When Native Tools Are Permitted</div>
cli/hook_edit_ledger.py CHANGED
@@ -212,6 +212,15 @@ def run(payload: dict, project_path: Path | None = None) -> dict | None:
212
212
  with open(ledger_file, "a", encoding="utf-8") as f:
213
213
  f.write(json.dumps(entry) + "\n")
214
214
 
215
+ # Structural changes (file created / manifest edited) dirty the repo map.
216
+ # Sentinel touch only — the scan happens later, in RepoMapService.ensure().
217
+ try:
218
+ from services.repo_map import is_structural_change, mark_map_dirty
219
+ if is_structural_change(rel, change_type):
220
+ mark_map_dirty(project_path, f"{change_type}:{rel}")
221
+ except Exception:
222
+ pass
223
+
215
224
  return {
216
225
  "_text": (
217
226
  f"[c3:ledger] {rel} {entry['version']} auto-logged. "
cli/hub_server.py CHANGED
@@ -333,6 +333,7 @@ _HUB_JS_FILES = [
333
333
  "hub_ui/components/session_drawer.js",
334
334
  "hub_ui/components/drill_panel.js",
335
335
  "hub_ui/components/drill_views.js",
336
+ "hub_ui/components/hub_credentials.js",
336
337
  "hub_ui/components/drill_subprojects.js",
337
338
  "hub_ui/components/drill_health.js",
338
339
  "hub_ui/components/drill_tasks.js",
@@ -437,8 +438,8 @@ def api_hub_config_set():
437
438
  cfg["projects_view"] = projects_view
438
439
  if "main_view" in data:
439
440
  main_view = str(data["main_view"]).strip().lower()
440
- if main_view not in {"projects", "board"}:
441
- return jsonify({"error": "main_view must be 'projects' or 'board'"}), 400
441
+ if main_view not in {"projects", "board", "creds"}:
442
+ return jsonify({"error": "main_view must be 'projects', 'board' or 'creds'"}), 400
442
443
  cfg["main_view"] = main_view
443
444
  if "oracle_url" in data:
444
445
  cfg["oracle_url"] = str(data["oracle_url"]).strip()
@@ -1773,12 +1774,100 @@ def api_projects_config_get():
1773
1774
  "defaults": {k: _config_defaults(k) for k in _CONFIG_READ_SECTIONS}})
1774
1775
 
1775
1776
 
1777
+ # ── Credentials (hub) ────────────────────────────────────────────────────────
1778
+ # Write-only wire contract, hub edition: values go IN over POST and are never
1779
+ # returned by any hub route (no reveal exists here). `credentials` stays out of
1780
+ # _CONFIG_WRITE_SECTIONS — these dedicated routes are the only hub write path.
1781
+
1782
+ _CRED_PUBLIC_FIELDS = ("scope", "type", "value_len", "env_var", "inject",
1783
+ "agent_readable", "description", "storage", "created",
1784
+ "updated")
1785
+
1786
+
1787
+ def _cred_entry_public(name, entry, *, usage=None, shadows_global=None):
1788
+ """Explicit allowlist serializer — structurally cannot emit a value."""
1789
+ rec = {"name": name}
1790
+ for key in _CRED_PUBLIC_FIELDS:
1791
+ rec[key] = entry.get(key, "")
1792
+ rec["value_len"] = entry.get("value_len", 0)
1793
+ rec["inject"] = bool(entry.get("inject"))
1794
+ rec["agent_readable"] = bool(entry.get("agent_readable"))
1795
+ if usage is not None:
1796
+ rec["last_used"] = (usage.get(name) or {}).get("last_used", "")
1797
+ rec["use_count"] = (usage.get(name) or {}).get("use_count", 0)
1798
+ if shadows_global is not None:
1799
+ rec["shadows_global"] = bool(shadows_global)
1800
+ return rec
1801
+
1802
+
1803
+ def _resolve_cred_target(path: str, scope: str, *, mutation: bool):
1804
+ """Resolve a credentials request target to (project, store_path, error).
1805
+
1806
+ scope='global' with no path targets the shared vault (~/.c3) directly;
1807
+ project scope requires a registered path. Mutations on a path without a
1808
+ .c3/ dir get 409 needs_init so the hub can't scatter .c3 dirs around."""
1809
+ from services import credential_store as cred_store
1810
+ if scope not in ("project", "global"):
1811
+ return None, "", (jsonify({"error": "scope must be 'project' or 'global'"}), 400)
1812
+ if not path:
1813
+ if scope == "project":
1814
+ return None, "", (jsonify({"error": "path is required for project scope"}), 400)
1815
+ home = cred_store.global_base()
1816
+ if home is None:
1817
+ return None, "", (jsonify({"error": "global scope unresolvable (no home dir)"}), 500)
1818
+ return None, str(home), None
1819
+ try:
1820
+ resolved = _resolve_project_path(path)
1821
+ except ValueError as e:
1822
+ return None, "", (jsonify({"error": str(e)}), 404)
1823
+ if mutation and not (resolved / ".c3").is_dir():
1824
+ return None, "", (jsonify({"error": "not initialized", "needs_init": True}), 409)
1825
+ return resolved, str(resolved), None
1826
+
1827
+
1828
+ def _hub_cred_audit(action: str, name: str, scope: str, project) -> None:
1829
+ """Names only — never values. Failure-safe. Project mutations audit to the
1830
+ target project's ActivityLog + EditLedger; global-scope mutations also land
1831
+ in ~/.c3/activity_log.jsonl so the shared vault keeps its own trail."""
1832
+ if project is not None:
1833
+ try:
1834
+ from services.activity_log import ActivityLog
1835
+ ActivityLog(str(project)).log("cred_action", {
1836
+ "kind": "creds", "action": action, "name": name,
1837
+ "scope": scope, "via": "hub",
1838
+ })
1839
+ except Exception:
1840
+ pass
1841
+ try:
1842
+ from services.edit_ledger import EditLedger
1843
+ EditLedger(str(project)).log_edit(
1844
+ file=f"cred://{name}", change_type=f"cred_{action}",
1845
+ summary=f"{action} {name} ({scope}) via Hub",
1846
+ tags=["creds", action],
1847
+ detail={"kind": "creds", "action": action, "name": name,
1848
+ "scope": scope},
1849
+ )
1850
+ except Exception:
1851
+ pass
1852
+ if scope == "global" or project is None:
1853
+ try:
1854
+ from services import credential_store as cred_store
1855
+ from services.activity_log import ActivityLog
1856
+ home = cred_store.global_base()
1857
+ if home is not None:
1858
+ ActivityLog(str(home)).log("cred_action", {
1859
+ "kind": "creds", "action": action, "name": name,
1860
+ "scope": scope, "via": "hub",
1861
+ })
1862
+ except Exception:
1863
+ pass
1864
+
1865
+
1776
1866
  @app.route("/api/projects/credentials", methods=["GET"])
1777
1867
  def api_projects_credentials():
1778
- """Read-only masked credential registry for a project (global entries +
1779
- project shadows). Values never transit the hub — the explicit field
1780
- allowlist below returns metadata only; management lives in the project UI
1781
- (`credentials` is deliberately absent from _CONFIG_WRITE_SECTIONS)."""
1868
+ """Masked credential registry for a project (global entries + project
1869
+ shadows). Values never transit the hub outbound — the allowlist serializer
1870
+ returns metadata, usage and shadow info only."""
1782
1871
  path = (request.args.get("path") or "").strip()
1783
1872
  if not path:
1784
1873
  return jsonify({"error": "path is required"}), 400
@@ -1787,22 +1876,174 @@ def api_projects_credentials():
1787
1876
  except ValueError as e:
1788
1877
  return jsonify({"error": str(e)}), 404
1789
1878
  from services import credential_store as cred_store
1879
+ usage = cred_store.read_usage_state(str(resolved))
1880
+ home = cred_store.global_base()
1881
+ global_names = set(cred_store.list_entries(str(home))) if home else set()
1790
1882
  entries = []
1791
1883
  for name, entry in cred_store.list_entries(str(resolved)).items():
1792
- entries.append({
1793
- "name": name,
1794
- "scope": entry.get("scope", ""),
1795
- "type": entry.get("type", "token"),
1796
- "value_len": entry.get("value_len", 0),
1797
- "env_var": entry.get("env_var", ""),
1798
- "inject": bool(entry.get("inject")),
1799
- "agent_readable": bool(entry.get("agent_readable")),
1800
- "description": entry.get("description", ""),
1801
- "updated": entry.get("updated", ""),
1802
- })
1884
+ entries.append(_cred_entry_public(
1885
+ name, entry, usage=usage,
1886
+ shadows_global=(entry.get("scope") == "project"
1887
+ and name in global_names)))
1803
1888
  return jsonify({"path": str(resolved), "entries": entries})
1804
1889
 
1805
1890
 
1891
+ @app.route("/api/projects/credentials", methods=["POST"])
1892
+ def api_projects_credentials_set():
1893
+ """Create/update an entry from the hub. `value` optional — metadata-only
1894
+ update when absent, touching ONLY the keys present in the payload; a
1895
+ submitted value is stored and never echoed back. scope='global' with no
1896
+ `path` targets the shared vault directly."""
1897
+ from services import credential_store as cred_store
1898
+ data = request.get_json(force=True) or {}
1899
+ name = str(data.get("name") or "").strip()
1900
+ scope = str(data.get("scope") or "project").strip().lower()
1901
+ project, store_path, err = _resolve_cred_target(
1902
+ str(data.get("path") or "").strip(), scope, mutation=True)
1903
+ if err:
1904
+ return err
1905
+ value = str(data.get("value") or "")
1906
+ ctype = str(data.get("type") or data.get("ctype") or "token")
1907
+ try:
1908
+ if value:
1909
+ entry = cred_store.set_credential(
1910
+ name, value, scope=scope, project_path=store_path, ctype=ctype,
1911
+ description=str(data.get("description") or ""),
1912
+ env_var=str(data.get("env_var") or ""),
1913
+ agent_readable=bool(data.get("agent_readable")),
1914
+ inject=bool(data.get("inject")))
1915
+ else:
1916
+ # Metadata-only update: touch ONLY the keys present in the payload
1917
+ # so a single-field toggle can't clobber the others.
1918
+ fields = {}
1919
+ for key in ("description", "env_var"):
1920
+ if key in data:
1921
+ fields[key] = str(data[key] or "")
1922
+ for key in ("agent_readable", "inject"):
1923
+ if key in data:
1924
+ fields[key] = bool(data[key])
1925
+ if "type" in data or "ctype" in data:
1926
+ fields["type"] = ctype
1927
+ entry = cred_store.update_metadata(
1928
+ name, scope=scope, project_path=store_path, **fields)
1929
+ except cred_store.CredentialError as exc:
1930
+ return jsonify({"error": str(exc)}), 400
1931
+ except RuntimeError as exc:
1932
+ return jsonify({"error": str(exc)}), 500
1933
+ _hub_cred_audit("set" if value else "update", name, scope, project)
1934
+ return jsonify({"entry": _cred_entry_public(name, {**entry, "scope": scope})})
1935
+
1936
+
1937
+ @app.route("/api/projects/credentials/import", methods=["POST"])
1938
+ def api_projects_credentials_import():
1939
+ """Import KEY=VALUE lines (.env paste). Values are stored, never echoed."""
1940
+ from services import credential_store as cred_store
1941
+ data = request.get_json(force=True) or {}
1942
+ scope = str(data.get("scope") or "project").strip().lower()
1943
+ project, store_path, err = _resolve_cred_target(
1944
+ str(data.get("path") or "").strip(), scope, mutation=True)
1945
+ if err:
1946
+ return err
1947
+ try:
1948
+ result = cred_store.import_env(
1949
+ str(data.get("text") or ""), scope=scope, project_path=store_path,
1950
+ overwrite=bool(data.get("overwrite")))
1951
+ except cred_store.CredentialError as exc:
1952
+ return jsonify({"error": str(exc)}), 400
1953
+ except RuntimeError as exc:
1954
+ return jsonify({"error": str(exc)}), 500
1955
+ for created in result["created"]:
1956
+ _hub_cred_audit("set", created, scope, project)
1957
+ return jsonify(result)
1958
+
1959
+
1960
+ @app.route("/api/projects/credentials/<name>", methods=["DELETE"])
1961
+ def api_projects_credentials_delete(name):
1962
+ """Delete an entry (value + registry). Scope inferred from the owning
1963
+ realm when omitted."""
1964
+ from services import credential_store as cred_store
1965
+ scope = str(request.args.get("scope") or "").strip().lower()
1966
+ path = str(request.args.get("path") or "").strip()
1967
+ project, store_path, err = _resolve_cred_target(
1968
+ path, scope or ("project" if path else "global"), mutation=True)
1969
+ if err:
1970
+ return err
1971
+ if not scope:
1972
+ entry = cred_store.get_entry(name, project_path=store_path)
1973
+ scope = (entry.get("scope") or "project") if entry else \
1974
+ ("project" if path else "global")
1975
+ try:
1976
+ removed = cred_store.delete_credential(
1977
+ name, scope=scope, project_path=store_path)
1978
+ except cred_store.CredentialError as exc:
1979
+ return jsonify({"error": str(exc)}), 400
1980
+ if removed:
1981
+ _hub_cred_audit("delete", name, scope, project)
1982
+ return jsonify({"removed": bool(removed), "scope": scope})
1983
+
1984
+
1985
+ @app.route("/api/projects/credentials/<name>/check", methods=["POST"])
1986
+ def api_projects_credentials_check(name):
1987
+ """Resolvability probe — returns a fingerprint, never the value."""
1988
+ from services import credential_store as cred_store
1989
+ data = request.get_json(silent=True) or {}
1990
+ path = str(data.get("path") or "").strip()
1991
+ project, store_path, err = _resolve_cred_target(
1992
+ path, "project" if path else "global", mutation=False)
1993
+ if err:
1994
+ return err
1995
+ entry = cred_store.get_entry(name, project_path=store_path)
1996
+ if not entry:
1997
+ return jsonify({"error": f"no credential named '{name}'"}), 404
1998
+ return jsonify({
1999
+ "name": name,
2000
+ "scope": entry["scope"],
2001
+ "storage": entry.get("storage", "keyring"),
2002
+ "resolvable": cred_store.get_value(name, project_path=store_path) is not None,
2003
+ "fingerprint": cred_store.fingerprint(name, project_path=store_path),
2004
+ })
2005
+
2006
+
2007
+ @app.route("/api/hub/credentials/overview", methods=["GET"])
2008
+ def api_hub_credentials_overview():
2009
+ """Cross-project credential inventory: the global vault plus each
2010
+ registered project's project-scoped entries, with shadow info both ways.
2011
+ Metadata only — the allowlist serializer structurally cannot emit a value."""
2012
+ from services import credential_store as cred_store
2013
+ home = cred_store.global_base()
2014
+ global_entries = cred_store.list_entries(str(home)) if home else {}
2015
+ shadowed_in = {name: [] for name in global_entries}
2016
+ projects_out = []
2017
+ for p in _pm().list_projects():
2018
+ ppath = str(p.get("path") or "")
2019
+ row = {"name": p.get("name") or "", "path": ppath,
2020
+ "initialized": True, "error": None, "entries": []}
2021
+ try:
2022
+ if not (Path(ppath) / ".c3").is_dir():
2023
+ row["initialized"] = False
2024
+ else:
2025
+ usage = cred_store.read_usage_state(ppath)
2026
+ for name, entry in cred_store.list_entries(ppath).items():
2027
+ if entry.get("scope") != "project":
2028
+ continue
2029
+ row["entries"].append(_cred_entry_public(
2030
+ name, entry, usage=usage,
2031
+ shadows_global=name in global_entries))
2032
+ if name in shadowed_in:
2033
+ shadowed_in[name].append(
2034
+ {"name": row["name"], "path": ppath})
2035
+ except Exception as e: # per-row isolation, like /api/search/global
2036
+ row["error"] = str(e)
2037
+ projects_out.append(row)
2038
+ global_usage = cred_store.read_usage_state(str(home)) if home else {}
2039
+ global_out = [
2040
+ {**_cred_entry_public(name, entry, usage=global_usage),
2041
+ "shadowed_in": shadowed_in.get(name, [])}
2042
+ for name, entry in global_entries.items()
2043
+ ]
2044
+ return jsonify({"global": {"entries": global_out}, "projects": projects_out})
2045
+
2046
+
1806
2047
  @app.route("/api/projects/config", methods=["PUT"])
1807
2048
  def api_projects_config_put():
1808
2049
  """Whitelisted section write: deep-merge, atomic replace, audited on the target."""
cli/hub_ui/app.js CHANGED
@@ -17,7 +17,7 @@ function App() {
17
17
  const [version, setVersion] = useState('');
18
18
  const [projects, setProjects] = useState([]);
19
19
  const [loaded, setLoaded] = useState(false);
20
- const [mainView, setMainView] = useState('projects'); // projects | board
20
+ const [mainView, setMainView] = useState('projects'); // projects | board | creds
21
21
  const [filter, setFilter] = useState('all'); // all | active | idle | tag:<x>
22
22
  const [search, setSearch] = useState('');
23
23
  const [view, setView] = useState('list'); // list | grid
@@ -44,7 +44,7 @@ function App() {
44
44
  if (cfg.projects_view === 'grid') setView('grid');
45
45
  if (cfg.sidebar_collapsed != null) setSidebarCollapsed(!!cfg.sidebar_collapsed);
46
46
  if (cfg.sidebar_group) setFilter(cfg.sidebar_group);
47
- if (cfg.main_view === 'board') setMainView('board');
47
+ if (cfg.main_view === 'board' || cfg.main_view === 'creds') setMainView(cfg.main_view);
48
48
  } catch { }
49
49
  try { const v = await api.get('/api/version'); setVersion(v.c3_version || ''); } catch { }
50
50
  }, []);
@@ -112,6 +112,8 @@ function App() {
112
112
  }}>
113
113
  {mainView === 'board' ? (
114
114
  <TaskBoard projects={projects} onOpenDrill={openDrill} />
115
+ ) : mainView === 'creds' ? (
116
+ <HubCredentials projects={projects} onOpenDrill={openDrill} />
115
117
  ) : (
116
118
  <React.Fragment>
117
119
  <SummaryBar projects={projects} search={search} setSearch={setSearch}