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 ADDED
@@ -0,0 +1,2 @@
1
+ """claudlet — a pixel desktop buddy that reacts to Claude Code."""
2
+ __version__ = "0.1.0"
claudlet/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from claudlet.pet import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()