abstract-claude 0.1.0__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.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract-claude
3
+ Version: 0.1.0
4
+ Summary: Fresh Claude Code session manager: settings-only template, durable OAuth, quota fallback wrapper, and a web console.
5
+ Author: jrputkey
6
+ License: MIT
7
+ Project-URL: Homepage, https://abstractendeavors.com
8
+ Keywords: claude,claude-code,oauth,cli
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # abstract-claude
13
+
14
+ Fresh **Claude Code** session manager: a settings-only template, durable OAuth,
15
+ automatic quota fallback, and a web console — packaged as one `pip`-installable CLI.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install abstract-claude # from your index
21
+ # or from the private index:
22
+ pip install -i https://<index>/api/pypi/simple/ abstract-claude
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ ```bash
28
+ abstract-claude init # materialize AC_ROOT (config + template)
29
+ abstract-claude serve # web console (default http://127.0.0.1:9111)
30
+ sudo abstract-claude install-service # install + enable the systemd unit
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ | Command | What it does |
36
+ |---|---|
37
+ | `serve [--host --port]` | Run the web console (Model+fallback, OAuth, Template+reset, Session settings). |
38
+ | `init` | Create `AC_ROOT` with `config.json` + `template/` from shipped defaults. |
39
+ | `save-template` | Capture current `~/.claude/settings.json` + minimal flags as the template. |
40
+ | `restore` | Create a fresh `~/.claude` (settings + flags) if it doesn't exist. |
41
+ | `reset [-y]` | Wipe ALL Claude state (transcripts/history/creds) → reseed template → clean session. |
42
+ | `oauth-status` / `oauth-set <token>` | Show / store the durable 1-year `claude-auth` token. |
43
+ | `launch -- <claude args>` | Launch Claude Code, auto-switching to the fallback model when the default is over quota. |
44
+ | `install-service [--user]` | Write + enable `9111_abstract_claude_web.service` (needs sudo). |
45
+
46
+ ## How it works
47
+
48
+ - **Template = config only.** `template/settings.json` + a minimal `~/.claude.json`
49
+ (onboarding/trust flags). No credentials, history, or transcripts — a reset gives a
50
+ genuinely clean session, not a restored snapshot.
51
+ - **OAuth is not templated.** Login rides on the durable 1-year `CLAUDE_CODE_OAUTH_TOKEN`
52
+ (managed by `claude-auth`), so a fresh session auto-authenticates — no `/login`.
53
+ - **Quota fallback is the wrapper.** Claude Code's `fallbackModel` only fires on
54
+ *overload*, never on quota; `autoContinueAtUsageLimit` doesn't exist before v2.1.234.
55
+ `abstract-claude launch` handles the real case: default Fable maxed → run on Opus 4.8.
56
+ Wire it up with:
57
+ ```bash
58
+ alias claude='abstract-claude launch --dangerously-skip-permissions'
59
+ ```
60
+
61
+ ## Config
62
+
63
+ `AC_ROOT` (default `/srv/abstract_claude`) holds `config.json`:
64
+
65
+ ```json
66
+ { "listen_host": "127.0.0.1", "listen_port": 9111,
67
+ "default_model": "claude-fable-5[1m]", "quota_fallback_model": "claude-opus-4-8",
68
+ "preflight_quota_check": true }
69
+ ```
70
+
71
+ Override the data root with `AC_ROOT=/path abstract-claude ...`.
@@ -0,0 +1,60 @@
1
+ # abstract-claude
2
+
3
+ Fresh **Claude Code** session manager: a settings-only template, durable OAuth,
4
+ automatic quota fallback, and a web console — packaged as one `pip`-installable CLI.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install abstract-claude # from your index
10
+ # or from the private index:
11
+ pip install -i https://<index>/api/pypi/simple/ abstract-claude
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```bash
17
+ abstract-claude init # materialize AC_ROOT (config + template)
18
+ abstract-claude serve # web console (default http://127.0.0.1:9111)
19
+ sudo abstract-claude install-service # install + enable the systemd unit
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ | Command | What it does |
25
+ |---|---|
26
+ | `serve [--host --port]` | Run the web console (Model+fallback, OAuth, Template+reset, Session settings). |
27
+ | `init` | Create `AC_ROOT` with `config.json` + `template/` from shipped defaults. |
28
+ | `save-template` | Capture current `~/.claude/settings.json` + minimal flags as the template. |
29
+ | `restore` | Create a fresh `~/.claude` (settings + flags) if it doesn't exist. |
30
+ | `reset [-y]` | Wipe ALL Claude state (transcripts/history/creds) → reseed template → clean session. |
31
+ | `oauth-status` / `oauth-set <token>` | Show / store the durable 1-year `claude-auth` token. |
32
+ | `launch -- <claude args>` | Launch Claude Code, auto-switching to the fallback model when the default is over quota. |
33
+ | `install-service [--user]` | Write + enable `9111_abstract_claude_web.service` (needs sudo). |
34
+
35
+ ## How it works
36
+
37
+ - **Template = config only.** `template/settings.json` + a minimal `~/.claude.json`
38
+ (onboarding/trust flags). No credentials, history, or transcripts — a reset gives a
39
+ genuinely clean session, not a restored snapshot.
40
+ - **OAuth is not templated.** Login rides on the durable 1-year `CLAUDE_CODE_OAUTH_TOKEN`
41
+ (managed by `claude-auth`), so a fresh session auto-authenticates — no `/login`.
42
+ - **Quota fallback is the wrapper.** Claude Code's `fallbackModel` only fires on
43
+ *overload*, never on quota; `autoContinueAtUsageLimit` doesn't exist before v2.1.234.
44
+ `abstract-claude launch` handles the real case: default Fable maxed → run on Opus 4.8.
45
+ Wire it up with:
46
+ ```bash
47
+ alias claude='abstract-claude launch --dangerously-skip-permissions'
48
+ ```
49
+
50
+ ## Config
51
+
52
+ `AC_ROOT` (default `/srv/abstract_claude`) holds `config.json`:
53
+
54
+ ```json
55
+ { "listen_host": "127.0.0.1", "listen_port": 9111,
56
+ "default_model": "claude-fable-5[1m]", "quota_fallback_model": "claude-opus-4-8",
57
+ "preflight_quota_check": true }
58
+ ```
59
+
60
+ Override the data root with `AC_ROOT=/path abstract-claude ...`.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "abstract-claude"
7
+ version = "0.1.0"
8
+ description = "Fresh Claude Code session manager: settings-only template, durable OAuth, quota fallback wrapper, and a web console."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "jrputkey" }]
13
+ keywords = ["claude", "claude-code", "oauth", "cli"]
14
+ dependencies = []
15
+
16
+ [project.scripts]
17
+ abstract-claude = "abstract_claude.cli:main"
18
+
19
+ [project.urls]
20
+ Homepage = "https://abstractendeavors.com"
21
+
22
+ [tool.pypit]
23
+ github_push = false
24
+
25
+ [tool.setuptools]
26
+ package-dir = { "" = "src" }
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.setuptools.package-data]
32
+ abstract_claude = ["templates/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """abstract_claude — fresh Claude Code session manager (template, OAuth, quota fallback, console)."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,135 @@
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, TEMPLATE_DIR, TPL_DOTCLAUDE, TPL_SETTINGS)
8
+ from .util import load_json, run, save_json, which_claude_auth
9
+
10
+ # Session-settings surfaced by the console (valid keys in current CC schema).
11
+ BOOL_KEYS = [
12
+ "enableArtifact", "workflowKeywordTriggerEnabled", "promptSuggestionEnabled",
13
+ "awaySummaryEnabled", "skipDangerousModePermissionPrompt", "autoCompactEnabled",
14
+ "switchModelsOnFlag", "autoScrollEnabled", "fileCheckpointingEnabled",
15
+ "terminalProgressBarEnabled", "remoteControlAtStartup", "inputNeededNotifEnabled",
16
+ "agentPushNotifEnabled",
17
+ ]
18
+ EFFORT_VALUES = ["low", "medium", "high", "xhigh"]
19
+ PERM_MODES = ["acceptEdits", "auto", "bypassPermissions", "default", "plan"]
20
+
21
+ # Minimal flags a fresh ~/.claude.json needs to skip onboarding/trust prompts.
22
+ FLAG_KEYS = ["hasCompletedOnboarding", "bypassPermissionsModeAccepted",
23
+ "hasSeenAutoModeEntryWarning", "hasSeenTasksHint"]
24
+
25
+
26
+ def _pkg_default(name):
27
+ """Read a shipped default template file from package data."""
28
+ try:
29
+ from importlib.resources import files
30
+ return (files("abstract_claude.templates") / name).read_text()
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ def init_root():
36
+ """Materialize AC_ROOT with config.json + template/ from shipped defaults if missing."""
37
+ os.makedirs(TEMPLATE_DIR, exist_ok=True)
38
+ created = []
39
+ if not os.path.exists(CONFIG_PATH):
40
+ save_json(CONFIG_PATH, dict(paths.DEFAULTS))
41
+ created.append(CONFIG_PATH)
42
+ for dst, name in ((TPL_SETTINGS, "settings.json"), (TPL_DOTCLAUDE, "dot-claude.json")):
43
+ if not os.path.exists(dst):
44
+ data = _pkg_default(name)
45
+ if data is not None:
46
+ with open(dst, "w") as f:
47
+ f.write(data)
48
+ created.append(dst)
49
+ return created
50
+
51
+
52
+ def save_template():
53
+ """Capture current settings.json + derive minimal flags into the template."""
54
+ os.makedirs(TEMPLATE_DIR, exist_ok=True)
55
+ live = load_json(LIVE_SETTINGS, {})
56
+ save_json(TPL_SETTINGS, live)
57
+ dot = load_json(LIVE_DOTCLAUDE, {})
58
+ flags = {k: bool(dot.get(k, True)) for k in FLAG_KEYS}
59
+ save_json(TPL_DOTCLAUDE, flags)
60
+ return {"ok": True, "out": f"saved settings + flags -> {TEMPLATE_DIR}"}
61
+
62
+
63
+ def restore():
64
+ """Create a fresh ~/.claude (settings + flags) if it does not exist."""
65
+ if os.path.isdir(LIVE_CLAUDE_DIR):
66
+ return {"ok": True, "out": "~/.claude exists (use reset to force a fresh session)"}
67
+ os.makedirs(LIVE_CLAUDE_DIR, mode=0o700, exist_ok=True)
68
+ tpl = load_json(TPL_SETTINGS, {})
69
+ save_json(LIVE_SETTINGS, tpl)
70
+ save_json(LIVE_DOTCLAUDE, load_json(TPL_DOTCLAUDE, {}))
71
+ return {"ok": True, "out": "fresh ~/.claude created; login via CLAUDE_CODE_OAUTH_TOKEN"}
72
+
73
+
74
+ def reset():
75
+ """Wipe ALL prior state, then reseed the template -> a clean session."""
76
+ shutil.rmtree(LIVE_CLAUDE_DIR, ignore_errors=True)
77
+ try:
78
+ os.remove(LIVE_DOTCLAUDE)
79
+ except FileNotFoundError:
80
+ pass
81
+ r = restore()
82
+ r["out"] = "wiped prior state; " + r["out"]
83
+ return r
84
+
85
+
86
+ def oauth_status():
87
+ r = run([which_claude_auth(), "status"], timeout=20)
88
+ return (r["out"] + (("\n" + r["err"]) if r["err"] else "")).strip()
89
+
90
+
91
+ def oauth_set(token):
92
+ token = (token or "").strip()
93
+ if not token.startswith("sk-ant-"):
94
+ return {"ok": False, "err": "token must start with sk-ant-"}
95
+ return run([which_claude_auth(), "set", token])
96
+
97
+
98
+ def oauth_probe():
99
+ return run([which_claude_auth(), "probe"], timeout=40)
100
+
101
+
102
+ def set_model(default_model=None, quota_fallback_model=None, fallback_model=None):
103
+ live = load_json(LIVE_SETTINGS, {})
104
+ cfg = load_json(CONFIG_PATH, {})
105
+ if default_model:
106
+ live["model"] = default_model
107
+ cfg["default_model"] = default_model
108
+ if fallback_model is not None:
109
+ fm = [x for x in fallback_model if x]
110
+ if fm:
111
+ live["fallbackModel"] = fm
112
+ else:
113
+ live.pop("fallbackModel", None)
114
+ if quota_fallback_model:
115
+ cfg["quota_fallback_model"] = quota_fallback_model
116
+ save_json(LIVE_SETTINGS, live)
117
+ save_json(CONFIG_PATH, cfg)
118
+ return {"ok": True}
119
+
120
+
121
+ def build_state():
122
+ ver = run(["claude", "--version"], timeout=15)
123
+ return {
124
+ "config": paths.load_config(),
125
+ "live_settings": load_json(LIVE_SETTINGS, {}),
126
+ "template_settings": load_json(TPL_SETTINGS, {}),
127
+ "template_dotclaude": load_json(TPL_DOTCLAUDE, {}),
128
+ "oauth": oauth_status(),
129
+ "version": ver["out"] or ver["err"],
130
+ "bool_keys": BOOL_KEYS,
131
+ "effort_values": EFFORT_VALUES,
132
+ "perm_modes": PERM_MODES,
133
+ "live_settings_path": LIVE_SETTINGS,
134
+ "root": ROOT,
135
+ }
@@ -0,0 +1,136 @@
1
+ """Command-line interface: `abstract-claude <subcommand>`."""
2
+ import argparse
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ from . import __version__, actions
8
+ from .paths import ROOT
9
+
10
+ UNIT = "9111_abstract_claude_web.service"
11
+
12
+
13
+ def _p(obj):
14
+ if isinstance(obj, dict):
15
+ print(obj.get("out") or obj.get("err") or json.dumps(obj))
16
+ else:
17
+ print(obj)
18
+
19
+
20
+ def cmd_serve(a):
21
+ from .server import serve
22
+ serve(host=a.host, port=a.port)
23
+
24
+
25
+ def cmd_init(a):
26
+ created = actions.init_root()
27
+ print("initialized " + ROOT)
28
+ for c in created:
29
+ print(" + " + c)
30
+ if not created:
31
+ print(" (already initialized)")
32
+
33
+
34
+ def cmd_save_template(a):
35
+ _p(actions.save_template())
36
+
37
+
38
+ def cmd_restore(a):
39
+ _p(actions.restore())
40
+
41
+
42
+ def cmd_reset(a):
43
+ if not a.yes:
44
+ ans = input("Wipe ALL Claude state and start fresh? type RESET: ").strip()
45
+ if ans != "RESET":
46
+ print("aborted")
47
+ return 1
48
+ _p(actions.reset())
49
+
50
+
51
+ def cmd_oauth_status(a):
52
+ print(actions.oauth_status())
53
+
54
+
55
+ def cmd_oauth_set(a):
56
+ _p(actions.oauth_set(a.token))
57
+
58
+
59
+ def cmd_launch(a):
60
+ from .launch import launch
61
+ return launch(a.args)
62
+
63
+
64
+ def cmd_install_service(a):
65
+ from .paths import load_config
66
+ if os.geteuid() != 0:
67
+ print("run with sudo: sudo abstract-claude install-service", file=sys.stderr)
68
+ return 1
69
+ exe = os.environ.get("AC_EXE") or sys.argv[0]
70
+ if not os.path.isabs(exe):
71
+ import shutil
72
+ exe = shutil.which("abstract-claude") or exe
73
+ port = load_config()["listen_port"]
74
+ unit = f"""[Unit]
75
+ Description=abstract_claude console
76
+ After=network.target
77
+
78
+ [Service]
79
+ Type=simple
80
+ User={a.user}
81
+ Group={a.user}
82
+ Environment=HOME=/home/{a.user}
83
+ Environment=AC_HOST=127.0.0.1
84
+ Environment=AC_PORT={port}
85
+ Environment=AC_ROOT={ROOT}
86
+ ExecStart={exe} serve
87
+ Restart=on-failure
88
+ RestartSec=3
89
+
90
+ [Install]
91
+ WantedBy=multi-user.target
92
+ """
93
+ dst = f"/etc/systemd/system/{UNIT}"
94
+ with open(dst, "w") as f:
95
+ f.write(unit)
96
+ os.system("systemctl daemon-reload")
97
+ os.system(f"systemctl enable --now {UNIT}")
98
+ print(f"installed {dst} -> http://127.0.0.1:{port}")
99
+ print(f"logs: journalctl -u {UNIT} -f")
100
+
101
+
102
+ def main(argv=None):
103
+ argv = list(sys.argv[1:] if argv is None else argv)
104
+ ap = argparse.ArgumentParser(prog="abstract-claude",
105
+ description="Fresh Claude Code session manager.")
106
+ ap.add_argument("-V", "--version", action="version", version=f"abstract-claude {__version__}")
107
+ sub = ap.add_subparsers(dest="cmd", required=True)
108
+
109
+ s = sub.add_parser("serve", help="run the web console")
110
+ s.add_argument("--host"); s.add_argument("--port", type=int)
111
+ s.set_defaults(fn=cmd_serve)
112
+
113
+ sub.add_parser("init", help="materialize AC_ROOT (config + template)").set_defaults(fn=cmd_init)
114
+ sub.add_parser("save-template", help="save current settings as template").set_defaults(fn=cmd_save_template)
115
+ sub.add_parser("restore", help="create fresh ~/.claude if missing").set_defaults(fn=cmd_restore)
116
+
117
+ r = sub.add_parser("reset", help="wipe all state -> fresh session")
118
+ r.add_argument("-y", "--yes", action="store_true"); r.set_defaults(fn=cmd_reset)
119
+
120
+ sub.add_parser("oauth-status", help="show claude-auth status").set_defaults(fn=cmd_oauth_status)
121
+ o = sub.add_parser("oauth-set", help="store a 1-year token")
122
+ o.add_argument("token"); o.set_defaults(fn=cmd_oauth_set)
123
+
124
+ l = sub.add_parser("launch", help="launch Claude with quota fallback")
125
+ l.add_argument("args", nargs=argparse.REMAINDER); l.set_defaults(fn=cmd_launch)
126
+
127
+ i = sub.add_parser("install-service", help="install+enable systemd unit (sudo)")
128
+ i.add_argument("--user", default=os.environ.get("SUDO_USER") or "solcatcher")
129
+ i.set_defaults(fn=cmd_install_service)
130
+
131
+ a = ap.parse_args(argv)
132
+ return a.fn(a) or 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1,47 @@
1
+ """`abstract-claude launch` — start Claude Code with automatic quota fallback."""
2
+ import os
3
+ import re
4
+ import subprocess
5
+
6
+ from .paths import load_config
7
+ from .util import real_claude
8
+
9
+ LIMIT_RE = re.compile(r"(reached your .*limit|usage limit reached|Fable .*limit)", re.I)
10
+
11
+
12
+ def _is_headless(argv):
13
+ return any(a in ("-p", "--print") for a in argv)
14
+
15
+
16
+ def launch(argv):
17
+ cfg = load_config()
18
+ real = real_claude()
19
+ default_model = cfg.get("default_model") or "claude-fable-5"
20
+ fallback_model = cfg.get("quota_fallback_model") or "claude-opus-4-8"
21
+ preflight = cfg.get("preflight_quota_check", True)
22
+ env = dict(os.environ)
23
+ env["HOME"] = os.path.expanduser("~")
24
+
25
+ if _is_headless(argv):
26
+ p = subprocess.run([real, "--model", default_model, *argv],
27
+ capture_output=True, text=True, env=env)
28
+ out = (p.stdout or "") + (p.stderr or "")
29
+ if LIMIT_RE.search(out) and default_model != fallback_model:
30
+ print(f"[claude-launch] {default_model} over quota — retrying on {fallback_model}",
31
+ flush=True)
32
+ os.execvpe(real, [real, "--model", fallback_model, *argv], env)
33
+ print(p.stdout, end="")
34
+ return p.returncode
35
+
36
+ model = default_model
37
+ if preflight and default_model != fallback_model:
38
+ try:
39
+ probe = subprocess.run([real, "-p", "--model", default_model, "ok"],
40
+ capture_output=True, text=True, timeout=30, env=env)
41
+ if LIMIT_RE.search((probe.stdout or "") + (probe.stderr or "")):
42
+ print(f"[claude-launch] {default_model} is maxed — starting on {fallback_model}",
43
+ flush=True)
44
+ model = fallback_model
45
+ except Exception:
46
+ pass
47
+ os.execvpe(real, [real, "--model", model, *argv], env)
@@ -0,0 +1,29 @@
1
+ """Path + config resolution. Runtime data root is AC_ROOT (default /srv/abstract_claude)."""
2
+ import os
3
+
4
+ ROOT = os.environ.get("AC_ROOT", "/srv/abstract_claude")
5
+ HOME = os.path.expanduser("~")
6
+
7
+ CONFIG_PATH = os.path.join(ROOT, "config.json")
8
+ TEMPLATE_DIR = os.path.join(ROOT, "template")
9
+ TPL_SETTINGS = os.path.join(TEMPLATE_DIR, "settings.json")
10
+ TPL_DOTCLAUDE = os.path.join(TEMPLATE_DIR, "dot-claude.json")
11
+
12
+ LIVE_CLAUDE_DIR = os.path.join(HOME, ".claude")
13
+ LIVE_SETTINGS = os.path.join(LIVE_CLAUDE_DIR, "settings.json")
14
+ LIVE_DOTCLAUDE = os.path.join(HOME, ".claude.json")
15
+
16
+ DEFAULTS = {
17
+ "listen_host": "127.0.0.1",
18
+ "listen_port": 9111,
19
+ "default_model": "claude-fable-5[1m]",
20
+ "quota_fallback_model": "claude-opus-4-8",
21
+ "preflight_quota_check": True,
22
+ }
23
+
24
+
25
+ def load_config():
26
+ from .util import load_json
27
+ cfg = dict(DEFAULTS)
28
+ cfg.update(load_json(CONFIG_PATH, {}))
29
+ return cfg
@@ -0,0 +1,218 @@
1
+ """Web console served by `abstract-claude serve`."""
2
+ import json
3
+ import os
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+
6
+ from . import actions
7
+ from .paths import load_config
8
+ from .util import save_json
9
+ from .paths import LIVE_SETTINGS
10
+
11
+
12
+ class Handler(BaseHTTPRequestHandler):
13
+ def _send(self, code, body, ctype="application/json"):
14
+ data = body.encode() if isinstance(body, str) else body
15
+ self.send_response(code)
16
+ self.send_header("Content-Type", ctype)
17
+ self.send_header("Content-Length", str(len(data)))
18
+ self.end_headers()
19
+ self.wfile.write(data)
20
+
21
+ def _json(self, code, obj):
22
+ self._send(code, json.dumps(obj))
23
+
24
+ def _body(self):
25
+ n = int(self.headers.get("Content-Length", 0) or 0)
26
+ if not n:
27
+ return {}
28
+ try:
29
+ return json.loads(self.rfile.read(n).decode() or "{}")
30
+ except Exception:
31
+ return {}
32
+
33
+ def log_message(self, *a):
34
+ pass
35
+
36
+ def do_GET(self):
37
+ if self.path == "/" or self.path.startswith("/?"):
38
+ return self._send(200, PAGE, "text/html; charset=utf-8")
39
+ if self.path == "/api/state":
40
+ return self._json(200, actions.build_state())
41
+ return self._json(404, {"error": "not found"})
42
+
43
+ def do_POST(self):
44
+ b = self._body()
45
+ try:
46
+ if self.path == "/api/settings":
47
+ s = b.get("settings")
48
+ if not isinstance(s, dict):
49
+ return self._json(400, {"error": "settings must be an object"})
50
+ save_json(LIVE_SETTINGS, s)
51
+ return self._json(200, {"ok": True, "written": LIVE_SETTINGS})
52
+ if self.path == "/api/model":
53
+ actions.set_model(b.get("default_model"), b.get("quota_fallback_model"),
54
+ b.get("fallback_model"))
55
+ return self._json(200, {"ok": True})
56
+ if self.path == "/api/template/save":
57
+ return self._json(200, actions.save_template())
58
+ if self.path == "/api/template/reset":
59
+ if b.get("confirm") != "RESET":
60
+ return self._json(400, {"error": "type RESET to confirm"})
61
+ return self._json(200, actions.reset())
62
+ if self.path == "/api/oauth/set":
63
+ r = actions.oauth_set(b.get("token"))
64
+ return self._json(200 if r["ok"] else 400, r)
65
+ if self.path == "/api/oauth/probe":
66
+ return self._json(200, actions.oauth_probe())
67
+ except Exception as e:
68
+ return self._json(500, {"error": str(e)})
69
+ return self._json(404, {"error": "not found"})
70
+
71
+
72
+ def serve(host=None, port=None):
73
+ actions.init_root()
74
+ cfg = load_config()
75
+ host = os.environ.get("AC_HOST", host or cfg["listen_host"])
76
+ port = int(os.environ.get("AC_PORT", port or cfg["listen_port"]))
77
+ srv = ThreadingHTTPServer((host, port), Handler)
78
+ print(f"abstract_claude console on http://{host}:{port}", flush=True)
79
+ try:
80
+ srv.serve_forever()
81
+ except KeyboardInterrupt:
82
+ pass
83
+
84
+
85
+ PAGE = r"""<!doctype html>
86
+ <html lang="en"><head><meta charset="utf-8">
87
+ <meta name="viewport" content="width=device-width, initial-scale=1">
88
+ <title>abstract_claude console</title>
89
+ <style>
90
+ :root{--bg:#0d1117;--panel:#161b22;--line:#30363d;--fg:#e6edf3;--mut:#8b949e;
91
+ --acc:#4c8dff;--ok:#3fb950;--warn:#d29922;--bad:#f85149;}
92
+ *{box-sizing:border-box}
93
+ body{margin:0;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;background:var(--bg);color:var(--fg)}
94
+ header{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:12px}
95
+ h1{font-size:16px;margin:0;font-weight:600}
96
+ .ver{color:var(--mut);font-size:12px}
97
+ main{max-width:920px;margin:0 auto;padding:20px;display:grid;gap:16px}
98
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px}
99
+ .card h2{margin:0 0 12px;font-size:14px;font-weight:600;letter-spacing:.3px}
100
+ label{display:block;color:var(--mut);font-size:12px;margin:8px 0 3px}
101
+ input,select{width:100%;background:#0d1117;border:1px solid var(--line);color:var(--fg);
102
+ padding:7px 9px;border-radius:7px;font:13px monospace}
103
+ .row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
104
+ .toggles{display:grid;grid-template-columns:1fr 1fr;gap:6px 18px;margin-top:6px}
105
+ .tog{display:flex;align-items:center;gap:8px;color:var(--fg);font-size:13px}
106
+ .tog input{width:auto}
107
+ button{background:var(--acc);color:#fff;border:0;padding:8px 14px;border-radius:7px;
108
+ cursor:pointer;font-size:13px;font-weight:600;margin-top:12px}
109
+ button.ghost{background:#21262d;border:1px solid var(--line)}
110
+ button.danger{background:var(--bad)}
111
+ pre{background:#0d1117;border:1px solid var(--line);border-radius:7px;padding:10px;
112
+ overflow:auto;font-size:12px;color:var(--mut);white-space:pre-wrap;margin:8px 0 0}
113
+ .msg{font-size:12px;margin-top:8px;min-height:16px}
114
+ .msg.ok{color:var(--ok)} .msg.bad{color:var(--bad)}
115
+ .hint{color:var(--mut);font-size:12px;margin:4px 0}
116
+ .flex{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
117
+ </style></head>
118
+ <body>
119
+ <header><h1>abstract_claude console</h1><span class="ver" id="ver"></span></header>
120
+ <main>
121
+
122
+ <div class="card">
123
+ <h2>Model &amp; fallback</h2>
124
+ <div class="row">
125
+ <div><label>Default model (settings.json)</label><input id="model"></div>
126
+ <div><label>Quota fallback → (wrapper: Fable maxed → this)</label><input id="qfb"></div>
127
+ </div>
128
+ <label>fallbackModel chain (overload only, comma-separated)</label>
129
+ <input id="fbchain" placeholder="claude-opus-4-8, claude-sonnet-5">
130
+ <p class="hint">fallbackModel fires on overload/unavailable, never on quota. The quota
131
+ switch is handled by <code>abstract-claude launch</code> using the value above.</p>
132
+ <button onclick="saveModel()">Save model config</button>
133
+ <div class="msg" id="m-model"></div>
134
+ </div>
135
+
136
+ <div class="card">
137
+ <h2>OAuth (claude-auth)</h2>
138
+ <pre id="oauth">loading…</pre>
139
+ <div class="flex"><button class="ghost" onclick="probe()">Live probe</button></div>
140
+ <label>Set / replace 1-year token (sk-ant-oat…)</label>
141
+ <input id="token" placeholder="sk-ant-oat01…">
142
+ <button onclick="setToken()">Store token</button>
143
+ <p class="hint">Re-minting needs a browser: run <code>claude-auth mint</code> in a
144
+ terminal, or paste a token minted elsewhere.</p>
145
+ <div class="msg" id="m-oauth"></div>
146
+ </div>
147
+
148
+ <div class="card">
149
+ <h2>Template &amp; fresh session</h2>
150
+ <p class="hint">Template = settings.json + flags only. Reset wipes ALL prior state and
151
+ reseeds the template → a clean session. Login rides on the durable env token.</p>
152
+ <div class="row">
153
+ <div><label>template/settings.json</label><pre id="tpl-set"></pre></div>
154
+ <div><label>template/dot-claude.json → ~/.claude.json</label><pre id="tpl-dot"></pre></div>
155
+ </div>
156
+ <div class="flex"><button class="ghost" onclick="saveTpl()">Save current settings as template</button></div>
157
+ <label>Type RESET to wipe &amp; start fresh</label>
158
+ <div class="flex">
159
+ <input id="confirm" placeholder="RESET" style="max-width:160px">
160
+ <button class="danger" onclick="reset()">reset (fresh session)</button>
161
+ </div>
162
+ <div class="msg" id="m-tpl"></div>
163
+ </div>
164
+
165
+ <div class="card">
166
+ <h2>Session settings (settings.json)</h2>
167
+ <div class="row">
168
+ <div><label>Permission default mode</label><select id="perm"></select></div>
169
+ <div><label>Effort level</label><select id="effort"></select></div>
170
+ </div>
171
+ <div class="toggles" id="toggles"></div>
172
+ <button onclick="saveSettings()">Save session settings</button>
173
+ <p class="hint" id="settings-path"></p>
174
+ <div class="msg" id="m-set"></div>
175
+ </div>
176
+
177
+ </main>
178
+ <script>
179
+ let S={};const $=id=>document.getElementById(id);
180
+ function msg(id,t,ok){const e=$(id);e.textContent=t;e.className="msg "+(ok?"ok":"bad");}
181
+ async function api(p,b){const o=b?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)}:{};const r=await fetch(p,o);return{status:r.status,data:await r.json()};}
182
+ async function load(){
183
+ const {data}=await api("/api/state");S=data;
184
+ $("ver").textContent=(data.version||"")+" · "+(data.root||"");
185
+ $("model").value=data.live_settings.model||data.config.default_model||"";
186
+ $("qfb").value=data.config.quota_fallback_model||"";
187
+ $("fbchain").value=(data.live_settings.fallbackModel||[]).join(", ");
188
+ $("oauth").textContent=data.oauth||"(no status)";
189
+ $("tpl-set").textContent=JSON.stringify(data.template_settings,null,2);
190
+ $("tpl-dot").textContent=JSON.stringify(data.template_dotclaude,null,2);
191
+ $("settings-path").textContent="writes: "+data.live_settings_path;
192
+ const perm=$("perm");perm.innerHTML="";data.perm_modes.forEach(v=>perm.add(new Option(v,v)));
193
+ perm.value=((data.live_settings.permissions||{}).defaultMode)||"bypassPermissions";
194
+ const eff=$("effort");eff.innerHTML="";data.effort_values.forEach(v=>eff.add(new Option(v,v)));
195
+ eff.value=data.live_settings.effortLevel||"high";
196
+ const t=$("toggles");t.innerHTML="";
197
+ data.bool_keys.forEach(k=>{const c=!!data.live_settings[k];const w=document.createElement("label");
198
+ w.className="tog";w.innerHTML=`<input type="checkbox" data-k="${k}" ${c?"checked":""}> ${k}`;t.appendChild(w);});
199
+ }
200
+ async function saveModel(){
201
+ const fb=$("fbchain").value.split(",").map(s=>s.trim()).filter(Boolean);
202
+ const{status,data}=await api("/api/model",{default_model:$("model").value.trim(),quota_fallback_model:$("qfb").value.trim(),fallback_model:fb});
203
+ msg("m-model",status==200?"Saved.":"Error: "+(data.error||""),status==200);load();
204
+ }
205
+ async function saveSettings(){
206
+ const s=JSON.parse(JSON.stringify(S.live_settings||{}));
207
+ s.permissions=s.permissions||{};s.permissions.defaultMode=$("perm").value;s.effortLevel=$("effort").value;
208
+ document.querySelectorAll("#toggles input").forEach(c=>{s[c.dataset.k]=c.checked;});
209
+ const{status,data}=await api("/api/settings",{settings:s});
210
+ msg("m-set",status==200?("Saved → "+data.written):"Error: "+(data.error||""),status==200);load();
211
+ }
212
+ async function saveTpl(){const{status,data}=await api("/api/template/save",{});msg("m-tpl",status==200?(data.out||"Saved."):"Error: "+(data.err||data.error||""),status==200);load();}
213
+ async function reset(){const{status,data}=await api("/api/template/reset",{confirm:$("confirm").value.trim()});msg("m-tpl",status==200?(data.out||"Fresh."):"Error: "+(data.error||data.err||""),status==200);$("confirm").value="";load();}
214
+ async function setToken(){const{status,data}=await api("/api/oauth/set",{token:$("token").value});msg("m-oauth",status==200?(data.out||"Stored."):"Error: "+(data.error||data.err||""),status==200);$("token").value="";load();}
215
+ async function probe(){msg("m-oauth","probing…",true);const{data}=await api("/api/oauth/probe",{});msg("m-oauth",(data.out||data.err||"done"),data.ok);}
216
+ load();
217
+ </script>
218
+ </body></html>"""
@@ -0,0 +1,6 @@
1
+ {
2
+ "hasCompletedOnboarding": true,
3
+ "bypassPermissionsModeAccepted": true,
4
+ "hasSeenAutoModeEntryWarning": true,
5
+ "hasSeenTasksHint": true
6
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "permissions": {
3
+ "defaultMode": "bypassPermissions"
4
+ },
5
+ "model": "claude-fable-5[1m]",
6
+ "enableArtifact": false,
7
+ "workflowKeywordTriggerEnabled": false,
8
+ "effortLevel": "high",
9
+ "promptSuggestionEnabled": false,
10
+ "awaySummaryEnabled": false,
11
+ "skipDangerousModePermissionPrompt": true,
12
+ "autoCompactEnabled": false,
13
+ "switchModelsOnFlag": false,
14
+ "autoScrollEnabled": true,
15
+ "fileCheckpointingEnabled": false,
16
+ "terminalProgressBarEnabled": false,
17
+ "remoteControlAtStartup": false,
18
+ "inputNeededNotifEnabled": true,
19
+ "agentPushNotifEnabled": true
20
+ }
@@ -0,0 +1,51 @@
1
+ """Small shared helpers: JSON IO and subprocess running."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+
6
+ from .paths import HOME, ROOT
7
+
8
+
9
+ def load_json(path, default=None):
10
+ try:
11
+ with open(path) as f:
12
+ return json.load(f)
13
+ except Exception:
14
+ return {} if default is None else default
15
+
16
+
17
+ def save_json(path, obj):
18
+ os.makedirs(os.path.dirname(path), exist_ok=True)
19
+ tmp = path + ".tmp"
20
+ with open(tmp, "w") as f:
21
+ json.dump(obj, f, indent=2)
22
+ f.write("\n")
23
+ os.replace(tmp, path)
24
+
25
+
26
+ def which_claude_auth():
27
+ for p in (os.path.join(HOME, ".local/bin/claude-auth"),
28
+ "/usr/local/bin/claude-auth"):
29
+ if os.path.exists(p):
30
+ return p
31
+ return "claude-auth"
32
+
33
+
34
+ def real_claude():
35
+ import shutil
36
+ return (shutil.which("claude")
37
+ or os.path.join(HOME, ".local/bin/claude"))
38
+
39
+
40
+ def run(cmd, timeout=60, stdin=None):
41
+ """Run a command; return {ok, code, out, err}."""
42
+ env = dict(os.environ)
43
+ env["HOME"] = HOME
44
+ env["PATH"] = os.path.join(HOME, ".local/bin") + os.pathsep + env.get("PATH", "")
45
+ try:
46
+ p = subprocess.run(cmd, capture_output=True, text=True,
47
+ timeout=timeout, env=env, input=stdin)
48
+ return {"ok": p.returncode == 0, "code": p.returncode,
49
+ "out": p.stdout.strip(), "err": p.stderr.strip()}
50
+ except Exception as e:
51
+ return {"ok": False, "code": -1, "out": "", "err": str(e)}
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract-claude
3
+ Version: 0.1.0
4
+ Summary: Fresh Claude Code session manager: settings-only template, durable OAuth, quota fallback wrapper, and a web console.
5
+ Author: jrputkey
6
+ License: MIT
7
+ Project-URL: Homepage, https://abstractendeavors.com
8
+ Keywords: claude,claude-code,oauth,cli
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # abstract-claude
13
+
14
+ Fresh **Claude Code** session manager: a settings-only template, durable OAuth,
15
+ automatic quota fallback, and a web console — packaged as one `pip`-installable CLI.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install abstract-claude # from your index
21
+ # or from the private index:
22
+ pip install -i https://<index>/api/pypi/simple/ abstract-claude
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ ```bash
28
+ abstract-claude init # materialize AC_ROOT (config + template)
29
+ abstract-claude serve # web console (default http://127.0.0.1:9111)
30
+ sudo abstract-claude install-service # install + enable the systemd unit
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ | Command | What it does |
36
+ |---|---|
37
+ | `serve [--host --port]` | Run the web console (Model+fallback, OAuth, Template+reset, Session settings). |
38
+ | `init` | Create `AC_ROOT` with `config.json` + `template/` from shipped defaults. |
39
+ | `save-template` | Capture current `~/.claude/settings.json` + minimal flags as the template. |
40
+ | `restore` | Create a fresh `~/.claude` (settings + flags) if it doesn't exist. |
41
+ | `reset [-y]` | Wipe ALL Claude state (transcripts/history/creds) → reseed template → clean session. |
42
+ | `oauth-status` / `oauth-set <token>` | Show / store the durable 1-year `claude-auth` token. |
43
+ | `launch -- <claude args>` | Launch Claude Code, auto-switching to the fallback model when the default is over quota. |
44
+ | `install-service [--user]` | Write + enable `9111_abstract_claude_web.service` (needs sudo). |
45
+
46
+ ## How it works
47
+
48
+ - **Template = config only.** `template/settings.json` + a minimal `~/.claude.json`
49
+ (onboarding/trust flags). No credentials, history, or transcripts — a reset gives a
50
+ genuinely clean session, not a restored snapshot.
51
+ - **OAuth is not templated.** Login rides on the durable 1-year `CLAUDE_CODE_OAUTH_TOKEN`
52
+ (managed by `claude-auth`), so a fresh session auto-authenticates — no `/login`.
53
+ - **Quota fallback is the wrapper.** Claude Code's `fallbackModel` only fires on
54
+ *overload*, never on quota; `autoContinueAtUsageLimit` doesn't exist before v2.1.234.
55
+ `abstract-claude launch` handles the real case: default Fable maxed → run on Opus 4.8.
56
+ Wire it up with:
57
+ ```bash
58
+ alias claude='abstract-claude launch --dangerously-skip-permissions'
59
+ ```
60
+
61
+ ## Config
62
+
63
+ `AC_ROOT` (default `/srv/abstract_claude`) holds `config.json`:
64
+
65
+ ```json
66
+ { "listen_host": "127.0.0.1", "listen_port": 9111,
67
+ "default_model": "claude-fable-5[1m]", "quota_fallback_model": "claude-opus-4-8",
68
+ "preflight_quota_check": true }
69
+ ```
70
+
71
+ Override the data root with `AC_ROOT=/path abstract-claude ...`.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/abstract_claude/__init__.py
4
+ src/abstract_claude/__main__.py
5
+ src/abstract_claude/actions.py
6
+ src/abstract_claude/cli.py
7
+ src/abstract_claude/launch.py
8
+ src/abstract_claude/paths.py
9
+ src/abstract_claude/server.py
10
+ src/abstract_claude/util.py
11
+ src/abstract_claude.egg-info/PKG-INFO
12
+ src/abstract_claude.egg-info/SOURCES.txt
13
+ src/abstract_claude.egg-info/dependency_links.txt
14
+ src/abstract_claude.egg-info/entry_points.txt
15
+ src/abstract_claude.egg-info/top_level.txt
16
+ src/abstract_claude/templates/dot-claude.json
17
+ src/abstract_claude/templates/settings.json
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ abstract-claude = abstract_claude.cli:main
@@ -0,0 +1 @@
1
+ abstract_claude