abstract-serve-core 0.1.1__tar.gz
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.
- abstract_serve_core-0.1.1/PKG-INFO +19 -0
- abstract_serve_core-0.1.1/README.md +8 -0
- abstract_serve_core-0.1.1/pyproject.toml +29 -0
- abstract_serve_core-0.1.1/setup.cfg +4 -0
- abstract_serve_core-0.1.1/src/abstract_serve/__init__.py +10 -0
- abstract_serve_core-0.1.1/src/abstract_serve/__main__.py +6 -0
- abstract_serve_core-0.1.1/src/abstract_serve/actions.py +594 -0
- abstract_serve_core-0.1.1/src/abstract_serve/attachments.py +128 -0
- abstract_serve_core-0.1.1/src/abstract_serve/cfgdir.py +272 -0
- abstract_serve_core-0.1.1/src/abstract_serve/cli.py +384 -0
- abstract_serve_core-0.1.1/src/abstract_serve/console_service.py +1109 -0
- abstract_serve_core-0.1.1/src/abstract_serve/hub.py +351 -0
- abstract_serve_core-0.1.1/src/abstract_serve/keepalive.py +225 -0
- abstract_serve_core-0.1.1/src/abstract_serve/launch.py +286 -0
- abstract_serve_core-0.1.1/src/abstract_serve/mcp.py +852 -0
- abstract_serve_core-0.1.1/src/abstract_serve/mct_archive.py +203 -0
- abstract_serve_core-0.1.1/src/abstract_serve/mct_repl.py +1128 -0
- abstract_serve_core-0.1.1/src/abstract_serve/models.py +298 -0
- abstract_serve_core-0.1.1/src/abstract_serve/paths.py +52 -0
- abstract_serve_core-0.1.1/src/abstract_serve/pricing.py +235 -0
- abstract_serve_core-0.1.1/src/abstract_serve/queue.py +144 -0
- abstract_serve_core-0.1.1/src/abstract_serve/reduce.py +316 -0
- abstract_serve_core-0.1.1/src/abstract_serve/relay.py +265 -0
- abstract_serve_core-0.1.1/src/abstract_serve/rollover.py +995 -0
- abstract_serve_core-0.1.1/src/abstract_serve/roster.py +729 -0
- abstract_serve_core-0.1.1/src/abstract_serve/seat_report.py +141 -0
- abstract_serve_core-0.1.1/src/abstract_serve/serve_cli.py +44 -0
- abstract_serve_core-0.1.1/src/abstract_serve/server.py +1659 -0
- abstract_serve_core-0.1.1/src/abstract_serve/templates/dot-claude.json +16 -0
- abstract_serve_core-0.1.1/src/abstract_serve/templates/settings.json +20 -0
- abstract_serve_core-0.1.1/src/abstract_serve/token_test.py +176 -0
- abstract_serve_core-0.1.1/src/abstract_serve/toolset.py +43 -0
- abstract_serve_core-0.1.1/src/abstract_serve/usage_db.py +1156 -0
- abstract_serve_core-0.1.1/src/abstract_serve/util.py +236 -0
- abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/backend-sessions.js +180 -0
- abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/index-DaUql2da.js +59 -0
- abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/index-oJz42rZV.css +34 -0
- abstract_serve_core-0.1.1/src/abstract_serve/webui/assets/keeper-live.js +2293 -0
- abstract_serve_core-0.1.1/src/abstract_serve/webui/index.html +30 -0
- abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/PKG-INFO +19 -0
- abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/SOURCES.txt +58 -0
- abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/dependency_links.txt +1 -0
- abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/entry_points.txt +3 -0
- abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/requires.txt +4 -0
- abstract_serve_core-0.1.1/src/abstract_serve_core.egg-info/top_level.txt +1 -0
- abstract_serve_core-0.1.1/tests/test_attach.py +99 -0
- abstract_serve_core-0.1.1/tests/test_console_service.py +290 -0
- abstract_serve_core-0.1.1/tests/test_governor.py +77 -0
- abstract_serve_core-0.1.1/tests/test_harness_classify.py +83 -0
- abstract_serve_core-0.1.1/tests/test_interrupt.py +46 -0
- abstract_serve_core-0.1.1/tests/test_models.py +153 -0
- abstract_serve_core-0.1.1/tests/test_pricing.py +234 -0
- abstract_serve_core-0.1.1/tests/test_queue.py +45 -0
- abstract_serve_core-0.1.1/tests/test_reduce.py +147 -0
- abstract_serve_core-0.1.1/tests/test_relay.py +284 -0
- abstract_serve_core-0.1.1/tests/test_rollover.py +53 -0
- abstract_serve_core-0.1.1/tests/test_roster.py +298 -0
- abstract_serve_core-0.1.1/tests/test_roster_archive.py +139 -0
- abstract_serve_core-0.1.1/tests/test_session_events.py +130 -0
- abstract_serve_core-0.1.1/tests/test_turn_uuid.py +62 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abstract-serve-core
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Provider-neutral shared Serve console for Claude, GPT, Hugpy, and local model backends.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
9
|
+
Provides-Extra: gpt
|
|
10
|
+
Requires-Dist: abstract-gpt>=0.1.9; extra == "gpt"
|
|
11
|
+
|
|
12
|
+
# abstract-serve
|
|
13
|
+
|
|
14
|
+
The provider-neutral shared Serve console. It owns the durable conversation
|
|
15
|
+
store, queue/hold contract, session picker, and provider adapters. Claude, GPT,
|
|
16
|
+
Hugpy, and local-model packages may provide adapters without owning the Serve
|
|
17
|
+
runtime.
|
|
18
|
+
|
|
19
|
+
Run `abstract-serve --no-browser` to start the console.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# abstract-serve
|
|
2
|
+
|
|
3
|
+
The provider-neutral shared Serve console. It owns the durable conversation
|
|
4
|
+
store, queue/hold contract, session picker, and provider adapters. Claude, GPT,
|
|
5
|
+
Hugpy, and local-model packages may provide adapters without owning the Serve
|
|
6
|
+
runtime.
|
|
7
|
+
|
|
8
|
+
Run `abstract-serve --no-browser` to start the console.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "abstract-serve-core"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Provider-neutral shared Serve console for Claude, GPT, Hugpy, and local model backends."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
dependencies = ["prompt_toolkit>=3.0"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
gpt = ["abstract-gpt>=0.1.9"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
abstract-serve = "abstract_serve.serve_cli:main"
|
|
19
|
+
abstract-serve-mcp = "abstract_serve.mcp:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
package-dir = { "" = "src" }
|
|
23
|
+
include-package-data = true
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
abstract_serve = ["templates/*.json", "webui/*", "webui/assets/*"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""abstract-serve — provider-neutral shared Serve console."""
|
|
2
|
+
try: # the installed distribution's version (pyproject), never a hand-kept string
|
|
3
|
+
from importlib.metadata import version as _v
|
|
4
|
+
__version__ = _v("abstract-serve")
|
|
5
|
+
except Exception: # running from a source checkout without metadata
|
|
6
|
+
__version__ = "0.0.0+src"
|
|
7
|
+
|
|
8
|
+
from .toolset import get_toolset # noqa: E402
|
|
9
|
+
|
|
10
|
+
__all__ = ["get_toolset"]
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Core actions: template save/restore/reset, oauth, state, and root init."""
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from . import paths
|
|
6
|
+
from .paths import (CONFIG_PATH, HOME, LIVE_CLAUDE_DIR, LIVE_DOTCLAUDE,
|
|
7
|
+
LIVE_SETTINGS, ROOT, SESSIONS_DIR, TEMPLATE_DIR, TPL_DOTCLAUDE,
|
|
8
|
+
TPL_SETTINGS)
|
|
9
|
+
from .util import (OAUTH_ENV, _operator_token, has_claude_auth, has_real_login, load_json,
|
|
10
|
+
load_oauth_token,
|
|
11
|
+
real_claude, run, save_json, store_oauth_token, which_claude_auth)
|
|
12
|
+
|
|
13
|
+
# Session-settings surfaced by the console (valid keys in current CC schema).
|
|
14
|
+
BOOL_KEYS = [
|
|
15
|
+
"enableArtifact", "workflowKeywordTriggerEnabled", "promptSuggestionEnabled",
|
|
16
|
+
"awaySummaryEnabled", "skipDangerousModePermissionPrompt", "autoCompactEnabled",
|
|
17
|
+
"switchModelsOnFlag", "autoScrollEnabled", "fileCheckpointingEnabled",
|
|
18
|
+
"terminalProgressBarEnabled", "remoteControlAtStartup", "inputNeededNotifEnabled",
|
|
19
|
+
"agentPushNotifEnabled",
|
|
20
|
+
]
|
|
21
|
+
EFFORT_VALUES = ["low", "medium", "high", "xhigh"]
|
|
22
|
+
PERM_MODES = ["acceptEdits", "auto", "bypassPermissions", "default", "plan"]
|
|
23
|
+
|
|
24
|
+
# Minimal flags a fresh ~/.claude.json needs to skip onboarding/trust prompts.
|
|
25
|
+
FLAG_KEYS = ["hasCompletedOnboarding", "bypassPermissionsModeAccepted",
|
|
26
|
+
"hasSeenAutoModeEntryWarning", "hasSeenTasksHint"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pkg_default(name):
|
|
30
|
+
"""Read a shipped default template file from package data."""
|
|
31
|
+
try:
|
|
32
|
+
from importlib.resources import files
|
|
33
|
+
return (files("abstract_claude.templates") / name).read_text()
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def init_root():
|
|
39
|
+
"""Materialize AC_ROOT with config.json + template/ from shipped defaults if missing."""
|
|
40
|
+
os.makedirs(TEMPLATE_DIR, exist_ok=True)
|
|
41
|
+
created = []
|
|
42
|
+
if not os.path.exists(CONFIG_PATH):
|
|
43
|
+
save_json(CONFIG_PATH, dict(paths.DEFAULTS))
|
|
44
|
+
created.append(CONFIG_PATH)
|
|
45
|
+
for dst, name in ((TPL_SETTINGS, "settings.json"), (TPL_DOTCLAUDE, "dot-claude.json")):
|
|
46
|
+
if not os.path.exists(dst):
|
|
47
|
+
data = _pkg_default(name)
|
|
48
|
+
if data is not None:
|
|
49
|
+
with open(dst, "w") as f:
|
|
50
|
+
f.write(data)
|
|
51
|
+
created.append(dst)
|
|
52
|
+
return created
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_template():
|
|
56
|
+
"""Capture current settings.json + derive minimal flags into the template."""
|
|
57
|
+
os.makedirs(TEMPLATE_DIR, exist_ok=True)
|
|
58
|
+
live = load_json(LIVE_SETTINGS, {})
|
|
59
|
+
save_json(TPL_SETTINGS, live)
|
|
60
|
+
dot = load_json(LIVE_DOTCLAUDE, {})
|
|
61
|
+
tpl_existing = load_json(TPL_DOTCLAUDE, {})
|
|
62
|
+
flags = {k: bool(dot.get(k, True)) for k in FLAG_KEYS}
|
|
63
|
+
# preserve MCP server registration (from live if present, else keep the template's)
|
|
64
|
+
mcp = dot.get("mcpServers") or tpl_existing.get("mcpServers")
|
|
65
|
+
if mcp:
|
|
66
|
+
flags["mcpServers"] = mcp
|
|
67
|
+
save_json(TPL_DOTCLAUDE, flags)
|
|
68
|
+
return {"ok": True, "out": f"saved settings + flags -> {TEMPLATE_DIR}"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def restore():
|
|
72
|
+
"""Create a fresh ~/.claude (settings + flags) if it does not exist."""
|
|
73
|
+
if os.path.isdir(LIVE_CLAUDE_DIR):
|
|
74
|
+
return {"ok": True, "out": "~/.claude exists (use reset to force a fresh session)"}
|
|
75
|
+
os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
|
|
76
|
+
tpl = load_json(TPL_SETTINGS, {})
|
|
77
|
+
save_json(LIVE_SETTINGS, tpl)
|
|
78
|
+
save_json(LIVE_DOTCLAUDE, load_json(TPL_DOTCLAUDE, {}))
|
|
79
|
+
return {"ok": True, "out": "fresh ~/.claude created; login via CLAUDE_CODE_OAUTH_TOKEN"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def reset(hard=False):
|
|
83
|
+
"""Wipe prior state and reseed the template -> a clean session.
|
|
84
|
+
|
|
85
|
+
By default the interactive login (~/.claude/.credentials.json) is PRESERVED,
|
|
86
|
+
since Claude Code's interactive startup requires it and the env token does not
|
|
87
|
+
suppress the login menu. Pass hard=True to also remove the login.
|
|
88
|
+
"""
|
|
89
|
+
creds_path = os.path.join(LIVE_CLAUDE_DIR, ".credentials.json")
|
|
90
|
+
saved_creds = None
|
|
91
|
+
if not hard:
|
|
92
|
+
try:
|
|
93
|
+
with open(creds_path, "rb") as f:
|
|
94
|
+
saved_creds = f.read()
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
shutil.rmtree(LIVE_CLAUDE_DIR, ignore_errors=True)
|
|
98
|
+
try:
|
|
99
|
+
os.remove(LIVE_DOTCLAUDE)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
pass
|
|
102
|
+
r = restore()
|
|
103
|
+
note = "wiped prior state"
|
|
104
|
+
if saved_creds is not None:
|
|
105
|
+
os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
|
|
106
|
+
with open(creds_path, "wb") as f:
|
|
107
|
+
f.write(saved_creds)
|
|
108
|
+
os.chmod(creds_path, 0o600)
|
|
109
|
+
note += " (kept login)"
|
|
110
|
+
else:
|
|
111
|
+
note += " (no stored login to keep — interactive will ask once)" if not hard \
|
|
112
|
+
else " (hard: login removed)"
|
|
113
|
+
r["out"] = note + "; " + r["out"]
|
|
114
|
+
return r
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_provisioned():
|
|
118
|
+
"""Idempotently ensure ~/.claude carries the template's settings + onboarding/
|
|
119
|
+
trust flags + MCP servers — so no theme/onboarding/trust prompts and tools are
|
|
120
|
+
registered. Non-destructive: never wipes transcripts or login, only ADDS missing
|
|
121
|
+
keys. Safe to call on every launch.
|
|
122
|
+
"""
|
|
123
|
+
changed = []
|
|
124
|
+
tpl_s = load_json(TPL_SETTINGS, {})
|
|
125
|
+
live_s = load_json(LIVE_SETTINGS, {})
|
|
126
|
+
added = not os.path.exists(LIVE_SETTINGS)
|
|
127
|
+
for k, v in tpl_s.items():
|
|
128
|
+
if k not in live_s:
|
|
129
|
+
live_s[k] = v
|
|
130
|
+
added = True
|
|
131
|
+
if added:
|
|
132
|
+
os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
|
|
133
|
+
save_json(LIVE_SETTINGS, live_s)
|
|
134
|
+
changed.append("settings")
|
|
135
|
+
|
|
136
|
+
tpl_d = load_json(TPL_DOTCLAUDE, {})
|
|
137
|
+
live_d = load_json(LIVE_DOTCLAUDE, {})
|
|
138
|
+
added = not os.path.exists(LIVE_DOTCLAUDE)
|
|
139
|
+
for k, v in tpl_d.items():
|
|
140
|
+
if k == "mcpServers":
|
|
141
|
+
live_mcp = dict(live_d.get("mcpServers") or {})
|
|
142
|
+
for name, spec in v.items():
|
|
143
|
+
if name not in live_mcp:
|
|
144
|
+
live_mcp[name] = spec
|
|
145
|
+
added = True
|
|
146
|
+
live_d["mcpServers"] = live_mcp
|
|
147
|
+
elif k not in live_d:
|
|
148
|
+
live_d[k] = v
|
|
149
|
+
added = True
|
|
150
|
+
if added:
|
|
151
|
+
save_json(LIVE_DOTCLAUDE, live_d)
|
|
152
|
+
changed.append("flags")
|
|
153
|
+
return changed
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _pid_alive(pid):
|
|
157
|
+
try:
|
|
158
|
+
os.kill(int(pid), 0)
|
|
159
|
+
return True
|
|
160
|
+
except (ProcessLookupError, ValueError):
|
|
161
|
+
return False
|
|
162
|
+
except PermissionError:
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def list_sessions(sessions_dir=None):
|
|
167
|
+
"""Per-launch session dirs, oldest first: [{path, name, pid, alive, started, cwd}]."""
|
|
168
|
+
base = sessions_dir or SESSIONS_DIR
|
|
169
|
+
out = []
|
|
170
|
+
try:
|
|
171
|
+
names = sorted(os.listdir(base))
|
|
172
|
+
except FileNotFoundError:
|
|
173
|
+
return out
|
|
174
|
+
for n in names:
|
|
175
|
+
d = os.path.join(base, n)
|
|
176
|
+
if not os.path.isdir(d):
|
|
177
|
+
continue
|
|
178
|
+
meta = load_json(os.path.join(d, "session.json"), {})
|
|
179
|
+
pid = meta.get("pid") or (n.rsplit("-", 1)[-1] if "-" in n else "")
|
|
180
|
+
out.append({"path": d, "name": n, "pid": pid, "alive": _pid_alive(pid) if pid else False,
|
|
181
|
+
"started": meta.get("started", ""), "cwd": meta.get("cwd", "")})
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def prune_sessions(keep=30, sessions_dir=None):
|
|
186
|
+
"""Remove the oldest finished session dirs beyond `keep` (live pids are never
|
|
187
|
+
touched). keep=0 disables pruning. Returns the removed paths."""
|
|
188
|
+
removed = []
|
|
189
|
+
if not keep or keep < 0:
|
|
190
|
+
return removed
|
|
191
|
+
finished = [s for s in list_sessions(sessions_dir) if not s["alive"]]
|
|
192
|
+
for s in finished[:max(0, len(finished) - keep)]:
|
|
193
|
+
shutil.rmtree(s["path"], ignore_errors=True)
|
|
194
|
+
removed.append(s["path"])
|
|
195
|
+
return removed
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _creds_ok(path):
|
|
199
|
+
d = load_json(path, {})
|
|
200
|
+
return bool(((d.get("claudeAiOauth") or {}).get("accessToken") or "").strip())
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def freshest_credentials(sessions_dir=None):
|
|
204
|
+
"""Path of the newest NON-EMPTY .credentials.json among ~/.claude and every
|
|
205
|
+
per-launch session dir — Claude Code rotates the OAuth refresh token inside
|
|
206
|
+
whichever config dir it runs in, so the holder copy in ~/.claude goes stale
|
|
207
|
+
after a session refreshes. The freshest copy is synced back into ~/.claude
|
|
208
|
+
(the holder) so it stays current. Returns '' when there is no login at all."""
|
|
209
|
+
base = sessions_dir or SESSIONS_DIR
|
|
210
|
+
holder = os.path.join(LIVE_CLAUDE_DIR, ".credentials.json")
|
|
211
|
+
cands = [holder]
|
|
212
|
+
try:
|
|
213
|
+
cands += [os.path.join(base, n, ".credentials.json") for n in os.listdir(base)]
|
|
214
|
+
except FileNotFoundError:
|
|
215
|
+
pass
|
|
216
|
+
best, best_m = "", -1.0
|
|
217
|
+
for c in cands:
|
|
218
|
+
try:
|
|
219
|
+
m = os.stat(c).st_mtime
|
|
220
|
+
except OSError:
|
|
221
|
+
continue
|
|
222
|
+
if m > best_m and _creds_ok(c):
|
|
223
|
+
best, best_m = c, m
|
|
224
|
+
if best and best != holder:
|
|
225
|
+
try:
|
|
226
|
+
os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
|
|
227
|
+
shutil.copyfile(best, holder)
|
|
228
|
+
os.chmod(holder, 0o600)
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
return best
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _template_doc(path, shipped_name):
|
|
235
|
+
"""AC_ROOT template file, else the package's shipped default (a seat with no
|
|
236
|
+
AC_ROOT materialized still gets a sane profile), else {}."""
|
|
237
|
+
doc = load_json(path, None)
|
|
238
|
+
if isinstance(doc, dict):
|
|
239
|
+
return doc
|
|
240
|
+
import json
|
|
241
|
+
try:
|
|
242
|
+
return json.loads(_pkg_default(shipped_name) or "{}")
|
|
243
|
+
except Exception:
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _login_identity():
|
|
248
|
+
"""oauthAccount/userID from the live ~/.claude.json (or ~/.claude/.claude.json)."""
|
|
249
|
+
holder = load_json(LIVE_DOTCLAUDE, None)
|
|
250
|
+
if not isinstance(holder, dict):
|
|
251
|
+
holder = load_json(os.path.join(LIVE_CLAUDE_DIR, ".claude.json"), {}) or {}
|
|
252
|
+
return {k: holder[k] for k in ("oauthAccount", "userID") if holder.get(k)}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _session_settings(settings=None):
|
|
256
|
+
"""The settings.json a fresh session starts with: the given doc, else
|
|
257
|
+
$AC_SETTINGS_JSON (an embedder such as hugpy-station), else the template."""
|
|
258
|
+
import json
|
|
259
|
+
if settings is None and os.environ.get("AC_SETTINGS_JSON"):
|
|
260
|
+
try:
|
|
261
|
+
settings = json.loads(os.environ["AC_SETTINGS_JSON"])
|
|
262
|
+
except Exception:
|
|
263
|
+
settings = None
|
|
264
|
+
if not isinstance(settings, dict):
|
|
265
|
+
settings = _template_doc(TPL_SETTINGS, "settings.json")
|
|
266
|
+
return settings
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def fresh_home(cwd=None, settings=None):
|
|
270
|
+
"""`abstract-claude reset` semantics on every launch (operator ruling
|
|
271
|
+
2026-09-04: "delete the .claude then run claude — no memory"): the existing
|
|
272
|
+
reset() clears ~/.claude + ~/.claude.json and keeps only the login; then the
|
|
273
|
+
session settings (template or $AC_SETTINGS_JSON) and fresh flags carrying the
|
|
274
|
+
login identity are written so Claude Code starts signed in with no
|
|
275
|
+
transcripts, history, todos, project trust or caches from earlier sessions.
|
|
276
|
+
Returns ~/.claude."""
|
|
277
|
+
cwd = cwd or os.getcwd()
|
|
278
|
+
freshest_credentials() # newest rotated token into the holder first
|
|
279
|
+
ident = _login_identity() # captured BEFORE reset removes ~/.claude.json
|
|
280
|
+
settings = _session_settings(settings)
|
|
281
|
+
reset(hard=False)
|
|
282
|
+
save_json(LIVE_SETTINGS, settings)
|
|
283
|
+
save_json(LIVE_DOTCLAUDE, session_flags(cwd, identity=ident))
|
|
284
|
+
return LIVE_CLAUDE_DIR
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def session_flags(cwd, tpl=None, identity=None):
|
|
288
|
+
"""The .claude.json a fresh session starts with: template flags + MCP
|
|
289
|
+
servers, the login IDENTITY carried from the holder (~/.claude.json
|
|
290
|
+
oauthAccount/userID — without it interactive startup shows the login
|
|
291
|
+
picker even with valid credentials), onboarding/trust flags forced on,
|
|
292
|
+
folder trust for `cwd` (no trust dialog), and the toolserver MCP bridge
|
|
293
|
+
given the fleet operator token when one is resolvable. Never carries
|
|
294
|
+
project history, per-project allow-lists or any other session state."""
|
|
295
|
+
d = dict(tpl if tpl is not None else _template_doc(TPL_DOTCLAUDE, "dot-claude.json"))
|
|
296
|
+
holder = identity if isinstance(identity, dict) else load_json(LIVE_DOTCLAUDE, None)
|
|
297
|
+
if not isinstance(holder, dict):
|
|
298
|
+
holder = load_json(os.path.join(LIVE_CLAUDE_DIR, ".claude.json"), {}) or {}
|
|
299
|
+
for k in ("oauthAccount", "userID"):
|
|
300
|
+
if k not in d and holder.get(k):
|
|
301
|
+
d[k] = holder[k]
|
|
302
|
+
for k in FLAG_KEYS:
|
|
303
|
+
d.setdefault(k, True)
|
|
304
|
+
if cwd:
|
|
305
|
+
d.setdefault("projects", {}).setdefault(cwd, {})["hasTrustDialogAccepted"] = True
|
|
306
|
+
ms = d.setdefault("mcpServers", {})
|
|
307
|
+
cur = ms.get("toolserver") or {}
|
|
308
|
+
if not cur or cur.get("command") == "abstract-claude":
|
|
309
|
+
env = dict(cur.get("env") or {})
|
|
310
|
+
url = (os.environ.get("TOOLSERVER_URL") or "").strip().rstrip("/")
|
|
311
|
+
env["TOOLSERVER_URL"] = url or env.get("TOOLSERVER_URL") or "https://toolserver.hugpy.ai"
|
|
312
|
+
tok = _operator_token()
|
|
313
|
+
if tok:
|
|
314
|
+
env["TOOLSERVER_TOKEN"] = tok
|
|
315
|
+
ms["toolserver"] = {"type": "stdio", "command": "abstract-claude", "args": ["mcp"], "env": env}
|
|
316
|
+
return d
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def new_session_dir(keep_login=True, sessions_dir=None, keep=30, pid=None, cwd=None,
|
|
320
|
+
settings=None, label=""):
|
|
321
|
+
"""Materialize a BRAND-NEW per-session Claude config dir (for CLAUDE_CONFIG_DIR):
|
|
322
|
+
settings.json (the `settings` doc, else $AC_SETTINGS_JSON, else the template),
|
|
323
|
+
.claude.json from session_flags(), and a copy of the freshest login so the
|
|
324
|
+
login menu never shows. Nothing from earlier sessions (transcripts, history,
|
|
325
|
+
project trust) is carried over. Returns the new dir path."""
|
|
326
|
+
import json, re, time
|
|
327
|
+
base = sessions_dir or SESSIONS_DIR
|
|
328
|
+
pid = pid or os.getpid()
|
|
329
|
+
cwd = cwd or os.getcwd()
|
|
330
|
+
os.makedirs(base, mode=0o700, exist_ok=True)
|
|
331
|
+
label = re.sub(r"[^A-Za-z0-9_.-]+", "-", label or os.environ.get("AC_SESSION_LABEL", ""))[:32]
|
|
332
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
333
|
+
stem = f"{stamp}-{pid}" + (f"-{label}" if label else "")
|
|
334
|
+
d = os.path.join(base, stem)
|
|
335
|
+
n = 0
|
|
336
|
+
while os.path.exists(d):
|
|
337
|
+
n += 1
|
|
338
|
+
d = os.path.join(base, f"{stem}.{n}")
|
|
339
|
+
os.makedirs(d, mode=0o700)
|
|
340
|
+
if settings is None and os.environ.get("AC_SETTINGS_JSON"):
|
|
341
|
+
try:
|
|
342
|
+
settings = json.loads(os.environ["AC_SETTINGS_JSON"])
|
|
343
|
+
except Exception:
|
|
344
|
+
settings = None
|
|
345
|
+
if not isinstance(settings, dict):
|
|
346
|
+
settings = _template_doc(TPL_SETTINGS, "settings.json")
|
|
347
|
+
save_json(os.path.join(d, "settings.json"), settings)
|
|
348
|
+
save_json(os.path.join(d, ".claude.json"), session_flags(cwd))
|
|
349
|
+
kept_login = False
|
|
350
|
+
if keep_login:
|
|
351
|
+
src = freshest_credentials(base)
|
|
352
|
+
if src:
|
|
353
|
+
dst = os.path.join(d, ".credentials.json")
|
|
354
|
+
shutil.copyfile(src, dst)
|
|
355
|
+
os.chmod(dst, 0o600)
|
|
356
|
+
kept_login = True
|
|
357
|
+
save_json(os.path.join(d, "session.json"), {
|
|
358
|
+
"pid": pid, "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
359
|
+
"cwd": cwd, "label": label, "template": TEMPLATE_DIR, "login_copied": kept_login,
|
|
360
|
+
"settings_source": "env:AC_SETTINGS_JSON" if os.environ.get("AC_SETTINGS_JSON") else "template",
|
|
361
|
+
})
|
|
362
|
+
try:
|
|
363
|
+
prune_sessions(keep, base)
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
return d
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def oauth_status():
|
|
370
|
+
if has_claude_auth():
|
|
371
|
+
r = run([which_claude_auth(), "status"], timeout=20)
|
|
372
|
+
return (r["out"] + (("\n" + r["err"]) if r["err"] else "")).strip()
|
|
373
|
+
tok = load_oauth_token()
|
|
374
|
+
if tok:
|
|
375
|
+
return f"stored token: yes ({tok[:12]}…, {len(tok)} chars); source: abstract_claude store"
|
|
376
|
+
return ("stored token: none — no durable OAuth token for this toolserver. "
|
|
377
|
+
"Solution: POST /claude/oauth_solve to auto-import a token this host can "
|
|
378
|
+
"already see, else mint one on a browser/tty host "
|
|
379
|
+
"(`abstract-claude oauth-mint`) and register it with "
|
|
380
|
+
"`abstract-claude oauth-set <sk-ant-oat…>`. Full plan: /claude/oauth_solution")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def oauth_set(token):
|
|
384
|
+
token = (token or "").strip()
|
|
385
|
+
if not token.startswith("sk-ant-"):
|
|
386
|
+
return {"ok": False, "err": "token must start with sk-ant-"}
|
|
387
|
+
path = store_oauth_token(token) # self-contained store (0600)
|
|
388
|
+
if has_claude_auth(): # keep claude-auth's meta in sync too
|
|
389
|
+
run([which_claude_auth(), "set", token])
|
|
390
|
+
return {"ok": True, "out": f"stored durable token -> {path}"}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def oauth_mint():
|
|
394
|
+
"""Run `claude setup-token` via a pty, capture the printed sk-ant-oat token, store it.
|
|
395
|
+
|
|
396
|
+
The one browser approval still happens interactively (URL is teed to your
|
|
397
|
+
terminal); the token is never shown or copy-pasted — it's parsed and stored 0600.
|
|
398
|
+
"""
|
|
399
|
+
import pty
|
|
400
|
+
import re
|
|
401
|
+
import sys
|
|
402
|
+
|
|
403
|
+
if not sys.stdin.isatty():
|
|
404
|
+
return {"ok": False, "err": "oauth-mint needs a terminal for browser approval; "
|
|
405
|
+
"use `oauth-set <token>` in non-interactive contexts"}
|
|
406
|
+
buf = bytearray()
|
|
407
|
+
|
|
408
|
+
def _read(fd):
|
|
409
|
+
data = os.read(fd, 1024)
|
|
410
|
+
buf.extend(data)
|
|
411
|
+
return data # pty.spawn tees this to your terminal
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
pty.spawn([real_claude(), "setup-token"], _read)
|
|
415
|
+
except Exception as e:
|
|
416
|
+
return {"ok": False, "err": f"setup-token failed: {e}"}
|
|
417
|
+
clean = re.sub(rb"\x1b\[[0-9;]*[A-Za-z]", b"", bytes(buf)) # strip ANSI
|
|
418
|
+
matches = re.findall(rb"sk-ant-oat[0-9A-Za-z_\-]+", clean)
|
|
419
|
+
if not matches:
|
|
420
|
+
return {"ok": False, "err": "no sk-ant-oat token found in setup-token output"}
|
|
421
|
+
token = max(matches, key=len).decode().strip() # the full token
|
|
422
|
+
if len(token) < 80:
|
|
423
|
+
return {"ok": False, "err": f"captured token looks truncated ({len(token)} chars)"}
|
|
424
|
+
path = store_oauth_token(token)
|
|
425
|
+
if has_claude_auth():
|
|
426
|
+
run([which_claude_auth(), "set", token])
|
|
427
|
+
return {"ok": True, "out": f"minted + stored durable token ({token[:12]}…) -> {path}"}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _credentials_token():
|
|
431
|
+
"""Best-effort durable/OAuth token from the live Claude CLI credential store
|
|
432
|
+
(~/.claude/.credentials.json) — left behind by any claude login in this HOME."""
|
|
433
|
+
data = load_json(os.path.join(HOME, ".claude", ".credentials.json"), {})
|
|
434
|
+
oa = (data or {}).get("claudeAiOauth") or {}
|
|
435
|
+
return oa.get("accessToken") or data.get("CLAUDE_CODE_OAUTH_TOKEN") or None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def discover_oauth_sources():
|
|
439
|
+
"""Token candidates this headless host could adopt WITHOUT a browser, best-first.
|
|
440
|
+
Each: {source, path, token, kind}. `kind` is 'durable' for sk-ant-oat… tokens."""
|
|
441
|
+
def _kind(t):
|
|
442
|
+
return "durable" if t.startswith("sk-ant-oat") else "oauth"
|
|
443
|
+
|
|
444
|
+
found, seen = [], set()
|
|
445
|
+
for source, path, tok in (
|
|
446
|
+
("env:CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
|
|
447
|
+
os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")),
|
|
448
|
+
("claude-cli-credentials", os.path.join(HOME, ".claude", ".credentials.json"),
|
|
449
|
+
_credentials_token()),
|
|
450
|
+
):
|
|
451
|
+
if tok and tok not in seen:
|
|
452
|
+
seen.add(tok)
|
|
453
|
+
found.append({"source": source, "path": path, "token": tok, "kind": _kind(tok)})
|
|
454
|
+
return found
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def oauth_solution():
|
|
458
|
+
"""Offer a concrete way to ATTAIN a durable OAuth token when none is stored.
|
|
459
|
+
|
|
460
|
+
Read-only: reports current state, any token this host could auto-import, and the
|
|
461
|
+
exact ordered steps. `/claude/oauth_solve` performs the auto-import; minting a
|
|
462
|
+
fresh token needs a browser/tty and therefore happens off-server.
|
|
463
|
+
"""
|
|
464
|
+
tok = load_oauth_token()
|
|
465
|
+
if tok:
|
|
466
|
+
return {"ok": True, "have_token": True, "stored_to": OAUTH_ENV,
|
|
467
|
+
"message": f"durable token already stored ({tok[:12]}…, {len(tok)} chars)"}
|
|
468
|
+
sources = discover_oauth_sources()
|
|
469
|
+
steps = []
|
|
470
|
+
if sources:
|
|
471
|
+
steps.append("Auto-import a token this host can already see: "
|
|
472
|
+
"POST /claude/oauth_solve (adopts the best source below).")
|
|
473
|
+
steps.append("Mint a fresh 1-year token on a machine WITH a browser/tty "
|
|
474
|
+
"(`abstract-claude oauth-mint`, or `claude setup-token`), then register "
|
|
475
|
+
"it here: `abstract-claude oauth-set <sk-ant-oat…>` "
|
|
476
|
+
"(POST /claude/oauth_set {\"token\": \"sk-ant-oat…\"}).")
|
|
477
|
+
return {
|
|
478
|
+
"ok": False, "have_token": False,
|
|
479
|
+
"reason": "no durable OAuth token stored for this toolserver",
|
|
480
|
+
"can_auto_import": bool(sources),
|
|
481
|
+
"importable_sources": [
|
|
482
|
+
{"source": s["source"], "path": s["path"], "kind": s["kind"],
|
|
483
|
+
"preview": s["token"][:12] + "…"} for s in sources
|
|
484
|
+
],
|
|
485
|
+
"steps": steps,
|
|
486
|
+
"host_note": "this toolserver runs headless (no browser/tty), so a fresh mint "
|
|
487
|
+
"must happen elsewhere; oauth_solve/oauth_set register it here",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def oauth_solve(import_from=None):
|
|
492
|
+
"""Attain a durable token automatically when possible: no-op if one is already
|
|
493
|
+
stored, else adopt the best discovered source (or the one named by `import_from`).
|
|
494
|
+
When nothing is importable, returns the oauth_solution() plan (with the manual
|
|
495
|
+
mint steps) rather than failing silently."""
|
|
496
|
+
tok = load_oauth_token()
|
|
497
|
+
if tok:
|
|
498
|
+
return {"ok": True, "attained": False, "already": True, "stored_to": OAUTH_ENV,
|
|
499
|
+
"message": f"durable token already stored ({tok[:12]}…)"}
|
|
500
|
+
sources = discover_oauth_sources()
|
|
501
|
+
if import_from:
|
|
502
|
+
sources = [s for s in sources if s["source"] == import_from] or sources
|
|
503
|
+
if not sources:
|
|
504
|
+
sol = oauth_solution()
|
|
505
|
+
sol.update({"ok": False, "attained": False,
|
|
506
|
+
"message": "nothing to auto-import; follow the mint steps"})
|
|
507
|
+
return sol
|
|
508
|
+
chosen = sources[0]
|
|
509
|
+
path = store_oauth_token(chosen["token"])
|
|
510
|
+
if has_claude_auth():
|
|
511
|
+
run([which_claude_auth(), "set", chosen["token"]])
|
|
512
|
+
return {"ok": True, "attained": True, "source": chosen["source"],
|
|
513
|
+
"kind": chosen["kind"], "stored_to": path,
|
|
514
|
+
"message": f"imported durable token from {chosen['source']} "
|
|
515
|
+
f"({chosen['token'][:12]}…)"}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def oauth_probe():
|
|
519
|
+
if has_claude_auth():
|
|
520
|
+
return run([which_claude_auth(), "probe"], timeout=40)
|
|
521
|
+
tok = load_oauth_token()
|
|
522
|
+
if tok:
|
|
523
|
+
return {"ok": True, "out": f"token present ({tok[:12]}…) — "
|
|
524
|
+
"no claude-auth binary to run a live probe"}
|
|
525
|
+
return {"ok": False, "out": "no token stored", "solution": oauth_solution()}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def set_model(default_model=None, quota_fallback_model=None, fallback_model=None):
|
|
529
|
+
live = load_json(LIVE_SETTINGS, {})
|
|
530
|
+
cfg = load_json(CONFIG_PATH, {})
|
|
531
|
+
if default_model:
|
|
532
|
+
live["model"] = default_model
|
|
533
|
+
cfg["default_model"] = default_model
|
|
534
|
+
if fallback_model is not None:
|
|
535
|
+
fm = [x for x in fallback_model if x]
|
|
536
|
+
if fm:
|
|
537
|
+
live["fallbackModel"] = fm
|
|
538
|
+
else:
|
|
539
|
+
live.pop("fallbackModel", None)
|
|
540
|
+
if quota_fallback_model:
|
|
541
|
+
cfg["quota_fallback_model"] = quota_fallback_model
|
|
542
|
+
save_json(LIVE_SETTINGS, live)
|
|
543
|
+
save_json(CONFIG_PATH, cfg)
|
|
544
|
+
return {"ok": True}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def mcp_tool_catalog(base=None, server="toolserver"):
|
|
548
|
+
"""Group the toolserver's live tools into MCP permission strings, so the console can
|
|
549
|
+
render a checklist for permissions.allow instead of hand-typing `mcp__<server>__<tool>`.
|
|
550
|
+
Best-effort: returns an empty catalog (+ error) if the toolserver is unreachable."""
|
|
551
|
+
import json as _json
|
|
552
|
+
import urllib.request
|
|
553
|
+
base = (base or os.environ.get("TOOLSERVER_URL", "http://127.0.0.1:7004")).rstrip("/")
|
|
554
|
+
out = {"server": server, "perm_all": "mcp__%s" % server, "base": base, "categories": []}
|
|
555
|
+
try:
|
|
556
|
+
with urllib.request.urlopen(base + "/endpoints", timeout=8) as r:
|
|
557
|
+
eps = _json.loads(r.read().decode())
|
|
558
|
+
except Exception as exc: # noqa: BLE001 - surface unreachable toolserver to the UI
|
|
559
|
+
out["error"] = str(exc)
|
|
560
|
+
return out
|
|
561
|
+
seen, cats = set(), {}
|
|
562
|
+
for ep in (eps if isinstance(eps, list) else []):
|
|
563
|
+
url = (ep or {}).get("url", "")
|
|
564
|
+
if not url or url.endswith("/"):
|
|
565
|
+
continue
|
|
566
|
+
seg = url.strip("/").split("/")
|
|
567
|
+
if seg[0] in ("endpoints", "prefixes") or any("<" in s for s in seg):
|
|
568
|
+
continue
|
|
569
|
+
name = "_".join(seg) # tool id = mcp bridge name (cat_route)
|
|
570
|
+
if name in seen:
|
|
571
|
+
continue
|
|
572
|
+
seen.add(name)
|
|
573
|
+
cats.setdefault(seg[0], []).append({"tool": name, "perm": "mcp__%s__%s" % (server, name)})
|
|
574
|
+
out["categories"] = [{"name": c, "tools": sorted(cats[c], key=lambda t: t["tool"])}
|
|
575
|
+
for c in sorted(cats)]
|
|
576
|
+
return out
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def build_state():
|
|
580
|
+
ver = run(["claude", "--version"], timeout=15)
|
|
581
|
+
return {
|
|
582
|
+
"config": paths.load_config(),
|
|
583
|
+
"live_settings": load_json(LIVE_SETTINGS, {}),
|
|
584
|
+
"template_settings": load_json(TPL_SETTINGS, {}),
|
|
585
|
+
"template_dotclaude": load_json(TPL_DOTCLAUDE, {}),
|
|
586
|
+
"oauth": oauth_status(),
|
|
587
|
+
"version": ver["out"] or ver["err"],
|
|
588
|
+
"bool_keys": BOOL_KEYS,
|
|
589
|
+
"effort_values": EFFORT_VALUES,
|
|
590
|
+
"perm_modes": PERM_MODES,
|
|
591
|
+
"mcp_tools": mcp_tool_catalog(),
|
|
592
|
+
"live_settings_path": LIVE_SETTINGS,
|
|
593
|
+
"root": ROOT,
|
|
594
|
+
}
|