claudlet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claudlet/__init__.py +2 -0
- claudlet/__main__.py +4 -0
- claudlet/attach.py +86 -0
- claudlet/configcli.py +187 -0
- claudlet/creature.py +626 -0
- claudlet/focus.py +83 -0
- claudlet/hook.py +196 -0
- claudlet/hostinfo.py +207 -0
- claudlet/install.py +157 -0
- claudlet/install_hooks.py +113 -0
- claudlet/macos_diag.py +119 -0
- claudlet/motion.py +133 -0
- claudlet/pet.py +1439 -0
- claudlet/petconfig.py +95 -0
- claudlet/physics.py +46 -0
- claudlet/skill/SKILL.md +143 -0
- claudlet/state_engine.py +228 -0
- claudlet/uninstall.py +91 -0
- claudlet/windows.py +134 -0
- claudlet/windows_macos.py +311 -0
- claudlet/windows_win32.py +359 -0
- claudlet-0.1.0.dist-info/METADATA +101 -0
- claudlet-0.1.0.dist-info/RECORD +28 -0
- claudlet-0.1.0.dist-info/WHEEL +5 -0
- claudlet-0.1.0.dist-info/entry_points.txt +10 -0
- claudlet-0.1.0.dist-info/licenses/LICENSE +21 -0
- claudlet-0.1.0.dist-info/licenses/NOTICE +7 -0
- claudlet-0.1.0.dist-info/top_level.txt +1 -0
claudlet/__init__.py
ADDED
claudlet/__main__.py
ADDED
claudlet/attach.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""claudlet-attach — bring up a pet for a Claude Code session (or standalone).
|
|
2
|
+
|
|
3
|
+
A console entry point so the /claudlet skill can just run `claudlet-attach`
|
|
4
|
+
instead of shelling into `python3 -c "import hostinfo; ..."` — which breaks under
|
|
5
|
+
a pipx install, where the package lives in an isolated venv the system python
|
|
6
|
+
can't import. Detects the session id and host, skips if a pet is already
|
|
7
|
+
attached (via the same liveness handshake the hook uses), and launches a
|
|
8
|
+
detached pet bound to the session.
|
|
9
|
+
|
|
10
|
+
claudlet-attach attach to this session (env/newest transcript)
|
|
11
|
+
claudlet-attach --session <id> attach to a specific session
|
|
12
|
+
claudlet-attach --standalone an unattached, decorative roaming pet
|
|
13
|
+
"""
|
|
14
|
+
import glob
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from claudlet import hostinfo
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _newest_session_id():
|
|
23
|
+
"""Fallback when $CLAUDE_CODE_SESSION_ID is unset: the session id of the
|
|
24
|
+
most recently modified transcript under ~/.claude/projects/."""
|
|
25
|
+
files = glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl"))
|
|
26
|
+
if not files:
|
|
27
|
+
return None
|
|
28
|
+
newest = max(files, key=os.path.getmtime)
|
|
29
|
+
return os.path.splitext(os.path.basename(newest))[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _launch(extra_args):
|
|
33
|
+
"""Launch a detached `python -m claudlet` that outlives this call."""
|
|
34
|
+
kw = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
|
|
35
|
+
"stderr": subprocess.DEVNULL}
|
|
36
|
+
if os.name == "posix":
|
|
37
|
+
kw["start_new_session"] = True # own process group; survives us
|
|
38
|
+
# ensure the child interpreter can import claudlet from a source checkout
|
|
39
|
+
# (pipx/pip installs already have it importable; the extra PYTHONPATH is harmless)
|
|
40
|
+
env = dict(os.environ)
|
|
41
|
+
src_dir = os.path.dirname(os.path.dirname(os.path.abspath(hostinfo.__file__)))
|
|
42
|
+
env["PYTHONPATH"] = src_dir + os.pathsep + env.get("PYTHONPATH", "")
|
|
43
|
+
subprocess.Popen([sys.executable, "-m", "claudlet"] + extra_args, env=env, **kw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _arg_value(argv, flag):
|
|
47
|
+
if flag in argv:
|
|
48
|
+
i = argv.index(flag)
|
|
49
|
+
if i + 1 < len(argv):
|
|
50
|
+
return argv[i + 1]
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv=None):
|
|
55
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
56
|
+
|
|
57
|
+
if "--standalone" in argv:
|
|
58
|
+
_launch([])
|
|
59
|
+
print("standalone pet running")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
session_id = (_arg_value(argv, "--session")
|
|
63
|
+
or os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
64
|
+
or _newest_session_id()
|
|
65
|
+
or "default")
|
|
66
|
+
host = hostinfo.detect_host()
|
|
67
|
+
|
|
68
|
+
if hostinfo.pet_alive(session_id):
|
|
69
|
+
print("already attached to session %s (host=%s)" % (session_id, host))
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
_launch(["--session", session_id, "--host", host])
|
|
73
|
+
print("attached to session %s (host=%s)" % (session_id, host))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cli():
|
|
78
|
+
"""console-script entry point; never hard-fails the caller."""
|
|
79
|
+
try:
|
|
80
|
+
sys.exit(main())
|
|
81
|
+
except Exception:
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
_cli()
|
claudlet/configcli.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""claudlet-config — locate, inspect, scaffold, and open the user config.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
claudlet-config # show path, status, effective values, ignored entries
|
|
6
|
+
claudlet-config --path # print the absolute config path only
|
|
7
|
+
claudlet-config init # create a starter template if none exists
|
|
8
|
+
claudlet-config open # open the config in the OS default editor
|
|
9
|
+
|
|
10
|
+
Thin presentation/scaffold layer over petconfig — all path/schema/validation
|
|
11
|
+
logic lives there. The /claudlet skill documents the schema so Claude can edit
|
|
12
|
+
the JSON directly and re-run this command to validate.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from claudlet import petconfig
|
|
19
|
+
from claudlet.state_engine import MAPPABLE_STATES, DEFAULT_EVENT_STATES
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def diagnose(raw):
|
|
23
|
+
"""Split a parsed config dict into what load_config accepts vs silently
|
|
24
|
+
drops. Pure. Returns {"accepted": <petconfig._clean result>, "ignored":
|
|
25
|
+
[human-readable strings]} so a typo'd state or unknown slot is visible
|
|
26
|
+
instead of degrading to defaults with no feedback."""
|
|
27
|
+
accepted = petconfig._clean(raw)
|
|
28
|
+
ignored = []
|
|
29
|
+
|
|
30
|
+
for key, val in (raw.get("tools") or {}).items():
|
|
31
|
+
if not (isinstance(key, str) and val in MAPPABLE_STATES):
|
|
32
|
+
ignored.append("tools.%s=%r (not a valid state)" % (key, val))
|
|
33
|
+
for key, val in (raw.get("events") or {}).items():
|
|
34
|
+
if key not in DEFAULT_EVENT_STATES:
|
|
35
|
+
ignored.append("events.%s=%r (unknown event slot)" % (key, val))
|
|
36
|
+
elif val not in MAPPABLE_STATES:
|
|
37
|
+
ignored.append("events.%s=%r (not a valid state)" % (key, val))
|
|
38
|
+
for key, val in (raw.get("raw_events") or {}).items():
|
|
39
|
+
if not (isinstance(key, str) and val in MAPPABLE_STATES):
|
|
40
|
+
ignored.append("raw_events.%s=%r (not a valid state)" % (key, val))
|
|
41
|
+
if "lang" in raw and raw.get("lang") not in ("ko", "en", "auto"):
|
|
42
|
+
ignored.append("lang=%r (use ko | en | auto)" % (raw.get("lang"),))
|
|
43
|
+
|
|
44
|
+
return {"accepted": accepted, "ignored": ignored}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_DEFAULTS = {"tool_states": {}, "event_states": {}, "raw_events": {},
|
|
48
|
+
"lang": "auto"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_report(path=None):
|
|
52
|
+
"""Inspect the config file at `path` (default: the resolved config_path).
|
|
53
|
+
Returns {"path", "status": found|missing|invalid, "error"?, "accepted",
|
|
54
|
+
"ignored"}. Never raises."""
|
|
55
|
+
path = os.path.abspath(path or petconfig.config_path())
|
|
56
|
+
if not os.path.exists(path):
|
|
57
|
+
return {"path": path, "status": "missing",
|
|
58
|
+
"accepted": dict(_DEFAULTS), "ignored": []}
|
|
59
|
+
try:
|
|
60
|
+
with open(path, encoding="utf-8") as f:
|
|
61
|
+
raw = json.load(f)
|
|
62
|
+
except (OSError, ValueError) as e:
|
|
63
|
+
return {"path": path, "status": "invalid", "error": str(e),
|
|
64
|
+
"accepted": dict(_DEFAULTS), "ignored": []}
|
|
65
|
+
if not isinstance(raw, dict):
|
|
66
|
+
return {"path": path, "status": "invalid",
|
|
67
|
+
"error": "top-level JSON must be an object",
|
|
68
|
+
"accepted": dict(_DEFAULTS), "ignored": []}
|
|
69
|
+
d = diagnose(raw)
|
|
70
|
+
return {"path": path, "status": "found",
|
|
71
|
+
"accepted": d["accepted"], "ignored": d["ignored"]}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# A valid, minimal starting point (per-key guidance lives in `show`, the skill
|
|
75
|
+
# doc, and docs/configuration.md — JSON has no comments).
|
|
76
|
+
TEMPLATE = {
|
|
77
|
+
"lang": "auto",
|
|
78
|
+
"tools": {"Bash": "work_computer"},
|
|
79
|
+
"events": {},
|
|
80
|
+
"raw_events": {},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def init_config(path=None):
|
|
85
|
+
"""Create a starter template at `path` if it doesn't exist. Returns True if
|
|
86
|
+
a file was created, False if one was already there (never clobbers)."""
|
|
87
|
+
path = os.path.abspath(path or petconfig.config_path())
|
|
88
|
+
if os.path.exists(path):
|
|
89
|
+
return False
|
|
90
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
91
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
92
|
+
json.dump(TEMPLATE, f, indent=2, ensure_ascii=False)
|
|
93
|
+
f.write("\n")
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _open_command(path, platform=None, name=None):
|
|
98
|
+
"""How to open `path`: an argv list for a subprocess, or the string
|
|
99
|
+
"startfile" meaning use os.startfile (Windows). Pure."""
|
|
100
|
+
platform = sys.platform if platform is None else platform
|
|
101
|
+
name = os.name if name is None else name
|
|
102
|
+
if name == "nt":
|
|
103
|
+
return "startfile"
|
|
104
|
+
if platform == "darwin":
|
|
105
|
+
return ["open", path]
|
|
106
|
+
return ["xdg-open", path]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _launch(path):
|
|
110
|
+
cmd = _open_command(path)
|
|
111
|
+
if cmd == "startfile":
|
|
112
|
+
os.startfile(path) # noqa: Windows only
|
|
113
|
+
else:
|
|
114
|
+
import subprocess
|
|
115
|
+
subprocess.Popen(cmd)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def open_config(path=None):
|
|
119
|
+
"""Scaffold the config if missing, then open it in the OS default editor.
|
|
120
|
+
Best-effort (never raises); returns the absolute path so the caller can
|
|
121
|
+
print it as a fallback."""
|
|
122
|
+
path = os.path.abspath(path or petconfig.config_path())
|
|
123
|
+
init_config(path)
|
|
124
|
+
try:
|
|
125
|
+
_launch(path)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render(r):
|
|
132
|
+
"""Human-readable text for a build_report() result."""
|
|
133
|
+
acc = r["accepted"]
|
|
134
|
+
status = r["status"]
|
|
135
|
+
if status == "invalid":
|
|
136
|
+
status += " (%s)" % r.get("error", "")
|
|
137
|
+
elif status == "missing":
|
|
138
|
+
status += " (built-in defaults apply)"
|
|
139
|
+
lines = [
|
|
140
|
+
"config: " + r["path"],
|
|
141
|
+
"status: " + status,
|
|
142
|
+
"lang: " + acc["lang"],
|
|
143
|
+
"tools: " + json.dumps(acc["tool_states"], ensure_ascii=False),
|
|
144
|
+
"events: " + json.dumps(acc["event_states"], ensure_ascii=False),
|
|
145
|
+
"raw_events: " + json.dumps(acc["raw_events"], ensure_ascii=False),
|
|
146
|
+
]
|
|
147
|
+
if r["ignored"]:
|
|
148
|
+
lines.append("ignored (present in the file but dropped — fix these):")
|
|
149
|
+
lines += [" - " + s for s in r["ignored"]]
|
|
150
|
+
lines += [
|
|
151
|
+
"---",
|
|
152
|
+
"valid states: " + ", ".join(sorted(MAPPABLE_STATES)),
|
|
153
|
+
"event slots: " + ", ".join(DEFAULT_EVENT_STATES),
|
|
154
|
+
"edit the file (or ask Claude via /claudlet config), then restart the pet.",
|
|
155
|
+
]
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main(argv=None):
|
|
160
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
161
|
+
arg = argv[0] if argv else ""
|
|
162
|
+
|
|
163
|
+
if arg == "--path":
|
|
164
|
+
print(os.path.abspath(petconfig.config_path()))
|
|
165
|
+
return 0
|
|
166
|
+
if arg == "init":
|
|
167
|
+
path = os.path.abspath(petconfig.config_path())
|
|
168
|
+
created = init_config(path)
|
|
169
|
+
print(("created " if created else "already exists: ") + path)
|
|
170
|
+
return 0
|
|
171
|
+
if arg == "open":
|
|
172
|
+
print("opening " + open_config())
|
|
173
|
+
return 0
|
|
174
|
+
print(render(build_report()))
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cli():
|
|
179
|
+
"""console-script entry point (never raise from the CLI)."""
|
|
180
|
+
try:
|
|
181
|
+
sys.exit(main(sys.argv[1:]))
|
|
182
|
+
except Exception:
|
|
183
|
+
sys.exit(0)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
_cli()
|