code-context-control 2.146.0__py3-none-any.whl → 2.149.1__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 +1 -1
- cli/hook_access_guard.py +1 -0
- cli/hub_server.py +231 -20
- cli/hub_ui/components/hub_access.js +119 -2
- cli/hub_ui/components/modals.js +34 -12
- cli/hub_ui/components/settings_modal.js +11 -0
- cli/mcp_server.py +11 -1
- {code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/METADATA +1 -1
- {code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/RECORD +16 -15
- services/access_guard.py +60 -4
- services/builtin_leases.py +182 -0
- services/project_manager.py +10 -2
- {code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/WHEEL +0 -0
- {code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
|
|
|
91
91
|
# Config
|
|
92
92
|
CONFIG_DIR = ".c3"
|
|
93
93
|
CONFIG_FILE = ".c3/config.json"
|
|
94
|
-
__version__ = "2.
|
|
94
|
+
__version__ = "2.149.1"
|
|
95
95
|
|
|
96
96
|
# The PreToolUse matcher for native subagent calls (installer and hub migration).
|
|
97
97
|
AGENT_MATCHER = "Agent|Task"
|
cli/hook_access_guard.py
CHANGED
|
@@ -447,6 +447,7 @@ def run(payload: dict, project_path: Path | None = None,
|
|
|
447
447
|
tool_input = payload.get("tool_input", {}) or {}
|
|
448
448
|
base = str(project_path if project_path is not None else Path.cwd())
|
|
449
449
|
session_id = str(payload.get("session_id") or "")
|
|
450
|
+
ag.bind_session(session_id)
|
|
450
451
|
|
|
451
452
|
if tool in _WRITE_TOOLS or tool in _READ_TOOLS:
|
|
452
453
|
fp = _target(tool_input)
|
cli/hub_server.py
CHANGED
|
@@ -72,6 +72,10 @@ _HUB_CONFIG_DEFAULTS = {
|
|
|
72
72
|
"projects_view": "list",
|
|
73
73
|
"main_view": "projects",
|
|
74
74
|
"oracle_url": "",
|
|
75
|
+
# Per-IDE launch command overrides, e.g. {"claude-code": "yep"} when the
|
|
76
|
+
# agent is started through a wrapper script instead of the stock binary.
|
|
77
|
+
# A project's own `ide_cmd` (projects.json) wins over this.
|
|
78
|
+
"ide_cmds": {},
|
|
75
79
|
# Minutes of project silence, with no session heartbeat left, before a UI
|
|
76
80
|
# server that an IDE session launched is stopped. 0 = never reap.
|
|
77
81
|
"ui_reap_minutes": 30,
|
|
@@ -81,6 +85,48 @@ _HUB_CONFIG_DEFAULTS = {
|
|
|
81
85
|
_MAIN_VIEWS = {"projects", "board", "ci", "creds", "tokens", "locks", "access",
|
|
82
86
|
"enforce", "sessions"}
|
|
83
87
|
|
|
88
|
+
# Stock command per IDE id, and whether it takes the project path as an
|
|
89
|
+
# argument (GUI) instead of being run inside a terminal at that path.
|
|
90
|
+
_IDE_DEFAULT_CMDS = {
|
|
91
|
+
"claude-code": ("claude", False),
|
|
92
|
+
"claude-app": ("claude-app", True),
|
|
93
|
+
"codex": ("codex", False),
|
|
94
|
+
"grok": ("grok", False),
|
|
95
|
+
"gemini": ("gemini", False),
|
|
96
|
+
"antigravity": ("antigravity", False),
|
|
97
|
+
"vscode": ("code", True),
|
|
98
|
+
"cursor": ("cursor", True),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_ide_cmd(ide: str, path: str, custom_cmd: str = "") -> tuple:
|
|
103
|
+
"""Resolve the command to launch for `ide` in `path`.
|
|
104
|
+
|
|
105
|
+
Precedence: explicit custom_cmd (this launch) → the project's stored
|
|
106
|
+
`ide_cmd` → hub config `ide_cmds[ide]` → the stock command.
|
|
107
|
+
Returns (cmd, is_gui, error) — error is a message when nothing resolves.
|
|
108
|
+
"""
|
|
109
|
+
default_cmd, is_gui = _IDE_DEFAULT_CMDS.get(ide, ("", False))
|
|
110
|
+
if ide != "custom" and not default_cmd:
|
|
111
|
+
return "", False, f"Unknown IDE: {ide}"
|
|
112
|
+
cmd = (custom_cmd or "").strip()
|
|
113
|
+
if not cmd:
|
|
114
|
+
try:
|
|
115
|
+
cmd = _pm().get_ide_cmd(path)
|
|
116
|
+
except Exception:
|
|
117
|
+
cmd = ""
|
|
118
|
+
if not cmd:
|
|
119
|
+
overrides = _read_hub_config().get("ide_cmds") or {}
|
|
120
|
+
if isinstance(overrides, dict):
|
|
121
|
+
cmd = str(overrides.get(ide) or "").strip()
|
|
122
|
+
if not cmd:
|
|
123
|
+
cmd = default_cmd
|
|
124
|
+
if not cmd:
|
|
125
|
+
return "", False, "custom_cmd is required for custom IDE"
|
|
126
|
+
# An overridden command replaces the binary, not the launch style: a
|
|
127
|
+
# wrapper for a terminal CLI is still a terminal CLI.
|
|
128
|
+
return cmd, is_gui, ""
|
|
129
|
+
|
|
84
130
|
# How often the hub reaps orphaned UI servers and chases autostart projects.
|
|
85
131
|
_UI_SWEEP_INTERVAL_S = 60
|
|
86
132
|
|
|
@@ -460,6 +506,15 @@ def api_hub_config_set():
|
|
|
460
506
|
cfg["main_view"] = main_view
|
|
461
507
|
if "oracle_url" in data:
|
|
462
508
|
cfg["oracle_url"] = str(data["oracle_url"]).strip()
|
|
509
|
+
if "ide_cmds" in data:
|
|
510
|
+
raw = data["ide_cmds"]
|
|
511
|
+
if not isinstance(raw, dict):
|
|
512
|
+
return jsonify({"error": "ide_cmds must be an object"}), 400
|
|
513
|
+
# Empty string = "back to the stock command", so the key is dropped
|
|
514
|
+
# rather than stored blank.
|
|
515
|
+
cfg["ide_cmds"] = {
|
|
516
|
+
str(k): str(v).strip() for k, v in raw.items() if str(v or "").strip()
|
|
517
|
+
}
|
|
463
518
|
if "sidebar_group" in data:
|
|
464
519
|
cfg["sidebar_group"] = str(data["sidebar_group"]).strip()
|
|
465
520
|
if "sidebar_collapsed" in data:
|
|
@@ -718,17 +773,11 @@ def api_projects_open():
|
|
|
718
773
|
|
|
719
774
|
@app.route("/api/projects/launch-ide", methods=["POST"])
|
|
720
775
|
def api_launch_ide():
|
|
721
|
-
"""Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
"grok": ("grok", False),
|
|
727
|
-
"gemini": ("gemini", False),
|
|
728
|
-
"antigravity": ("antigravity", False),
|
|
729
|
-
"vscode": ("code", True),
|
|
730
|
-
"cursor": ("cursor", True),
|
|
731
|
-
}
|
|
776
|
+
"""Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}
|
|
777
|
+
|
|
778
|
+
The command itself is resolved by _resolve_ide_cmd, so a project or the
|
|
779
|
+
hub can point `claude-code` at a wrapper such as `yep`.
|
|
780
|
+
"""
|
|
732
781
|
try:
|
|
733
782
|
data = request.get_json(force=True) or {}
|
|
734
783
|
path_str = (data.get("path") or "").strip()
|
|
@@ -744,14 +793,9 @@ def api_launch_ide():
|
|
|
744
793
|
if not path.exists():
|
|
745
794
|
return jsonify({"error": f"Path does not exist: {path_str}"}), 404
|
|
746
795
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
cmd, is_gui = custom_cmd, False
|
|
751
|
-
elif ide in _IDE_CMDS:
|
|
752
|
-
cmd, is_gui = _IDE_CMDS[ide]
|
|
753
|
-
else:
|
|
754
|
-
return jsonify({"error": f"Unknown IDE: {ide}"}), 400
|
|
796
|
+
cmd, is_gui, err = _resolve_ide_cmd(ide, str(path), custom_cmd)
|
|
797
|
+
if err:
|
|
798
|
+
return jsonify({"error": err}), 400
|
|
755
799
|
|
|
756
800
|
if is_gui:
|
|
757
801
|
# GUI IDEs (VS Code, Cursor) accept a path argument directly
|
|
@@ -795,7 +839,7 @@ def api_launch_ide():
|
|
|
795
839
|
|
|
796
840
|
@app.route("/api/projects/update", methods=["POST"])
|
|
797
841
|
def api_projects_update():
|
|
798
|
-
"""Update editable project fields
|
|
842
|
+
"""Update editable project fields. Body: {path, name?, tags?, notes?, ide_cmd?}"""
|
|
799
843
|
data = request.get_json(force=True) or {}
|
|
800
844
|
path = (data.get("path") or "").strip()
|
|
801
845
|
if not path:
|
|
@@ -812,6 +856,9 @@ def api_projects_update():
|
|
|
812
856
|
fields["tags"] = [t.strip() for t in str(raw).split(",") if t.strip()]
|
|
813
857
|
if "notes" in data:
|
|
814
858
|
fields["notes"] = str(data["notes"])
|
|
859
|
+
if "ide_cmd" in data:
|
|
860
|
+
# Blank clears the override and falls back to the stock command.
|
|
861
|
+
fields["ide_cmd"] = str(data["ide_cmd"] or "").strip()
|
|
815
862
|
try:
|
|
816
863
|
ok = _pm().update_project(path, **fields)
|
|
817
864
|
return jsonify({"updated": ok})
|
|
@@ -3839,6 +3886,170 @@ def api_hub_access_rule_remove():
|
|
|
3839
3886
|
return jsonify(result)
|
|
3840
3887
|
|
|
3841
3888
|
|
|
3889
|
+
# ── Builtin guard modes (v2.147.0) ─────────────────────────────────────────
|
|
3890
|
+
|
|
3891
|
+
def _hub_human():
|
|
3892
|
+
"""The Desk client behind ``Authorization: Bearer``, else ``None``.
|
|
3893
|
+
|
|
3894
|
+
Loopback and the CSRF guard stop a browser, not a process on this
|
|
3895
|
+
machine: an agent can POST here as easily as a person. A Desk client
|
|
3896
|
+
token is minted only with ``bootstrap.key``, which Access Guard denies
|
|
3897
|
+
to agents, and is stored as a hash, so presenting one is the proof a
|
|
3898
|
+
loosening builtin change needs. A phone token does not count: builtin
|
|
3899
|
+
guards are not loosened from a device that leaves the building.
|
|
3900
|
+
"""
|
|
3901
|
+
from oracle.services import client_tokens
|
|
3902
|
+
scheme, _, token = (request.headers.get("Authorization") or "").partition(" ")
|
|
3903
|
+
if scheme.lower() != "bearer" or not token.strip():
|
|
3904
|
+
return None
|
|
3905
|
+
who = client_tokens.principal_for(token.strip())
|
|
3906
|
+
return who if who and who.get("kind") == "desk" else None
|
|
3907
|
+
|
|
3908
|
+
|
|
3909
|
+
@app.route("/api/hub/access/builtin", methods=["GET"])
|
|
3910
|
+
def api_hub_access_builtin():
|
|
3911
|
+
"""Every Tier-1 builtin with its live global mode, and each project's own.
|
|
3912
|
+
|
|
3913
|
+
Modes are read through the keyring (config and attestation must agree),
|
|
3914
|
+
so a mode listed here is one evaluation honours. ``projects`` holds only
|
|
3915
|
+
projects that set a mode of their own, or could not be read.
|
|
3916
|
+
``strictness`` ranks each mode 0 (allow) to 3 (full deny), so a client
|
|
3917
|
+
can tell loosening from tightening without knowing the tiers.
|
|
3918
|
+
"""
|
|
3919
|
+
try:
|
|
3920
|
+
global_modes = access_guard._scope_modes("global")
|
|
3921
|
+
except Exception as e:
|
|
3922
|
+
return jsonify({"error": str(e)}), 500
|
|
3923
|
+
home = access_guard._global_base()
|
|
3924
|
+
guards = []
|
|
3925
|
+
for glob in access_guard.DISABLEABLE_BUILTINS:
|
|
3926
|
+
canon = access_guard._norm_builtin(glob)
|
|
3927
|
+
guards.append({
|
|
3928
|
+
"glob": canon,
|
|
3929
|
+
"global": global_modes.get(canon, "default"),
|
|
3930
|
+
"strictness": {m: access_guard.builtin_strictness(canon, m)
|
|
3931
|
+
for m in (*access_guard.BUILTIN_MODES, "default")},
|
|
3932
|
+
})
|
|
3933
|
+
projects = []
|
|
3934
|
+
for p in _pm().list_registered():
|
|
3935
|
+
ppath = str(p.get("path") or "")
|
|
3936
|
+
row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
|
|
3937
|
+
"modes": {}, "error": None}
|
|
3938
|
+
try:
|
|
3939
|
+
base = Path(ppath).resolve()
|
|
3940
|
+
if (base / ".c3").is_dir() and base != home:
|
|
3941
|
+
row["modes"] = access_guard._scope_modes("project", ppath)
|
|
3942
|
+
except Exception as e:
|
|
3943
|
+
row["error"] = str(e)
|
|
3944
|
+
if row["modes"] or row["error"]:
|
|
3945
|
+
projects.append(row)
|
|
3946
|
+
from services import builtin_leases
|
|
3947
|
+
return jsonify({"guards": guards, "projects": projects,
|
|
3948
|
+
"leases": builtin_leases.active()})
|
|
3949
|
+
|
|
3950
|
+
|
|
3951
|
+
@app.route("/api/hub/access/builtin/mode", methods=["POST"])
|
|
3952
|
+
def api_hub_access_builtin_mode():
|
|
3953
|
+
"""Set a Tier-1 builtin's mode in the global realm or one project's.
|
|
3954
|
+
|
|
3955
|
+
Body: ``{scope, path?, glob, mode, confirm?}``. A mode at least as strict
|
|
3956
|
+
as the live one needs nothing more. A looser one needs a Desk client
|
|
3957
|
+
token (403 ``needs_human`` without it) and ``confirm`` equal to the glob
|
|
3958
|
+
(400 ``needs_confirmation``). A project is judged against what its
|
|
3959
|
+
agents see today, which is the global mode unless it set its own.
|
|
3960
|
+
"""
|
|
3961
|
+
data = request.get_json(silent=True) or {}
|
|
3962
|
+
scope, project, err = _hub_access_target(data)
|
|
3963
|
+
if err:
|
|
3964
|
+
return err
|
|
3965
|
+
glob = str(data.get("glob") or "")
|
|
3966
|
+
mode = str(data.get("mode") or "").strip()
|
|
3967
|
+
canon = access_guard._norm_builtin(glob)
|
|
3968
|
+
try:
|
|
3969
|
+
new = access_guard.builtin_strictness(canon, mode)
|
|
3970
|
+
live = (access_guard._scope_modes("global") if scope == "global"
|
|
3971
|
+
else access_guard.effective_builtin_modes(project))
|
|
3972
|
+
old = access_guard.builtin_strictness(canon, live.get(canon, "default"))
|
|
3973
|
+
except ValueError as exc:
|
|
3974
|
+
return jsonify({"error": str(exc)}), 400
|
|
3975
|
+
if new < old:
|
|
3976
|
+
if _hub_human() is None:
|
|
3977
|
+
return jsonify({
|
|
3978
|
+
"error": "loosening a built-in guard needs C3 Desk",
|
|
3979
|
+
"needs_human": True,
|
|
3980
|
+
"command": f'c3 access builtin mode "{canon}" {mode}'
|
|
3981
|
+
+ (f' --project --path "{project}"'
|
|
3982
|
+
if scope == "project" else ""),
|
|
3983
|
+
}), 403
|
|
3984
|
+
if access_guard._norm_builtin(data.get("confirm")) != canon:
|
|
3985
|
+
return jsonify({
|
|
3986
|
+
"error": "loosening a built-in guard weakens protection",
|
|
3987
|
+
"needs_confirmation": True, "confirm_with": canon,
|
|
3988
|
+
}), 400
|
|
3989
|
+
try:
|
|
3990
|
+
result = access_guard.set_builtin_mode(canon, mode, scope,
|
|
3991
|
+
project or ".")
|
|
3992
|
+
except ValueError as exc:
|
|
3993
|
+
return jsonify({"error": str(exc)}), 400
|
|
3994
|
+
if result["changed"]:
|
|
3995
|
+
_hub_access_audit(f"builtin_mode_{result['mode']}", result["glob"],
|
|
3996
|
+
"builtin", scope, project)
|
|
3997
|
+
return jsonify({**result, "loosened": new < old})
|
|
3998
|
+
|
|
3999
|
+
|
|
4000
|
+
@app.route("/api/hub/access/builtin/lease", methods=["POST"])
|
|
4001
|
+
def api_hub_access_builtin_lease():
|
|
4002
|
+
"""Loosen a builtin for a while: every session, or one.
|
|
4003
|
+
|
|
4004
|
+
Body: ``{scope, path?, glob, mode, ttl_s, session_id?, confirm}``. A
|
|
4005
|
+
lease only ever loosens, so it always needs a Desk client token and the
|
|
4006
|
+
glob typed back, and a lease no looser than the live mode is a 400
|
|
4007
|
+
rather than a row that would never apply. ``ttl_s`` is 60 s to 8 h.
|
|
4008
|
+
"""
|
|
4009
|
+
from services import builtin_leases
|
|
4010
|
+
data = request.get_json(silent=True) or {}
|
|
4011
|
+
scope, project, err = _hub_access_target(data)
|
|
4012
|
+
if err:
|
|
4013
|
+
return err
|
|
4014
|
+
who = _hub_human()
|
|
4015
|
+
if who is None:
|
|
4016
|
+
return jsonify({"error": "a temporary built-in change needs C3 Desk",
|
|
4017
|
+
"needs_human": True}), 403
|
|
4018
|
+
canon = access_guard._norm_builtin(data.get("glob"))
|
|
4019
|
+
mode = str(data.get("mode") or "").strip()
|
|
4020
|
+
if access_guard._norm_builtin(data.get("confirm")) != canon or not canon:
|
|
4021
|
+
return jsonify({"error": "a temporary change weakens protection",
|
|
4022
|
+
"needs_confirmation": True, "confirm_with": canon}), 400
|
|
4023
|
+
try:
|
|
4024
|
+
live = access_guard.effective_builtin_modes(project or ".") \
|
|
4025
|
+
if scope == "project" else access_guard._scope_modes("global")
|
|
4026
|
+
if access_guard.builtin_strictness(canon, mode) >= \
|
|
4027
|
+
access_guard.builtin_strictness(canon, live.get(canon, "default")):
|
|
4028
|
+
return jsonify({"error": f"{canon} is already {live.get(canon, 'default')}"
|
|
4029
|
+
f" — a {mode} lease would change nothing"}), 400
|
|
4030
|
+
row = builtin_leases.mint(
|
|
4031
|
+
canon, mode, scope=scope, project_path=project or ".",
|
|
4032
|
+
session_id=str(data.get("session_id") or ""),
|
|
4033
|
+
ttl_s=data.get("ttl_s"), created_by=who["client_id"])
|
|
4034
|
+
except ValueError as exc:
|
|
4035
|
+
return jsonify({"error": str(exc)}), 400
|
|
4036
|
+
_hub_access_audit(f"builtin_lease_{mode}", canon, "builtin", scope, project)
|
|
4037
|
+
return jsonify({"lease": row})
|
|
4038
|
+
|
|
4039
|
+
|
|
4040
|
+
@app.route("/api/hub/access/builtin/lease/revoke", methods=["POST"])
|
|
4041
|
+
def api_hub_access_builtin_lease_revoke():
|
|
4042
|
+
"""End a lease now. Tightening, so no token. Body: ``{id}``."""
|
|
4043
|
+
from services import builtin_leases
|
|
4044
|
+
lease_id = str((request.get_json(silent=True) or {}).get("id") or "")
|
|
4045
|
+
gone = builtin_leases.revoke(lease_id) if lease_id else None
|
|
4046
|
+
if gone is None:
|
|
4047
|
+
return jsonify({"error": "unknown or already-expired lease"}), 404
|
|
4048
|
+
_hub_access_audit("builtin_lease_revoke", gone["glob"], "builtin",
|
|
4049
|
+
gone["scope"], gone.get("project_path") or None)
|
|
4050
|
+
return jsonify({"revoked": lease_id})
|
|
4051
|
+
|
|
4052
|
+
|
|
3842
4053
|
# ── Project management: tasks / milestones / notes (v2.45.0) ──────────────
|
|
3843
4054
|
# Direct TaskStore per request (reload-per-op store — no runtime build needed
|
|
3844
4055
|
# for mutations); every write audited to the target project's activity log.
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// hand — the server enforces that regardless of what this UI believes);
|
|
20
20
|
// - a request that lapsed while the page showed it refreshes to its real
|
|
21
21
|
// status (the decide route answers 409), never silently mints a grant;
|
|
22
|
-
// - rule MUTATION stays on the per-project server and `c3 access
|
|
23
|
-
//
|
|
22
|
+
// - path-rule MUTATION stays on the per-project server and `c3 access`.
|
|
23
|
+
// Builtin guards may be TIGHTENED here; loosening one needs C3 Desk or
|
|
24
|
+
// the CLI, because this page holds no proof that a person clicked.
|
|
24
25
|
|
|
25
26
|
const ACC_KIND_COLOR = (kind) => (
|
|
26
27
|
kind === 'deny' ? T.error : kind === 'mask' ? T.blue
|
|
@@ -387,6 +388,121 @@ function AccRulesPanel({ projects, focus }) {
|
|
|
387
388
|
);
|
|
388
389
|
}
|
|
389
390
|
|
|
391
|
+
// ── Builtin guards ─────────────────────────────────────────────────────────
|
|
392
|
+
// The global mode of every Tier-1 builtin. Picking a stricter mode applies
|
|
393
|
+
// it; picking a looser one is refused by the server without a Desk token,
|
|
394
|
+
// and the panel shows the CLI command instead.
|
|
395
|
+
const ACC_BUILTIN_MODES = ['default', 'deny', 'confirm', 'allow'];
|
|
396
|
+
|
|
397
|
+
function AccBuiltinPanel() {
|
|
398
|
+
const [data, setData] = useState(null);
|
|
399
|
+
const [err, setErr] = useState('');
|
|
400
|
+
const [busy, setBusy] = useState('');
|
|
401
|
+
const [needs, setNeeds] = useState(null);
|
|
402
|
+
|
|
403
|
+
const load = useCallback(async () => {
|
|
404
|
+
try { setData(await api.get('/api/hub/access/builtin')); setErr(''); }
|
|
405
|
+
catch (e) { setErr(apiErr(e)); }
|
|
406
|
+
}, []);
|
|
407
|
+
useEffect(() => { load(); }, [load]);
|
|
408
|
+
usePoll(load, 15000);
|
|
409
|
+
|
|
410
|
+
const setMode = async (g, mode) => {
|
|
411
|
+
setBusy(g.glob); setNeeds(null);
|
|
412
|
+
try {
|
|
413
|
+
await api.post('/api/hub/access/builtin/mode', { scope: 'global', glob: g.glob, mode });
|
|
414
|
+
notify(`${g.glob} is now ${mode} everywhere`, 'ok');
|
|
415
|
+
} catch (e) {
|
|
416
|
+
const body = e && e.payload;
|
|
417
|
+
if (body && body.needs_human) setNeeds({ glob: g.glob, command: body.command });
|
|
418
|
+
else notify(apiErr(e), 'err');
|
|
419
|
+
}
|
|
420
|
+
setBusy('');
|
|
421
|
+
load();
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const revoke = async (l) => {
|
|
425
|
+
setBusy(l.id);
|
|
426
|
+
try {
|
|
427
|
+
await api.post('/api/hub/access/builtin/lease/revoke', { id: l.id });
|
|
428
|
+
notify(`Ended the ${l.mode} lease on ${l.glob}`, 'ok');
|
|
429
|
+
} catch (e) { notify(apiErr(e), 'err'); }
|
|
430
|
+
setBusy('');
|
|
431
|
+
load();
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const projects = (data && data.projects) || [];
|
|
435
|
+
const leases = (data && data.leases) || [];
|
|
436
|
+
return (
|
|
437
|
+
<div style={{
|
|
438
|
+
background: T.surface, border: `1px solid ${T.border}`, borderRadius: 10,
|
|
439
|
+
padding: '12px 16px',
|
|
440
|
+
}}>
|
|
441
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
442
|
+
<div style={{ fontSize: 12.5, fontWeight: 700, color: T.text }}>Built-in guards</div>
|
|
443
|
+
<span style={{ fontSize: 10.5, color: T.textDim }}>
|
|
444
|
+
global mode — a stricter mode applies here; loosening one needs C3 Desk or the CLI
|
|
445
|
+
</span>
|
|
446
|
+
</div>
|
|
447
|
+
{err && <div style={{ marginTop: 8, fontSize: 11.5, color: T.error }}>{err}</div>}
|
|
448
|
+
{needs && (
|
|
449
|
+
<div style={{ marginTop: 8, fontSize: 11.5, color: T.warn }}>
|
|
450
|
+
Loosening <span className="mono">{needs.glob}</span> needs C3 Desk, or run:{' '}
|
|
451
|
+
<span className="mono" style={{ color: T.text }}>{needs.command}</span>
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
{data && (
|
|
455
|
+
<div style={{ marginTop: 10, display: 'grid', gridTemplateColumns: 'minmax(0,1fr) auto', gap: '4px 12px', alignItems: 'center' }}>
|
|
456
|
+
{data.guards.map(g => (
|
|
457
|
+
<React.Fragment key={g.glob}>
|
|
458
|
+
<span className="mono" style={{ fontSize: 11.5, color: T.text, overflow: 'hidden', textOverflow: 'ellipsis' }}>{g.glob}</span>
|
|
459
|
+
<select value={g.global} disabled={busy === g.glob}
|
|
460
|
+
onChange={e => setMode(g, e.target.value)}
|
|
461
|
+
style={{
|
|
462
|
+
background: T.surfaceAlt, color: g.global === 'default' ? T.textMuted : T.warn,
|
|
463
|
+
border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 11.5, padding: '2px 6px',
|
|
464
|
+
}}>
|
|
465
|
+
{ACC_BUILTIN_MODES.map(m => <option key={m} value={m}>{m}</option>)}
|
|
466
|
+
</select>
|
|
467
|
+
</React.Fragment>
|
|
468
|
+
))}
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
{leases.length > 0 && (
|
|
472
|
+
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
473
|
+
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: 1, textTransform: 'uppercase', color: T.warn }}>
|
|
474
|
+
temporary changes
|
|
475
|
+
</div>
|
|
476
|
+
{leases.map(l => (
|
|
477
|
+
<div key={l.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5 }}>
|
|
478
|
+
<span className="mono" style={{ color: T.text }}>{l.glob}</span>
|
|
479
|
+
<span style={{ color: T.warn }}>{l.mode}</span>
|
|
480
|
+
<span style={{ color: T.textMuted }}>
|
|
481
|
+
{l.scope === 'project' ? l.project_path : 'all projects'}
|
|
482
|
+
{' · '}{l.session_id ? <>session <span className="mono">{l.session_id.slice(0, 8)}</span></> : 'all sessions'}
|
|
483
|
+
{' · '}ends in {accExpiresIn(l.expires_at) || '—'}
|
|
484
|
+
</span>
|
|
485
|
+
<div style={{ flex: 1 }} />
|
|
486
|
+
<Btn variant="ghost" disabled={busy === l.id} onClick={() => revoke(l)}>Revoke</Btn>
|
|
487
|
+
</div>
|
|
488
|
+
))}
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
{projects.length > 0 && (
|
|
492
|
+
<div style={{ marginTop: 8, fontSize: 11, color: T.textDim }}>
|
|
493
|
+
Set per project:{' '}
|
|
494
|
+
{projects.map(p => (
|
|
495
|
+
<span key={p.path} className="mono" style={{ marginRight: 10 }}>
|
|
496
|
+
{p.name}: {p.error ? `unreadable (${p.error})`
|
|
497
|
+
: Object.entries(p.modes).map(([g, m]) => `${g}=${m}`).join(', ')}
|
|
498
|
+
</span>
|
|
499
|
+
))}
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
</div>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
390
506
|
// ── Active grants ──────────────────────────────────────────────────────────
|
|
391
507
|
// A rule grant can authorise many calls over hours. The only other place it
|
|
392
508
|
// shows up is a line in the agent's own transcript, which is the wrong place
|
|
@@ -762,6 +878,7 @@ function HubAccess({ projects }) {
|
|
|
762
878
|
)}
|
|
763
879
|
|
|
764
880
|
<AccGrantsPanel projects={projects} />
|
|
881
|
+
<AccBuiltinPanel />
|
|
765
882
|
<AccRulesPanel projects={projects} focus={ruleFocus} />
|
|
766
883
|
{confirmSpec && (
|
|
767
884
|
<CredConfirm spec={confirmSpec} onClose={() => setConfirmSpec(null)} />
|
cli/hub_ui/components/modals.js
CHANGED
|
@@ -528,24 +528,41 @@ function ReparentModal({ project, projects, onClose, onChanged }) {
|
|
|
528
528
|
|
|
529
529
|
// ── IDE picker ─────────────────────────────────────────────────
|
|
530
530
|
// POST /api/projects/launch-ide {path, ide, custom_cmd?}
|
|
531
|
+
// The command is editable per project (stored as ide_cmd via
|
|
532
|
+
// /api/projects/update) so an agent started through a wrapper — `yep` rather
|
|
533
|
+
// than `claude` — launches from the card too, not just from here.
|
|
531
534
|
function IdePickerModal({ project, onClose, onChanged }) {
|
|
532
535
|
const known = IDE_OPTIONS.some(o => o.id === (project && project.ide));
|
|
533
536
|
const [selected, setSelected] = useState(known ? project.ide : 'claude-code');
|
|
534
|
-
const
|
|
537
|
+
const savedCmd = ((project && project.ide_cmd) || '').trim();
|
|
538
|
+
const [customCmd, setCustomCmd] = useState(savedCmd);
|
|
539
|
+
const [remember, setRemember] = useState(!!savedCmd);
|
|
535
540
|
const [busy, setBusy] = useState(false);
|
|
536
541
|
|
|
542
|
+
const stockCmd = (id) => {
|
|
543
|
+
const opt = IDE_OPTIONS.find(o => o.id === id);
|
|
544
|
+
return opt && opt.cmd !== '...' ? opt.cmd : '';
|
|
545
|
+
};
|
|
546
|
+
const effectiveCmd = customCmd.trim() || stockCmd(selected);
|
|
547
|
+
|
|
537
548
|
const launch = async () => {
|
|
538
|
-
|
|
549
|
+
const cmd = customCmd.trim();
|
|
550
|
+
if (selected === 'custom' && !cmd) {
|
|
539
551
|
notify('Enter a custom command first.', 'err');
|
|
540
552
|
return;
|
|
541
553
|
}
|
|
542
554
|
setBusy(true);
|
|
543
555
|
try {
|
|
556
|
+
// Persist first: the project card launches with no command of its own.
|
|
557
|
+
const wanted = remember ? cmd : '';
|
|
558
|
+
if (wanted !== savedCmd) {
|
|
559
|
+
await api.post('/api/projects/update', { path: project.path, ide_cmd: wanted });
|
|
560
|
+
}
|
|
544
561
|
await api.post('/api/projects/launch-ide', {
|
|
545
|
-
path: project.path, ide: selected,
|
|
546
|
-
custom_cmd: selected === 'custom' ? customCmd.trim() : '',
|
|
562
|
+
path: project.path, ide: selected, custom_cmd: cmd,
|
|
547
563
|
});
|
|
548
|
-
notify(`Launched ${ideLabel(selected)} in ${project.name || project.path}`);
|
|
564
|
+
notify(`Launched ${ideLabel(selected)} (${effectiveCmd}) in ${project.name || project.path}`);
|
|
565
|
+
if (onChanged) onChanged();
|
|
549
566
|
onClose();
|
|
550
567
|
} catch (e) {
|
|
551
568
|
notify(`Launch failed: ${e.message}`, 'err');
|
|
@@ -575,13 +592,18 @@ function IdePickerModal({ project, onClose, onChanged }) {
|
|
|
575
592
|
);
|
|
576
593
|
})}
|
|
577
594
|
</div>
|
|
578
|
-
|
|
579
|
-
<
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
595
|
+
<div>
|
|
596
|
+
<MdlLabel>Command{selected === 'custom' ? '' : ' (leave blank for the default)'}</MdlLabel>
|
|
597
|
+
<input value={customCmd} onChange={e => setCustomCmd(e.target.value)}
|
|
598
|
+
placeholder={selected === 'custom' ? 'e.g. nvim .' : `${stockCmd(selected)} — override with e.g. yep`}
|
|
599
|
+
className="mono" style={mdlInputStyle()} />
|
|
600
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6,
|
|
601
|
+
fontSize: 11, color: T.textMuted, cursor: 'pointer' }}>
|
|
602
|
+
<input type="checkbox" checked={remember}
|
|
603
|
+
onChange={e => setRemember(e.target.checked)} />
|
|
604
|
+
Remember for this project (used by the card's launch button too)
|
|
605
|
+
</label>
|
|
606
|
+
</div>
|
|
585
607
|
<MdlFooter>
|
|
586
608
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
587
609
|
<Btn onClick={launch} disabled={busy}>
|
|
@@ -14,6 +14,7 @@ function SettingsModal({ onClose, onChanged }) {
|
|
|
14
14
|
const [port, setPort] = useState('');
|
|
15
15
|
const [autoBrowser, setAutoBrowser] = useState(true);
|
|
16
16
|
const [oracleUrl, setOracleUrl] = useState('');
|
|
17
|
+
const [agentCmd, setAgentCmd] = useState(''); // ide_cmds['claude-code']
|
|
17
18
|
const [cacheSize, setCacheSize] = useState(8);
|
|
18
19
|
const [saving, setSaving] = useState(false);
|
|
19
20
|
const [svc, setSvc] = useState(null); // hub: {installed, running, method, port, log_path} | {error}
|
|
@@ -51,6 +52,7 @@ function SettingsModal({ onClose, onChanged }) {
|
|
|
51
52
|
setPort(d.port || 3330);
|
|
52
53
|
setAutoBrowser(!!d.auto_open_browser);
|
|
53
54
|
setOracleUrl(d.oracle_url || '');
|
|
55
|
+
setAgentCmd(((d.ide_cmds || {})['claude-code']) || '');
|
|
54
56
|
setCacheSize(d.runtime_cache_size || 8);
|
|
55
57
|
} catch {
|
|
56
58
|
notify('Could not load hub config', 'err');
|
|
@@ -70,9 +72,14 @@ function SettingsModal({ onClose, onChanged }) {
|
|
|
70
72
|
const cache = Math.max(1, parseInt(cacheSize, 10) || 8);
|
|
71
73
|
setSaving(true);
|
|
72
74
|
try {
|
|
75
|
+
// Blank restores the stock `claude`; other IDE overrides are left alone.
|
|
76
|
+
const ideCmds = { ...((cfg && cfg.ide_cmds) || {}) };
|
|
77
|
+
if (agentCmd.trim()) ideCmds['claude-code'] = agentCmd.trim();
|
|
78
|
+
else delete ideCmds['claude-code'];
|
|
73
79
|
await api.post('/api/hub/config', {
|
|
74
80
|
port: portVal, auto_open_browser: autoBrowser,
|
|
75
81
|
oracle_url: oracleUrl.trim(), runtime_cache_size: cache,
|
|
82
|
+
ide_cmds: ideCmds,
|
|
76
83
|
});
|
|
77
84
|
const portChanged = cfg && portVal !== cfg.port;
|
|
78
85
|
notify(portChanged
|
|
@@ -222,6 +229,10 @@ function SettingsModal({ onClose, onChanged }) {
|
|
|
222
229
|
<input type="number" value={cacheSize} onChange={e => setCacheSize(e.target.value)}
|
|
223
230
|
min={1} className="mono" style={inputStyle(110)} />
|
|
224
231
|
), 'Max project runtimes kept warm for drill-in and global search.')}
|
|
232
|
+
{fieldRow('Agent command', (
|
|
233
|
+
<input value={agentCmd} onChange={e => setAgentCmd(e.target.value)}
|
|
234
|
+
placeholder="claude" className="mono" style={inputStyle(220)} />
|
|
235
|
+
), 'Default command for "Claude Code CLI" launches — e.g. yep for a wrapper script. A project can override it in Open in IDE.')}
|
|
225
236
|
{fieldRow('Oracle URL', (
|
|
226
237
|
<input value={oracleUrl} onChange={e => setOracleUrl(e.target.value)}
|
|
227
238
|
placeholder="http://localhost:3331" className="mono" style={inputStyle(220)} />
|
cli/mcp_server.py
CHANGED
|
@@ -23,9 +23,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
23
23
|
|
|
24
24
|
from fastmcp import Context, FastMCP
|
|
25
25
|
|
|
26
|
+
from cli.tools import _grants
|
|
26
27
|
from core.host import resolve_host
|
|
27
28
|
from core.ide import get_profile
|
|
28
|
-
from services import delegate_hints
|
|
29
|
+
from services import access_guard, delegate_hints
|
|
29
30
|
from services.auto_memory import AutoMemory
|
|
30
31
|
from services.context_snapshot import ContextSnapshot
|
|
31
32
|
from services.runtime import C3Runtime, build_runtime, start_runtime, stop_runtime
|
|
@@ -191,6 +192,14 @@ def _notify_mcp_ready(svc) -> None:
|
|
|
191
192
|
async def lifespan(server):
|
|
192
193
|
"""Initialize all services, auto-start session, start file watcher."""
|
|
193
194
|
project = PROJECT_PATH
|
|
195
|
+
# numpy's native module must load before any background thread exists: on
|
|
196
|
+
# Windows, loading it from c3-initial-index (via chromadb) while this
|
|
197
|
+
# process starts other threads deadlocks both, and the first tool call
|
|
198
|
+
# never answers. Absent numpy, nothing here needs it.
|
|
199
|
+
try:
|
|
200
|
+
import numpy # noqa: F401, PLC0415
|
|
201
|
+
except ImportError:
|
|
202
|
+
pass
|
|
194
203
|
services = build_runtime(project, ide_name=_IDE_NAME)
|
|
195
204
|
if getattr(services, "time_tracker", None) is not None:
|
|
196
205
|
try:
|
|
@@ -508,6 +517,7 @@ def _ensure_repo_map_once(rt) -> None:
|
|
|
508
517
|
def _svc(ctx: Context) -> C3Runtime:
|
|
509
518
|
rt = ctx.request_context.lifespan_context
|
|
510
519
|
_ensure_repo_map_once(rt)
|
|
520
|
+
access_guard.bind_session(_grants.session_id(rt))
|
|
511
521
|
return rt
|
|
512
522
|
|
|
513
523
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.149.1
|
|
4
4
|
Summary: Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub.
|
|
5
5
|
Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
|
|
2
2
|
cli/_hook_utils.py,sha256=RhfS2WS5VMSi2w2Jxcts1wg5WC-UFp89JOQvcO5Jjb8,23002
|
|
3
3
|
cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
|
|
4
|
-
cli/c3.py,sha256=
|
|
4
|
+
cli/c3.py,sha256=P-UUPaigu00WNQgtYuyOrpG9UQGfhKQl1uicUsT6Qgk,431810
|
|
5
5
|
cli/docs.html,sha256=pPxcP5ieY0DcfEDvegwIcRxbz1woLZoKLVSF-4JVy0Q,146784
|
|
6
6
|
cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
|
|
7
|
-
cli/hook_access_guard.py,sha256=
|
|
7
|
+
cli/hook_access_guard.py,sha256=tzK3pQBGehDDZi6fECcYk8nRhy0QkmUtTF2TuJG7nbY,27928
|
|
8
8
|
cli/hook_agent_model.py,sha256=NzetPAuzl7eiWgr6tGzWUY24qiT717FD-QqeFOudGrg,2784
|
|
9
9
|
cli/hook_artifact.py,sha256=Se1CNBfoBFyvJQlRmYdNtdRXIkQp9zaF0O6ndRMo7ts,2198
|
|
10
10
|
cli/hook_auto_snapshot.py,sha256=yGFqRANtw8qNqRNr-oCgpuyaUyTkSzIOFxXt5kauq4I,5271
|
|
@@ -23,10 +23,10 @@ cli/hook_session_end.py,sha256=toFEPDKRoutDuVOmuOgHmQ8CM0o7tF7yALhMxJz7VEM,3482
|
|
|
23
23
|
cli/hook_session_open.py,sha256=Badeikv37D7b6inSHbVjAxtfdH4xiJKFT1O52fgreGs,7443
|
|
24
24
|
cli/hook_session_stats.py,sha256=I-2NeLlV1NxeqKtTM2JH9haSpFU7r4qG0u8pfCJ80YU,8725
|
|
25
25
|
cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,6252
|
|
26
|
-
cli/hub_server.py,sha256=
|
|
26
|
+
cli/hub_server.py,sha256=VgDAlVmWgvh57KUbMgL2PT4303fmlr578QLk6ehe2pA,227863
|
|
27
27
|
cli/hub_ui.html,sha256=v41R_JAH4Jwodpy3tbeBUel-80FbNmffBrey-QH5Q64,2963
|
|
28
28
|
cli/mcp_proxy.py,sha256=r-Qhlie7gCtotex8yQUudCyBLRUK-IW6cNIW5RhBW0o,17648
|
|
29
|
-
cli/mcp_server.py,sha256=
|
|
29
|
+
cli/mcp_server.py,sha256=Omsjg01JePjMrbJwXBMAmJ6zX0MNjTbREJ6BhfuVlI4,74248
|
|
30
30
|
cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
|
|
31
31
|
cli/server.py,sha256=dJOOXYU1zmN-UBuMmHhv8XlnknSyO8xj7mXWSDAGIBY,173188
|
|
32
32
|
cli/ui.html,sha256=uuPwIJNKDQH_fsaR3xNB2AKky9WzKfaXnKr_k3aTtxo,5715
|
|
@@ -60,7 +60,7 @@ cli/hub_ui/components/drill_subprojects.js,sha256=wdPNTIMrrLLQI5Qxi3xk-rZFMDO8pF
|
|
|
60
60
|
cli/hub_ui/components/drill_tasks.js,sha256=1iG8Kx9vCoCIHFDXCVtGiw_HwYff3SuScM90tj0cFRQ,14245
|
|
61
61
|
cli/hub_ui/components/drill_views.js,sha256=HyYPcpl9VXAcrmYRNEodHl9cNcFJMrF8XwTvCR3vdsc,15480
|
|
62
62
|
cli/hub_ui/components/global_search.js,sha256=GPfq1EsZa73vXBNCVn4VhT3a7gYkaUblbcXOFCe7PJQ,11395
|
|
63
|
-
cli/hub_ui/components/hub_access.js,sha256=
|
|
63
|
+
cli/hub_ui/components/hub_access.js,sha256=dllDqBIGdUWTJVxmdfv2OYM_d2f-K1TShqtPqH_dju0,39551
|
|
64
64
|
cli/hub_ui/components/hub_ci.js,sha256=KrdzALpgFTFcPEMN1R-OU0tyl8irsj91HECRlGsQLjQ,17873
|
|
65
65
|
cli/hub_ui/components/hub_cred_audit.js,sha256=fUmAPTOa75gdxAsw6cNHPYenu7jDTljSbFX9GYJnW14,11662
|
|
66
66
|
cli/hub_ui/components/hub_credentials.js,sha256=xDqxDb0a0yaJQgTE-hEtx0PTOo4x9j__5GhdlxGd9mo,119643
|
|
@@ -69,11 +69,11 @@ cli/hub_ui/components/hub_locks.js,sha256=v1yv7JH5anfC_kZo26I1Dyt1_ytamGOaBpCIWG
|
|
|
69
69
|
cli/hub_ui/components/hub_sessions.js,sha256=Uo-U3T9ZIqSgiBkfNIWB3K2c-J-WBJApUU2IPSi5BZk,21401
|
|
70
70
|
cli/hub_ui/components/hub_tokens.js,sha256=oBFTvW5zJShpBaqIqmJPK_g7SpEiulurMRjlfO34T2c,18565
|
|
71
71
|
cli/hub_ui/components/mcp_manager.js,sha256=Bqd74xuHVyADpILJZbFHTlM2gcUpG1nH_ngZhMlobaU,17405
|
|
72
|
-
cli/hub_ui/components/modals.js,sha256
|
|
72
|
+
cli/hub_ui/components/modals.js,sha256=-vV55ENULEeovjDcse-wvIotbDxJKlFrUf2sv_crqok,66991
|
|
73
73
|
cli/hub_ui/components/project_card.js,sha256=V_g5LUUj99IPdjw5f98ViZ-mbBGTXt_RKqq_0g-tPMs,30722
|
|
74
74
|
cli/hub_ui/components/project_tree.js,sha256=jPgkHZwgBM9iNT4pxXPTMgV4dtZpOX5EQsGvzcL4PP0,3829
|
|
75
75
|
cli/hub_ui/components/session_drawer.js,sha256=OEBvdF3PqRji9CEntolDMVplXF2NTG2xwmXCqkcb0Nw,7553
|
|
76
|
-
cli/hub_ui/components/settings_modal.js,sha256=
|
|
76
|
+
cli/hub_ui/components/settings_modal.js,sha256=gz2G8gpA5yZ4q0KS3GKlDqrJxvSx_kkbLXYxpGm8g0g,14503
|
|
77
77
|
cli/hub_ui/components/sidebar.js,sha256=RH0OlqCINFNCIXgns-wgge5gG8vkn5wyfRT87qdmo1I,4585
|
|
78
78
|
cli/hub_ui/components/summary_bar.js,sha256=JnMq01Oum8DwwUOhFnX5mr8K7I-ERdPlbaBOR8QT72o,2993
|
|
79
79
|
cli/hub_ui/components/task_board.js,sha256=QiWKmj1NkhkloGlfzZj_tWd7Sgz3xSxwVLp8I5OfIiM,15952
|
|
@@ -131,7 +131,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
|
|
|
131
131
|
cli/ui/components/settings.js,sha256=FXcn-UQE2mXC6HFsLeEas-wAUFKt0OJaLySroZQMhGA,84095
|
|
132
132
|
cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
|
|
133
133
|
cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
|
|
134
|
-
code_context_control-2.
|
|
134
|
+
code_context_control-2.149.1.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
|
|
135
135
|
core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
|
|
136
136
|
core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
|
|
137
137
|
core/config.py,sha256=wCjrZKXRywUbR6WRcO7optyOirAQ2DeRNdPCU0X5zIY,23762
|
|
@@ -188,7 +188,7 @@ oracle/ui/chat/send.js,sha256=X_Uc6EuuIxkUTlRWGGYOq6gQkJCQeCE3p5NMyxx7RzQ,11911
|
|
|
188
188
|
oracle/ui/chat/stream_renderer.js,sha256=RZndI2mu5xbzZF-4NG9jeOqXTcmIqfZqxgkrBvTzWmk,29861
|
|
189
189
|
oracle/ui/chat/toolbar.js,sha256=ZDexIIpGwsQ6XsY4Tyo082rJoofdD_JBTV97-mLQUVM,12027
|
|
190
190
|
services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
|
|
191
|
-
services/access_guard.py,sha256=
|
|
191
|
+
services/access_guard.py,sha256=Qr9jl3DPINytvgXzuZJtnA2yyXq0GnQguGQwXinK7XE,81458
|
|
192
192
|
services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,9568
|
|
193
193
|
services/activity_log.py,sha256=c_jTOsR-d1d5B3u67eg8mRA7LZhtP6H1-6leeGa0Go4,7990
|
|
194
194
|
services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
|
|
@@ -204,6 +204,7 @@ services/background_service.py,sha256=Hn-vjAUq_wPm0cUiwwzyr-D7Xxkygjuy7s3_9XuLaO
|
|
|
204
204
|
services/benchmark_dashboard.py,sha256=iR-DnqnoKbqHMJ4d-ZkIvJBYfzwTa7r-jzO6j2BYDfQ,27711
|
|
205
205
|
services/bitbucket_client.py,sha256=2RehoyVP4vc52zjQi3Gdlt2WRAUpFv8zVMn12xx1yHE,22338
|
|
206
206
|
services/bitbucket_credentials.py,sha256=2qLA9pQMol4y95y4DJMNBsBBPUsJQCKbLFo2iiCnfvI,7364
|
|
207
|
+
services/builtin_leases.py,sha256=WRYBQRMckz2GZ80Zw2QtgY0WwnbxF1ULDrFp5B07qlk,6298
|
|
207
208
|
services/ci_act.py,sha256=82i5aa1wgQvc8Xq_pQ9-k1_57LBQ0-mWSUT9xV-q7Tk,24070
|
|
208
209
|
services/ci_cache.py,sha256=Too3XyeE-oeiqQm4IJ10giFvhIEr29YfDLUpZDl8SBU,13256
|
|
209
210
|
services/ci_expr.py,sha256=BsCUoL4ZN1Q8KjYJ07xWsPynV04Q8dg-ryIIKNOYk70,15641
|
|
@@ -274,7 +275,7 @@ services/override_policy.py,sha256=TehVPxfQ8LZWIOUzCnnioIdMUPZZ7JZa1ja77smpW2c,3
|
|
|
274
275
|
services/override_requests.py,sha256=H9lSUbfOjkjE1muztaQ58InqqsqhtnKIT-pF2VzvWkg,31148
|
|
275
276
|
services/override_wake.py,sha256=HjSOXQTDi0irgeMyrTqngL7xLcSOOIgYsVW9yxod4ps,10363
|
|
276
277
|
services/parser.py,sha256=S_YoKLS3r1xCUVHApXb3BTEfoRAzFhuo1_8XsG0qxVE,63505
|
|
277
|
-
services/project_manager.py,sha256=
|
|
278
|
+
services/project_manager.py,sha256=7NvQau5x0PCVagQsXpuXZ4VtXCkCfy9es_v1oEHmzgs,48718
|
|
278
279
|
services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
|
|
279
280
|
services/protocol.py,sha256=CWZ0BWWd-jPui-11hZlvGgkNk18gMFhhLWbp-BP35Kk,12861
|
|
280
281
|
services/proxy_state.py,sha256=u5rd0k6CrOsywZA8FpRu_hMLwhR0TAJhZjy5MdWbCGc,6107
|
|
@@ -333,8 +334,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
|
|
|
333
334
|
tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
|
|
334
335
|
tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
|
|
335
336
|
tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
|
|
336
|
-
code_context_control-2.
|
|
337
|
-
code_context_control-2.
|
|
338
|
-
code_context_control-2.
|
|
339
|
-
code_context_control-2.
|
|
340
|
-
code_context_control-2.
|
|
337
|
+
code_context_control-2.149.1.dist-info/METADATA,sha256=OPwBuyGX5nRTAGhNLG_qcwpq9LFLYT_k2CsnN46Jk3A,28902
|
|
338
|
+
code_context_control-2.149.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
339
|
+
code_context_control-2.149.1.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
|
|
340
|
+
code_context_control-2.149.1.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
|
|
341
|
+
code_context_control-2.149.1.dist-info/RECORD,,
|
services/access_guard.py
CHANGED
|
@@ -60,6 +60,14 @@ BUILTIN_MODES = ("deny", "confirm", "allow")
|
|
|
60
60
|
# (or, worse, silently disable one).
|
|
61
61
|
_ACCESS_KEYRING_SERVICE = "c3-access"
|
|
62
62
|
|
|
63
|
+
# Temporary and per-session builtin modes (services/builtin_leases.py), in
|
|
64
|
+
# ~/.c3. Named here so the hot path can skip the lease module when no file
|
|
65
|
+
# exists.
|
|
66
|
+
LEASES_FILE = "builtin_leases.json"
|
|
67
|
+
|
|
68
|
+
# The agent session this process evaluates for; see bind_session().
|
|
69
|
+
_SESSION_ID = ""
|
|
70
|
+
|
|
63
71
|
# Mask presets (docs/mask-guard.md §4). Names + param schema live here so the
|
|
64
72
|
# hook subprocess can validate config without importing the transform engines;
|
|
65
73
|
# the engines themselves are in services/mask_presets.py.
|
|
@@ -600,10 +608,33 @@ def effective_builtin_modes(project_path: str = ".") -> dict:
|
|
|
600
608
|
"""
|
|
601
609
|
out = _scope_modes("global")
|
|
602
610
|
proj_base = _scope_base("project", project_path)
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
return out
|
|
611
|
+
# When the home directory IS the project there is one scope, not two.
|
|
612
|
+
if proj_base is not None and proj_base != _global_base():
|
|
613
|
+
out.update(_scope_modes("project", project_path))
|
|
614
|
+
return _lease_overlay(out, project_path)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def bind_session(session_id: str) -> None:
|
|
618
|
+
"""Name the agent session this process evaluates for, so leases bound to
|
|
619
|
+
that session apply. The hook binds per call from its payload; the MCP
|
|
620
|
+
server binds before each tool call. Unbound, only all-session leases
|
|
621
|
+
apply."""
|
|
622
|
+
global _SESSION_ID
|
|
623
|
+
_SESSION_ID = str(session_id or "")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _lease_overlay(modes: dict, project_path: str) -> dict:
|
|
627
|
+
"""*modes* with live leases applied. A lease only loosens: one that is
|
|
628
|
+
stricter than the standing mode is ignored."""
|
|
629
|
+
home = _global_base()
|
|
630
|
+
if home is None or not (home / ".c3" / LEASES_FILE).is_file():
|
|
631
|
+
return modes
|
|
632
|
+
from services import builtin_leases # noqa: PLC0415 — only when leases exist
|
|
633
|
+
for glob, mode in builtin_leases.live_modes(project_path, _SESSION_ID).items():
|
|
634
|
+
if builtin_strictness(glob, mode) < builtin_strictness(
|
|
635
|
+
glob, modes.get(glob, "default")):
|
|
636
|
+
modes[glob] = mode
|
|
637
|
+
return modes
|
|
607
638
|
|
|
608
639
|
|
|
609
640
|
def builtin_mode_realms(project_path: str = ".") -> dict:
|
|
@@ -621,6 +652,31 @@ def builtin_mode_realms(project_path: str = ".") -> dict:
|
|
|
621
652
|
return out
|
|
622
653
|
|
|
623
654
|
|
|
655
|
+
_KIND_RANK = {_KIND_CONFIRM: 1, _KIND_READ_ONLY: 2, _KIND_DENY: 3}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def builtin_strictness(glob, mode: str) -> int:
|
|
659
|
+
"""How strict *mode* makes a Tier-1 builtin: 0 (allow) to 3 (full deny).
|
|
660
|
+
|
|
661
|
+
The same mode word means different rules per tier — ``default`` is a full
|
|
662
|
+
deny on ``**/.env*`` and read-only on ``**/.git/**`` — so loosening is
|
|
663
|
+
only comparable through this. Raises ValueError for a glob that is not
|
|
664
|
+
Tier 1 or an unknown mode.
|
|
665
|
+
"""
|
|
666
|
+
canon = _norm_builtin(glob)
|
|
667
|
+
variants = _BUILTIN_VARIANTS.get(canon)
|
|
668
|
+
if variants is None:
|
|
669
|
+
raise ValueError(f"'{_norm_glob(glob)}' is not a disableable builtin "
|
|
670
|
+
f"— expected one of: {', '.join(DISABLEABLE_BUILTINS)}")
|
|
671
|
+
if mode not in BUILTIN_MODES + ("default",):
|
|
672
|
+
raise ValueError(f"unknown mode '{mode}' — expected one of: "
|
|
673
|
+
f"{', '.join(BUILTIN_MODES + ('default',))}")
|
|
674
|
+
if mode == "allow":
|
|
675
|
+
return 0
|
|
676
|
+
return _KIND_RANK[variants[mode if mode in ("deny", "confirm")
|
|
677
|
+
else "default"].kind]
|
|
678
|
+
|
|
679
|
+
|
|
624
680
|
def disabled_builtins(project_path: str = ".") -> frozenset:
|
|
625
681
|
"""Canonical globs of builtins that are genuinely OFF right now — the
|
|
626
682
|
legacy two-key opt-out plus mode="allow" entries. Callers that only ask
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Temporary and per-session modes for Tier-1 builtin guards.
|
|
2
|
+
|
|
3
|
+
A lease loosens one builtin to ``confirm`` or ``allow`` until it expires, for
|
|
4
|
+
every session (``session_id`` empty) or for one agent session. It never
|
|
5
|
+
touches the permanent two-key mode: when a lease lapses the guard is back
|
|
6
|
+
where it was, with nothing to undo.
|
|
7
|
+
|
|
8
|
+
Leases are two-key like modes. A row in ``~/.c3/builtin_leases.json`` counts
|
|
9
|
+
only while the keyring (service ``c3-access``, account ``lease|<id>``) holds
|
|
10
|
+
the sha256 of that exact row, so a row written, copied or extended by hand
|
|
11
|
+
has no matching attestation and is ignored. Every failure path ignores the
|
|
12
|
+
lease, which leaves the guard on.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import secrets
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
|
|
21
|
+
from services import access_guard as ag
|
|
22
|
+
from services.atomic_json import write_json_atomic
|
|
23
|
+
|
|
24
|
+
LEASE_MODES = ("confirm", "allow")
|
|
25
|
+
MIN_TTL_S = 60
|
|
26
|
+
MAX_TTL_S = 8 * 3600
|
|
27
|
+
_LOCK_FILE = ".c3/builtin_leases.lock"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> datetime:
|
|
31
|
+
return datetime.now(timezone.utc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse(value) -> datetime | None:
|
|
35
|
+
try:
|
|
36
|
+
dt = datetime.fromisoformat(str(value))
|
|
37
|
+
except ValueError:
|
|
38
|
+
return None
|
|
39
|
+
return dt if dt.tzinfo else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _store():
|
|
43
|
+
base = ag._global_base()
|
|
44
|
+
return base / ".c3" / ag.LEASES_FILE if base is not None else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _account(lease_id: str) -> str:
|
|
48
|
+
return f"lease|{lease_id}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _digest(row: dict) -> str:
|
|
52
|
+
blob = json.dumps(row, sort_keys=True, separators=(",", ":"))
|
|
53
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _attested(row: dict) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
import keyring # noqa: PLC0415 — lazy, as in access_guard
|
|
59
|
+
return keyring.get_password(ag._ACCESS_KEYRING_SERVICE,
|
|
60
|
+
_account(str(row.get("id")))) == _digest(row)
|
|
61
|
+
except Exception:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load() -> list:
|
|
66
|
+
path = _store()
|
|
67
|
+
if path is None or not path.is_file():
|
|
68
|
+
return []
|
|
69
|
+
try:
|
|
70
|
+
rows = json.loads(path.read_text(encoding="utf-8")).get("leases")
|
|
71
|
+
except (OSError, ValueError, AttributeError):
|
|
72
|
+
return []
|
|
73
|
+
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _live(row: dict, at: datetime) -> bool:
|
|
77
|
+
exp = _parse(row.get("expires_at"))
|
|
78
|
+
return exp is not None and at < exp
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def active() -> list:
|
|
82
|
+
"""Live, attested leases, soonest to expire first."""
|
|
83
|
+
at = _now()
|
|
84
|
+
return sorted((r for r in _load() if _live(r, at) and _attested(r)),
|
|
85
|
+
key=lambda r: str(r.get("expires_at")))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def live_modes(project_path: str, session_id: str) -> dict:
|
|
89
|
+
"""``{canonical_glob: mode}`` from the leases that reach this project and
|
|
90
|
+
session. ``allow`` wins over ``confirm`` when two leases cover one glob."""
|
|
91
|
+
rows = _load()
|
|
92
|
+
if not rows:
|
|
93
|
+
return {}
|
|
94
|
+
realms = {"global", ag.realm("project", project_path)}
|
|
95
|
+
at = _now()
|
|
96
|
+
out = {}
|
|
97
|
+
for r in rows:
|
|
98
|
+
if (r.get("realm") in realms and r.get("mode") in LEASE_MODES
|
|
99
|
+
and r.get("session_id", "") in ("", session_id)
|
|
100
|
+
and _live(r, at) and _attested(r)):
|
|
101
|
+
if out.get(r["glob"]) != "allow":
|
|
102
|
+
out[r["glob"]] = r["mode"]
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _save(rows: list) -> None:
|
|
107
|
+
write_json_atomic(_store(), {"leases": rows})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _lock():
|
|
111
|
+
from services.override_grants import _Lock # noqa: PLC0415 — off the hot path
|
|
112
|
+
return _Lock(ag._global_base(), _LOCK_FILE)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mint(glob, mode: str, *, scope: str, project_path: str = ".",
|
|
116
|
+
session_id: str = "", ttl_s: int, created_by: str) -> dict:
|
|
117
|
+
"""Create a lease and return its row.
|
|
118
|
+
|
|
119
|
+
Raises ValueError for a glob that is not Tier 1, a mode other than
|
|
120
|
+
confirm/allow, a TTL outside 60 s–8 h, a scope without a realm, or a
|
|
121
|
+
keyring that will not attest (attestation is written first, so a lease
|
|
122
|
+
that could never count is never stored).
|
|
123
|
+
"""
|
|
124
|
+
if mode not in LEASE_MODES:
|
|
125
|
+
raise ValueError(f"a lease mode must be one of: {', '.join(LEASE_MODES)}")
|
|
126
|
+
ag.builtin_strictness(glob, mode)
|
|
127
|
+
try:
|
|
128
|
+
ttl = int(ttl_s)
|
|
129
|
+
except (TypeError, ValueError):
|
|
130
|
+
raise ValueError("ttl_s must be a whole number of seconds") from None
|
|
131
|
+
if not MIN_TTL_S <= ttl <= MAX_TTL_S:
|
|
132
|
+
raise ValueError(f"ttl_s must be between {MIN_TTL_S} and {MAX_TTL_S}")
|
|
133
|
+
if scope not in ("global", "project"):
|
|
134
|
+
raise ValueError("scope must be 'global' or 'project'")
|
|
135
|
+
realm = ag.realm(scope, project_path)
|
|
136
|
+
if not realm or _store() is None:
|
|
137
|
+
raise ValueError("no home directory to hold the lease")
|
|
138
|
+
at = _now()
|
|
139
|
+
row = {
|
|
140
|
+
"id": "bls_" + secrets.token_hex(6),
|
|
141
|
+
"glob": ag._norm_builtin(glob),
|
|
142
|
+
"mode": mode,
|
|
143
|
+
"scope": scope,
|
|
144
|
+
"realm": realm,
|
|
145
|
+
"project_path": str(project_path) if scope == "project" else "",
|
|
146
|
+
"session_id": str(session_id or ""),
|
|
147
|
+
"created_at": at.isoformat(),
|
|
148
|
+
"expires_at": (at + timedelta(seconds=ttl)).isoformat(),
|
|
149
|
+
"created_by": str(created_by),
|
|
150
|
+
}
|
|
151
|
+
try:
|
|
152
|
+
import keyring # noqa: PLC0415
|
|
153
|
+
keyring.set_password(ag._ACCESS_KEYRING_SERVICE, _account(row["id"]),
|
|
154
|
+
_digest(row))
|
|
155
|
+
except Exception:
|
|
156
|
+
raise ValueError("keyring unavailable, so the lease cannot be attested "
|
|
157
|
+
"and would not take effect") from None
|
|
158
|
+
with _lock():
|
|
159
|
+
rows = [r for r in _load() if _live(r, at)]
|
|
160
|
+
rows.append(row)
|
|
161
|
+
_save(rows)
|
|
162
|
+
return row
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def revoke(lease_id: str) -> dict | None:
|
|
166
|
+
"""Remove a lease; returns the removed row, or None if it was not live.
|
|
167
|
+
Expired rows are swept on the way."""
|
|
168
|
+
if _store() is None:
|
|
169
|
+
return None
|
|
170
|
+
at = _now()
|
|
171
|
+
with _lock():
|
|
172
|
+
rows = _load()
|
|
173
|
+
gone = next((r for r in rows if r.get("id") == lease_id), None)
|
|
174
|
+
_save([r for r in rows if r is not gone and _live(r, at)])
|
|
175
|
+
if gone is None:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
import keyring # noqa: PLC0415
|
|
179
|
+
keyring.set_password(ag._ACCESS_KEYRING_SERVICE, _account(lease_id), "")
|
|
180
|
+
except Exception: # a keyring that cannot be written cannot be read either
|
|
181
|
+
pass # so a re-added copy of the row stays unattested
|
|
182
|
+
return gone if _live(gone, at) else None
|
services/project_manager.py
CHANGED
|
@@ -794,11 +794,19 @@ class ProjectManager:
|
|
|
794
794
|
"initialized": bool(cfg),
|
|
795
795
|
}
|
|
796
796
|
|
|
797
|
+
def get_ide_cmd(self, path: str) -> str:
|
|
798
|
+
"""Per-project launch command override (e.g. `yep` instead of `claude`)."""
|
|
799
|
+
path = str(Path(path).resolve())
|
|
800
|
+
for p in self._read_projects():
|
|
801
|
+
if p["path"] == path:
|
|
802
|
+
return str(p.get("ide_cmd") or "").strip()
|
|
803
|
+
return ""
|
|
804
|
+
|
|
797
805
|
def update_project(self, path: str, **fields) -> bool:
|
|
798
|
-
"""Update editable project fields: name, tags, notes."""
|
|
806
|
+
"""Update editable project fields: name, tags, notes, ide_cmd."""
|
|
799
807
|
path = str(Path(path).resolve())
|
|
800
808
|
projects = self._read_projects()
|
|
801
|
-
allowed = {"name", "tags", "notes", "autostart_ui"}
|
|
809
|
+
allowed = {"name", "tags", "notes", "autostart_ui", "ide_cmd"}
|
|
802
810
|
for p in projects:
|
|
803
811
|
if p["path"] == path:
|
|
804
812
|
for k, v in fields.items():
|
|
File without changes
|
{code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.146.0.dist-info → code_context_control-2.149.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|