hill-ops 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.
- hill_ops/__init__.py +2 -0
- hill_ops/__main__.py +3 -0
- hill_ops/claude.py +581 -0
- hill_ops/cli.py +83 -0
- hill_ops/clock.py +43 -0
- hill_ops/connect.py +89 -0
- hill_ops/events.py +99 -0
- hill_ops/host.py +212 -0
- hill_ops/hub.py +161 -0
- hill_ops/index.py +141 -0
- hill_ops/instances.py +172 -0
- hill_ops/keytree.py +73 -0
- hill_ops/layout.py +400 -0
- hill_ops/panel.py +1432 -0
- hill_ops/paths.py +37 -0
- hill_ops/profiles.py +166 -0
- hill_ops/reference.py +99 -0
- hill_ops/relay.py +243 -0
- hill_ops/runner.py +115 -0
- hill_ops/screens.py +720 -0
- hill_ops/tmux.conf +44 -0
- hill_ops/work.py +124 -0
- hill_ops-0.1.0.dist-info/METADATA +11 -0
- hill_ops-0.1.0.dist-info/RECORD +26 -0
- hill_ops-0.1.0.dist-info/WHEEL +4 -0
- hill_ops-0.1.0.dist-info/entry_points.txt +3 -0
hill_ops/__init__.py
ADDED
hill_ops/__main__.py
ADDED
hill_ops/claude.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Claude in the strip: what it's told, the tips it offers, and its answers
|
|
2
|
+
to your questions.
|
|
3
|
+
|
|
4
|
+
Claude gets what the event log holds (see events.py), what the app said in
|
|
5
|
+
hello, its README included, its overview and the settings, never the text on
|
|
6
|
+
screen; and
|
|
7
|
+
the questions you ask it, which the log leaves out. It runs with no tools of
|
|
8
|
+
its own and none of your Claude Code settings, hooks or CLAUDE.md. One
|
|
9
|
+
session holds both tips and answers, so you can ask about a tip; its first
|
|
10
|
+
message tells Claude everything, and each one after only what's new.
|
|
11
|
+
|
|
12
|
+
A tip is one short line, for the panel's tip line or the strip's line; an
|
|
13
|
+
answer is a few lines, which the panel shows above the tip. Either may offer
|
|
14
|
+
one setting to change, to one of that setting's own choices, one setting to
|
|
15
|
+
show without changing it, one of the app's commands to run, or one section
|
|
16
|
+
of the help to open; an answer may instead offer a work item to file, in a
|
|
17
|
+
project hill-ops can file in (see work.py), for what doesn't exist yet.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shlex
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from settings_panel import Group, Setting, StoreError
|
|
30
|
+
|
|
31
|
+
from .events import log_dir
|
|
32
|
+
from .hub import Peer
|
|
33
|
+
from .index import AppDocs
|
|
34
|
+
from .work import Item, Project, clean_title, open_items
|
|
35
|
+
|
|
36
|
+
COMMAND = "claude-agent-acp"
|
|
37
|
+
"""Claude's agent, found on the PATH; $HILL_CLAUDE_AGENT names another."""
|
|
38
|
+
SESSIONS = 5
|
|
39
|
+
"""How many sessions of the log Claude reads first, this one included."""
|
|
40
|
+
EVENTS = 400
|
|
41
|
+
"""At most this many events at a time, the latest."""
|
|
42
|
+
TIP_LENGTH = 80
|
|
43
|
+
"""A longer tip is cut."""
|
|
44
|
+
DOCS_LENGTH = 40_000
|
|
45
|
+
"""How much of an app's README Claude reads, at most."""
|
|
46
|
+
DOCS_SUFFIXES = (".md", ".markdown", ".txt")
|
|
47
|
+
"""What a README can be: hill-ops reads text files only."""
|
|
48
|
+
|
|
49
|
+
SYSTEM = """\
|
|
50
|
+
You are Claude in hill-ops's strip, the line under a terminal app such as palace \
|
|
51
|
+
(a notes screen) or micro (an editor): the support desk for hill-ops and the \
|
|
52
|
+
apps on it. You help someone get more out of the app, and steer it for \
|
|
53
|
+
them: you offer tips, you answer what they ask, you say where a setting, \
|
|
54
|
+
key, command or help section is and take them there, and you offer the \
|
|
55
|
+
app's commands and settings that do what they want. When what they want \
|
|
56
|
+
doesn't exist yet, you say so, look for it among the open work items, and \
|
|
57
|
+
else offer to file one. The strip \
|
|
58
|
+
grows into a panel with the app's settings; under them is your tip, on a \
|
|
59
|
+
line of its own, and under that one line for both commands and you: what \
|
|
60
|
+
they type after ":" is one of the app's commands, which the app runs; \
|
|
61
|
+
anything else comes to you. Your answer shows above the tip. An app may run panes beside it, in columns: a \
|
|
62
|
+
view in the middle, such as palace's preview, and a Claude pane on the right, \
|
|
63
|
+
with the strip under those two; hill-ops's Layout settings set their widths.
|
|
64
|
+
|
|
65
|
+
hill-ops tells you the app's keys, commands and help, and its README when it has \
|
|
66
|
+
one; the keys, commands and help of the other apps it knows, as they last \
|
|
67
|
+
ran; the open work items of the projects you can file in (hill-ops, and apps \
|
|
68
|
+
whose profile names a work folder), each with its number, status, title \
|
|
69
|
+
and the start of its Goal; the lines of its overview, the panel's first tab, when it sends one \
|
|
70
|
+
(palace's: the work items that want something, each with its id); every \
|
|
71
|
+
setting, with its value and choices; and an event log of their \
|
|
72
|
+
last few sessions: what they did through the strip (settings, help, the \
|
|
73
|
+
command line, hints they clicked, questions they asked you, borders they \
|
|
74
|
+
dragged), the questions the app asked them, errors, the programs the app ran \
|
|
75
|
+
beside it, and the window's size as it changed (resize). The log never holds their own text: no \
|
|
76
|
+
titles, paths or what they typed; the overview and the README may. Your \
|
|
77
|
+
first message has all of it, and each message after that only what's new.
|
|
78
|
+
|
|
79
|
+
A message that starts with "Tip" asks for a tip, and says where it shows. \
|
|
80
|
+
Offer at most one, and only one that fits what they actually did, such as:
|
|
81
|
+
- a key for something they reach by clicking a hint or a longer way round,
|
|
82
|
+
- a setting that suits what they keep changing,
|
|
83
|
+
- help on something they look up again and again,
|
|
84
|
+
- a way round an error that keeps coming back,
|
|
85
|
+
- after the window changed size (another monitor, or maximized), a layout \
|
|
86
|
+
that suits it: Layout's widths, the Claude pane off or back, the panel's \
|
|
87
|
+
height; mind how many columns each pane has left.
|
|
88
|
+
- a command for something they do the long way round.
|
|
89
|
+
Don't repeat a tip you gave or one in the log (its tip events), and don't \
|
|
90
|
+
tell them what they already do.
|
|
91
|
+
Answer with one JSON object and nothing else:
|
|
92
|
+
{"tip": "the tip: at most 70 characters, plain and friendly", \
|
|
93
|
+
"setting": {"group": "<group id>", "key": ["<key>", ...], "value": <one of its choices>}, \
|
|
94
|
+
"command": "<one of the app's commands, with its arguments, without the colon>", \
|
|
95
|
+
"help": "<the title of a section of the app's help>"}
|
|
96
|
+
"setting" is a change they can accept with one key, "command" a command \
|
|
97
|
+
they can run with one key, "help" a section of the help to open; give one \
|
|
98
|
+
of them at most, or none. With nothing worth \
|
|
99
|
+
saying, answer {"tip": null}.
|
|
100
|
+
|
|
101
|
+
A message that starts with "Question" ends with what they typed: a \
|
|
102
|
+
question, or a request, about the app or not. Answer it in a few short \
|
|
103
|
+
lines of plain text, without Markdown, since it shows in a few rows of the \
|
|
104
|
+
panel, and name keys as the app's help does. Answer from what hill-ops tells \
|
|
105
|
+
you, and say so when that doesn't cover it. Act as a support desk: find \
|
|
106
|
+
what they're after and take them there; when it takes several steps, list \
|
|
107
|
+
them briefly and offer the first. Asked where something is, say where (the \
|
|
108
|
+
group and setting, the key and its pane, the command) and offer to show it. \
|
|
109
|
+
When what they ask for doesn't exist in the app or in hill-ops, say so plainly; \
|
|
110
|
+
if an open work item already covers it, name it (\"palace 012, waiting on \
|
|
111
|
+
your decision\") rather than filing another; else offer to file one, in \
|
|
112
|
+
the project it belongs to: the app's own when it's about the app, hill-ops's \
|
|
113
|
+
when it's about the strip, the panel, the layout or you. You have no tools: you can't \
|
|
114
|
+
read or change their files, and real work on what's in the app, such as \
|
|
115
|
+
writing a note or working on a work item, is for the Claude the app runs \
|
|
116
|
+
beside it, if any; say so, and offer what gets them there. When one of the \
|
|
117
|
+
app's commands, one setting changed or one section of the help would do \
|
|
118
|
+
what they want, end with a line holding just a JSON object, \
|
|
119
|
+
{"command": "<the command, with its arguments, without the colon>"}, \
|
|
120
|
+
{"setting": {"group": "<group id>", "key": ["<key>", ...], "value": <one of \
|
|
121
|
+
its choices>}}, {"show": {"group": "<group id>", "key": ["<key>", ...]}} (a \
|
|
122
|
+
setting to show them, unchanged), {"help": "<the title of a section of the \
|
|
123
|
+
app's help>"} or {"work": {"project": "<a project you can file in>", \
|
|
124
|
+
"title": "<what's wanted, in a few words>", "goal": "<what it should do, a \
|
|
125
|
+
few plain sentences>", "done_when": "<how to tell it's done, a sentence>"}}: \
|
|
126
|
+
hill-ops offers it, and they take it with one key. A work item is written to \
|
|
127
|
+
the project's work folder, not committed; offer one only for something \
|
|
128
|
+
that doesn't exist and isn't an open item already. Offer only commands the app \
|
|
129
|
+
lists, with arguments as its help describes them, and never a setting's \
|
|
130
|
+
value now."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def command() -> list[str]:
|
|
134
|
+
"""The agent's command line: $HILL_CLAUDE_AGENT, else claude-agent-acp."""
|
|
135
|
+
return shlex.split(os.environ.get("HILL_CLAUDE_AGENT") or COMMAND)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def meta(model: str) -> dict[str, Any]:
|
|
139
|
+
"""session/new's _meta for claude-agent-acp: Claude's system prompt and
|
|
140
|
+
`model`, with no tools, none of your Claude Code settings, and nothing
|
|
141
|
+
saved among your Claude Code sessions."""
|
|
142
|
+
options = {"tools": [], "settingSources": [], "model": model, "persistSession": False}
|
|
143
|
+
return {"systemPrompt": SYSTEM, "claudeCode": {"options": options}}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(eq=False)
|
|
147
|
+
class Answer:
|
|
148
|
+
"""What Claude said, a tip or an answer to a question, and what it
|
|
149
|
+
offers: a setting to change, a command to run, or a section of the help
|
|
150
|
+
to open."""
|
|
151
|
+
|
|
152
|
+
text: str
|
|
153
|
+
setting: tuple[Group, Setting, Any] | None = None
|
|
154
|
+
"""A change to offer: the group, the setting and its new value."""
|
|
155
|
+
help: str | None = None
|
|
156
|
+
"""A section of the app's help to open."""
|
|
157
|
+
command: str | None = None
|
|
158
|
+
"""One of the app's commands to run, as typed after ":"."""
|
|
159
|
+
show: tuple[Group, Setting] | None = None
|
|
160
|
+
"""A setting to show in the panel, unchanged."""
|
|
161
|
+
work: Draft | None = None
|
|
162
|
+
"""A work item to file."""
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def offer(self) -> str | None:
|
|
166
|
+
"""What it offers, in words: "set List → Sort by to newest first"."""
|
|
167
|
+
if self.setting is not None:
|
|
168
|
+
group, setting, value = self.setting
|
|
169
|
+
return f"set {group.name} → {setting.label} to {setting.show(value)}"
|
|
170
|
+
if self.command is not None:
|
|
171
|
+
return f"run :{self.command}"
|
|
172
|
+
if self.help is not None:
|
|
173
|
+
return f"open the help on {self.help}"
|
|
174
|
+
if self.show is not None:
|
|
175
|
+
group, setting = self.show
|
|
176
|
+
return f"show {group.name} → {setting.label}"
|
|
177
|
+
if self.work is not None:
|
|
178
|
+
return f"file a {self.work.project.name} work item: {self.work.title}"
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class Draft:
|
|
184
|
+
"""A work item Claude offers to file: where, and what it says."""
|
|
185
|
+
|
|
186
|
+
project: Project
|
|
187
|
+
title: str
|
|
188
|
+
goal: str
|
|
189
|
+
done_when: str = ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def read_sessions(current: Path | None) -> list[tuple[str, list[dict]]]:
|
|
193
|
+
"""The events of the last SESSIONS sessions, oldest first, as (name,
|
|
194
|
+
events): the logs in log_dir(), then `current`, this session's; at most
|
|
195
|
+
EVENTS of them, the latest."""
|
|
196
|
+
earlier = [p for p in sorted(log_dir().glob("*.jsonl")) if p != current][-(SESSIONS - 1):]
|
|
197
|
+
sessions = []
|
|
198
|
+
for path in [*earlier, *([current] if current is not None else [])]:
|
|
199
|
+
events = _read_log(path)
|
|
200
|
+
if events is not None:
|
|
201
|
+
sessions.append((path.stem, events))
|
|
202
|
+
kept, room = [], EVENTS
|
|
203
|
+
for name, events in reversed(sessions):
|
|
204
|
+
if room <= 0:
|
|
205
|
+
break
|
|
206
|
+
events = events[-room:]
|
|
207
|
+
kept.append((name, events))
|
|
208
|
+
room -= len(events)
|
|
209
|
+
return kept[::-1]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _read_log(path: Path) -> list[dict] | None:
|
|
213
|
+
"""A session's events, or None if its log can't be read."""
|
|
214
|
+
try:
|
|
215
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
216
|
+
except OSError:
|
|
217
|
+
return None
|
|
218
|
+
events = []
|
|
219
|
+
for line in lines:
|
|
220
|
+
try:
|
|
221
|
+
event = json.loads(line)
|
|
222
|
+
except ValueError:
|
|
223
|
+
continue
|
|
224
|
+
if isinstance(event, dict):
|
|
225
|
+
events.append(event)
|
|
226
|
+
return events
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def read_docs(path: str | None) -> str | None:
|
|
230
|
+
"""An app's README, as its hello names it: at most DOCS_LENGTH
|
|
231
|
+
characters of a text file, or None."""
|
|
232
|
+
if not path or Path(path).suffix.lower() not in DOCS_SUFFIXES:
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
with open(Path(path).expanduser(), encoding="utf-8", errors="replace") as f:
|
|
236
|
+
text = f.read(DOCS_LENGTH + 1)
|
|
237
|
+
except OSError:
|
|
238
|
+
return None
|
|
239
|
+
return text if len(text) <= DOCS_LENGTH else text[:DOCS_LENGTH] + "\n(cut here)"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Context:
|
|
243
|
+
"""What Claude has been told in its session, so that each message tells
|
|
244
|
+
it only what's new: the app on the strip (its hello and README), the
|
|
245
|
+
settings, and the event log."""
|
|
246
|
+
|
|
247
|
+
def __init__(self) -> None:
|
|
248
|
+
self.hellos: set[str] = set()
|
|
249
|
+
"""The hellos told, each as JSON."""
|
|
250
|
+
self.app: str | None = None
|
|
251
|
+
"""The app on the strip, as Claude last heard."""
|
|
252
|
+
self.settings: dict[tuple, str] = {}
|
|
253
|
+
"""Each setting's value as told, as JSON, by (group, key)."""
|
|
254
|
+
self.events = 0
|
|
255
|
+
"""How many of this session's events Claude has had."""
|
|
256
|
+
self.overviews: dict[str, str] = {}
|
|
257
|
+
"""Each app's overview as told, as JSON, by the app."""
|
|
258
|
+
self.others: dict[str, str] = {}
|
|
259
|
+
"""Each other app's keys, commands and help as told, as JSON."""
|
|
260
|
+
self.work: dict[str, str] = {}
|
|
261
|
+
"""Each project's open work items as told, as JSON."""
|
|
262
|
+
|
|
263
|
+
def news(
|
|
264
|
+
self, peer: Peer | None, groups: list[Group], current: Path | None, fresh: bool,
|
|
265
|
+
others: list[AppDocs] | None = None, projects: list[Project] | None = None,
|
|
266
|
+
) -> list[str]:
|
|
267
|
+
"""What to tell Claude: everything if its session is `fresh`, else
|
|
268
|
+
what's changed since its last message. `peer` is the app on the
|
|
269
|
+
strip, `current` this session's event log, `others` the other apps
|
|
270
|
+
hill-ops knows and `projects` those Claude can file work items in."""
|
|
271
|
+
if fresh:
|
|
272
|
+
self.__init__()
|
|
273
|
+
lines = [
|
|
274
|
+
*self._app(peer), *self._others(peer, others or []), *self._overview(peer), *self._work(projects or []),
|
|
275
|
+
*self._settings(groups), *self._events(current, fresh),
|
|
276
|
+
]
|
|
277
|
+
return lines if fresh or not lines else ["Since your last message:", *lines]
|
|
278
|
+
|
|
279
|
+
def _app(self, peer: Peer | None) -> list[str]:
|
|
280
|
+
if peer is None:
|
|
281
|
+
return []
|
|
282
|
+
hello = json.dumps([peer.app, peer.keys, peer.commands, peer.help, peer.docs], default=str)
|
|
283
|
+
if hello in self.hellos:
|
|
284
|
+
lines = [] if peer.app == self.app else [f"\nThe app on the strip: {peer.app} again."]
|
|
285
|
+
else:
|
|
286
|
+
self.hellos.add(hello)
|
|
287
|
+
lines = _hello(peer)
|
|
288
|
+
self.app = peer.app
|
|
289
|
+
return lines
|
|
290
|
+
|
|
291
|
+
def _others(self, peer: Peer | None, others: list[AppDocs]) -> list[str]:
|
|
292
|
+
lines = []
|
|
293
|
+
for docs in others:
|
|
294
|
+
if peer is not None and docs.app == peer.app:
|
|
295
|
+
continue
|
|
296
|
+
told = json.dumps([docs.keys, docs.commands, docs.help], default=str)
|
|
297
|
+
if self.others.get(docs.app) == told:
|
|
298
|
+
continue
|
|
299
|
+
self.others[docs.app] = told
|
|
300
|
+
lines += _docs_lines(docs)
|
|
301
|
+
return lines
|
|
302
|
+
|
|
303
|
+
def _work(self, projects: list[Project]) -> list[str]:
|
|
304
|
+
lines = []
|
|
305
|
+
for project in projects:
|
|
306
|
+
items = open_items(project)
|
|
307
|
+
told = json.dumps([[i.number, i.status, i.title, i.goal] for i in items])
|
|
308
|
+
if self.work.get(project.name) == told:
|
|
309
|
+
continue
|
|
310
|
+
self.work[project.name] = told
|
|
311
|
+
lines += _work_lines(project, items)
|
|
312
|
+
return lines
|
|
313
|
+
|
|
314
|
+
def _overview(self, peer: Peer | None) -> list[str]:
|
|
315
|
+
if peer is None or peer.overview is None:
|
|
316
|
+
return []
|
|
317
|
+
told = json.dumps(peer.overview, sort_keys=True, default=str)
|
|
318
|
+
if self.overviews.get(peer.app) == told:
|
|
319
|
+
return []
|
|
320
|
+
self.overviews[peer.app] = told
|
|
321
|
+
return _overview_lines(peer.app, peer.overview)
|
|
322
|
+
|
|
323
|
+
def _settings(self, groups: list[Group]) -> list[str]:
|
|
324
|
+
lines = []
|
|
325
|
+
for group in groups:
|
|
326
|
+
rows = []
|
|
327
|
+
for setting in group.settings:
|
|
328
|
+
value, told = _value(group, setting), self.settings.get((group.id, setting.key))
|
|
329
|
+
if value == told:
|
|
330
|
+
continue
|
|
331
|
+
self.settings[(group.id, setting.key)] = value
|
|
332
|
+
if told is None:
|
|
333
|
+
choices = ", ".join(json.dumps(c, default=str) for c in setting.options())
|
|
334
|
+
rows.append(f'- key {json.dumps(list(setting.key))} "{setting.label}" is {value}; choices {choices}: {setting.help}')
|
|
335
|
+
else:
|
|
336
|
+
rows.append(f'- key {json.dumps(list(setting.key))} "{setting.label}" is now {value}')
|
|
337
|
+
if rows:
|
|
338
|
+
lines += [f'\nSettings group "{group.id}" ({group.name}):', *rows]
|
|
339
|
+
return lines
|
|
340
|
+
|
|
341
|
+
def _events(self, current: Path | None, fresh: bool) -> list[str]:
|
|
342
|
+
mine = (_read_log(current) or []) if current is not None else []
|
|
343
|
+
if fresh:
|
|
344
|
+
self.events = len(mine)
|
|
345
|
+
sessions = [(name, events) for name, events in read_sessions(current) if events]
|
|
346
|
+
if not sessions:
|
|
347
|
+
return ["\nThe event log: nothing yet."]
|
|
348
|
+
lines = ["\nThe event log (time, event, app, details), oldest session first:"]
|
|
349
|
+
for name, events in sessions:
|
|
350
|
+
lines.append(f"# Session {name}")
|
|
351
|
+
lines += [_event_line(e) for e in events]
|
|
352
|
+
return lines
|
|
353
|
+
new, self.events = mine[self.events:], len(mine)
|
|
354
|
+
return ["\nNew in the event log:", *[_event_line(e) for e in new[-EVENTS:]]] if new else []
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _hello(peer: Peer) -> list[str]:
|
|
358
|
+
"""What the app said in hello, and its README."""
|
|
359
|
+
lines = [f"\nThe app on the strip: {peer.app}"]
|
|
360
|
+
if peer.keys:
|
|
361
|
+
lines.append("Its keys for the whole app: " + "; ".join(f"{k[0]} {k[1]}".strip() for k in peer.keys))
|
|
362
|
+
if peer.commands:
|
|
363
|
+
lines.append("Its commands:")
|
|
364
|
+
lines += [f"- {c[0]} {c[1] if len(c) > 1 else ''}: {c[2] if len(c) > 2 else ''}" for c in peer.commands]
|
|
365
|
+
for title, entries in peer.help:
|
|
366
|
+
lines.append(f"Its help, {title}:")
|
|
367
|
+
lines += [f"- {e[0]}: {e[1]}" for e in entries if isinstance(e, list) and len(e) >= 2]
|
|
368
|
+
if docs := read_docs(peer.docs):
|
|
369
|
+
lines += ["Its README, up to (end of the README):", docs.rstrip(), "(end of the README)"]
|
|
370
|
+
return lines
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _docs_lines(docs: AppDocs) -> list[str]:
|
|
374
|
+
"""Another app's keys, commands and help, as it last said hello: what
|
|
375
|
+
Claude needs to say where something is, though it's not on the strip."""
|
|
376
|
+
lines = [f"\nAnother app hill-ops knows, not on the strip now: {docs.app}"]
|
|
377
|
+
if docs.keys:
|
|
378
|
+
lines.append("Its keys for the whole app: " + "; ".join(f"{k[0]} {k[1]}".strip() for k in docs.keys if len(k) > 1))
|
|
379
|
+
if docs.commands:
|
|
380
|
+
lines.append("Its commands: " + "; ".join(
|
|
381
|
+
f"{c[0]} {c[1] if len(c) > 1 else ''}".strip() + (f" ({c[2]})" if len(c) > 2 and c[2] else "") for c in docs.commands if c
|
|
382
|
+
))
|
|
383
|
+
for section in docs.help:
|
|
384
|
+
if len(section) > 1 and isinstance(section[1], list):
|
|
385
|
+
lines.append(f"Its help, {section[0]}: " + "; ".join(f"{e[0]} {e[1]}" for e in section[1] if isinstance(e, list) and len(e) >= 2))
|
|
386
|
+
return lines
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _work_lines(project: Project, items: list[Item]) -> list[str]:
|
|
390
|
+
"""A project's open work items, as Claude reads them."""
|
|
391
|
+
lines = [f"\nOpen work items of {project.name}, which you can file in, as they are now:"]
|
|
392
|
+
lines += [f"- {project.name} {i.number} ({i.status}): {i.title}. {i.goal}".rstrip() for i in items]
|
|
393
|
+
if not items:
|
|
394
|
+
lines.append("- none")
|
|
395
|
+
return lines
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _overview_lines(app: str, overview: dict) -> list[str]:
|
|
399
|
+
"""An app's overview as Claude reads it: a line each, with its id."""
|
|
400
|
+
title = str(overview.get("title") or "Overview")
|
|
401
|
+
lines = [f"\n{app}'s {title}, the panel's first tab, as it is now ({overview.get('where') or ''}):"]
|
|
402
|
+
for line in overview.get("lines") or []:
|
|
403
|
+
if not isinstance(line, dict) or not line.get("id") or line.get("separator"):
|
|
404
|
+
continue
|
|
405
|
+
text = line.get("text")
|
|
406
|
+
if isinstance(text, list):
|
|
407
|
+
text = "".join(str(span[0]) for span in text if isinstance(span, list) and span)
|
|
408
|
+
help = f" ({line['help']})" if line.get("help") else ""
|
|
409
|
+
lines.append(f"- id {json.dumps(str(line['id']))}: {' '.join(str(text or '').split())}{help}")
|
|
410
|
+
if len(lines) == 1:
|
|
411
|
+
lines.append(f"- nothing: {overview.get('empty') or 'no lines'}")
|
|
412
|
+
return lines
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _value(group: Group, setting: Setting) -> str:
|
|
416
|
+
try:
|
|
417
|
+
return json.dumps(group.value(setting), default=str)
|
|
418
|
+
except (StoreError, OSError):
|
|
419
|
+
return "unreadable"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _event_line(event: dict) -> str:
|
|
423
|
+
details = " ".join(
|
|
424
|
+
f"{k}={json.dumps(v, ensure_ascii=False, default=str)}"
|
|
425
|
+
for k, v in event.items() if k not in ("time", "event", "app") and v is not None
|
|
426
|
+
)
|
|
427
|
+
parts = (str(event.get("time") or "")[11:19], str(event.get("event") or ""), str(event.get("app") or ""), details)
|
|
428
|
+
return " ".join(p for p in parts if p)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def tip_prompt(why: str | None, news: list[str]) -> str:
|
|
432
|
+
"""A message asking Claude for a tip, with what's new (Context.news):
|
|
433
|
+
`why` says who asked and where, such as "asked in the help, on Map";
|
|
434
|
+
None for the tip offered once a session on the strip's line."""
|
|
435
|
+
first = f"Tip, {why}." if why else "Tip, unasked: the one offered once a session on the strip's line, a while after it starts."
|
|
436
|
+
return "\n".join([first, *(news or ["Nothing new since your last message."])])
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def question_prompt(question: str, news: list[str]) -> str:
|
|
440
|
+
"""A message with your question, after what's new (Context.news)."""
|
|
441
|
+
return "\n".join(["Question.", *news, "", f"Their question: {question}"])
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_tip(text: str, groups: list[Group], sections: list[str], commands: list | None = None) -> Answer | None:
|
|
445
|
+
"""The tip in Claude's answer, or None. A setting is kept only if it's
|
|
446
|
+
one of `groups`' with one of its own choices, other than its value now;
|
|
447
|
+
a command only if it's one of `commands` (the app's, as in hello); a
|
|
448
|
+
help section only if it's one of `sections`."""
|
|
449
|
+
start, end = text.find("{"), text.rfind("}")
|
|
450
|
+
try:
|
|
451
|
+
answer = json.loads(text[start:end + 1]) if 0 <= start < end else None
|
|
452
|
+
except ValueError:
|
|
453
|
+
return None
|
|
454
|
+
if not isinstance(answer, dict) or not isinstance(answer.get("tip"), str) or not answer["tip"].strip():
|
|
455
|
+
return None
|
|
456
|
+
tip = " ".join(answer["tip"].split())
|
|
457
|
+
if len(tip) > TIP_LENGTH:
|
|
458
|
+
tip = tip[:TIP_LENGTH - 1].rstrip() + "…"
|
|
459
|
+
return Answer(tip, *_offer(answer, groups, sections, commands, []))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def parse_answer(
|
|
463
|
+
text: str, groups: list[Group], sections: list[str], commands: list | None = None, projects: list[Project] | None = None,
|
|
464
|
+
) -> Answer:
|
|
465
|
+
"""Claude's answer to a question, and what it offers, from the JSON
|
|
466
|
+
object it may end with: a setting, a command, a section of the help or a
|
|
467
|
+
setting to show, kept as parse_tip keeps them, or a work item to file in
|
|
468
|
+
one of `projects`."""
|
|
469
|
+
body, offer = _split(text)
|
|
470
|
+
answer = Answer(body, *_offer(offer or {}, groups, sections, commands, projects or []))
|
|
471
|
+
if not answer.text and answer.offer:
|
|
472
|
+
answer.text = answer.offer[:1].upper() + answer.offer[1:] + "?"
|
|
473
|
+
return answer
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def shown(text: str) -> str:
|
|
477
|
+
"""An answer as it shows while it comes: what may be the start of the
|
|
478
|
+
JSON object it ends with, or of a code fence around it, stays out."""
|
|
479
|
+
lines = text.split("\n")
|
|
480
|
+
for i, line in enumerate(lines):
|
|
481
|
+
if line.lstrip().startswith(("{", "```")):
|
|
482
|
+
return "\n".join(lines[:i]).strip()
|
|
483
|
+
return text.strip()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _split(text: str) -> tuple[str, dict | None]:
|
|
487
|
+
"""An answer's text, and the JSON object it ends with, if any: on a line
|
|
488
|
+
of its own or over its last few, in a code fence or not."""
|
|
489
|
+
lines = text.rstrip().split("\n")
|
|
490
|
+
end = len(lines)
|
|
491
|
+
while end and lines[end - 1].strip().startswith("```"):
|
|
492
|
+
end -= 1
|
|
493
|
+
for start in range(end - 1, max(end - 13, -1), -1):
|
|
494
|
+
if not lines[start].lstrip().startswith("{"):
|
|
495
|
+
continue
|
|
496
|
+
try:
|
|
497
|
+
found = json.loads("\n".join(lines[start:end]))
|
|
498
|
+
except ValueError:
|
|
499
|
+
continue
|
|
500
|
+
if not isinstance(found, dict):
|
|
501
|
+
break
|
|
502
|
+
while start and lines[start - 1].strip().startswith("```"):
|
|
503
|
+
start -= 1
|
|
504
|
+
return "\n".join(lines[:start]).strip(), found
|
|
505
|
+
return text.strip(), None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _offer(
|
|
509
|
+
found: dict, groups: list[Group], sections: list[str], commands: list | None, projects: list[Project],
|
|
510
|
+
) -> tuple[Any, ...]:
|
|
511
|
+
"""The one offer in what Claude answered, as (setting, help, command,
|
|
512
|
+
show, work): a setting first, then a command, a section of the help, a
|
|
513
|
+
setting to show, and a work item."""
|
|
514
|
+
if (setting := _setting(found.get("setting"), groups)) is not None:
|
|
515
|
+
return setting, None, None, None, None
|
|
516
|
+
if (line := _command(found.get("command"), commands or [])) is not None:
|
|
517
|
+
return None, None, line, None, None
|
|
518
|
+
if (section := _section(found.get("help"), sections)) is not None:
|
|
519
|
+
return None, section, None, None, None
|
|
520
|
+
if (show := _shown_setting(found.get("show"), groups)) is not None:
|
|
521
|
+
return None, None, None, show, None
|
|
522
|
+
return None, None, None, None, _draft(found.get("work"), projects)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _shown_setting(proposed: Any, groups: list[Group]) -> tuple[Group, Setting] | None:
|
|
526
|
+
if not isinstance(proposed, dict):
|
|
527
|
+
return None
|
|
528
|
+
key = proposed.get("key")
|
|
529
|
+
key = [key] if isinstance(key, str) else key
|
|
530
|
+
for group in groups:
|
|
531
|
+
for setting in group.settings if group.id == proposed.get("group") else []:
|
|
532
|
+
if list(setting.key) == key:
|
|
533
|
+
return group, setting
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _draft(proposed: Any, projects: list[Project]) -> Draft | None:
|
|
538
|
+
"""A work item Claude offers, if it names a project hill-ops can file in,
|
|
539
|
+
with a title and a goal."""
|
|
540
|
+
if not isinstance(proposed, dict):
|
|
541
|
+
return None
|
|
542
|
+
project = next((p for p in projects if p.name == proposed.get("project")), None)
|
|
543
|
+
title, goal, done = (proposed.get(k) for k in ("title", "goal", "done_when"))
|
|
544
|
+
if project is None or not isinstance(title, str) or not title.strip() or not isinstance(goal, str) or not goal.strip():
|
|
545
|
+
return None
|
|
546
|
+
return Draft(project, clean_title(title), goal.strip(), done.strip() if isinstance(done, str) else "")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _command(proposed: Any, commands: list) -> str | None:
|
|
550
|
+
"""A command line Claude offers, if its first word names one of
|
|
551
|
+
`commands`; without a leading ":", and on one line."""
|
|
552
|
+
if not isinstance(proposed, str):
|
|
553
|
+
return None
|
|
554
|
+
line = " ".join(proposed.strip().removeprefix(":").split())
|
|
555
|
+
names = {c[0] for c in commands if isinstance(c, (list, tuple)) and c and isinstance(c[0], str)}
|
|
556
|
+
return line if line.partition(" ")[0] in names else None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _setting(proposed: Any, groups: list[Group]) -> tuple[Group, Setting, Any] | None:
|
|
560
|
+
if not isinstance(proposed, dict):
|
|
561
|
+
return None
|
|
562
|
+
key, value = proposed.get("key"), proposed.get("value")
|
|
563
|
+
key = [key] if isinstance(key, str) else key
|
|
564
|
+
for group in groups:
|
|
565
|
+
for setting in group.settings if group.id == proposed.get("group") else []:
|
|
566
|
+
if list(setting.key) != key:
|
|
567
|
+
continue
|
|
568
|
+
try:
|
|
569
|
+
current = group.value(setting)
|
|
570
|
+
except (StoreError, OSError):
|
|
571
|
+
return None
|
|
572
|
+
# True == 1 in Python: a value must be one of the choices, type and all.
|
|
573
|
+
offered = any(type(c) is type(value) and c == value for c in setting.options())
|
|
574
|
+
return (group, setting, value) if offered and value != current else None
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _section(title: Any, sections: list[str]) -> str | None:
|
|
579
|
+
if not isinstance(title, str):
|
|
580
|
+
return None
|
|
581
|
+
return next((s for s in sections if s.casefold() == title.strip().casefold()), None)
|
hill_ops/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The hill-ops command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
USAGE = """\
|
|
10
|
+
usage: hill-ops [--] COMMAND [ARGS...] run COMMAND above hill-ops's strip, in
|
|
11
|
+
the instance of COMMAND used last
|
|
12
|
+
that isn't running
|
|
13
|
+
hill-ops --resume [--] COMMAND ... pick the instance from a list
|
|
14
|
+
hill-ops --new [--] COMMAND ... in a new instance
|
|
15
|
+
hill-ops --as ID [--] COMMAND ... in instance ID, made if there's none
|
|
16
|
+
hill-ops settings [GROUP] the settings on their own, open on
|
|
17
|
+
GROUP
|
|
18
|
+
hill-ops docs [APP] APP's reference in Markdown, from
|
|
19
|
+
what it declares (hill-ops's own without
|
|
20
|
+
APP)
|
|
21
|
+
hill-ops connect the channel over stdin and stdout,
|
|
22
|
+
for apps that can't open a socket
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
args = sys.argv[1:]
|
|
28
|
+
if not args or args[0] in ("-h", "--help"):
|
|
29
|
+
print(USAGE, end="", file=sys.stdout if args else sys.stderr)
|
|
30
|
+
sys.exit(0 if args else 2)
|
|
31
|
+
command, rest = args[0], args[1:]
|
|
32
|
+
if command == "settings" and len(rest) <= 1:
|
|
33
|
+
from .panel import SettingsApp
|
|
34
|
+
|
|
35
|
+
SettingsApp(rest[0] if rest else None).run()
|
|
36
|
+
elif command == "docs" and len(rest) <= 1:
|
|
37
|
+
from .reference import Unknown, reference
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
text, gaps = reference(rest[0] if rest else "hill")
|
|
41
|
+
except Unknown as e:
|
|
42
|
+
print(f"hill-ops docs: {e}", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
print(text, end="")
|
|
45
|
+
for gap in gaps:
|
|
46
|
+
print(f"hill-ops docs: undocumented: {gap}", file=sys.stderr)
|
|
47
|
+
elif command == "connect" and not rest:
|
|
48
|
+
from .connect import main as bridge
|
|
49
|
+
|
|
50
|
+
sys.exit(bridge())
|
|
51
|
+
elif command == "_run":
|
|
52
|
+
from .runner import main as run_app
|
|
53
|
+
|
|
54
|
+
sys.exit(run_app())
|
|
55
|
+
elif command == "_side":
|
|
56
|
+
from .runner import side
|
|
57
|
+
|
|
58
|
+
sys.exit(side())
|
|
59
|
+
elif command == "_over":
|
|
60
|
+
from .runner import over
|
|
61
|
+
|
|
62
|
+
sys.exit(over())
|
|
63
|
+
elif command == "_strip":
|
|
64
|
+
from hill_client import SOCKET
|
|
65
|
+
|
|
66
|
+
from .events import EventLog
|
|
67
|
+
from .panel import Panel
|
|
68
|
+
|
|
69
|
+
log = os.environ.get("HILL_EVENTS")
|
|
70
|
+
Panel(Path(os.environ[SOCKET]), os.environ.get("HILL_APP_PANE"), event_log=EventLog(Path(log) if log else None)).run()
|
|
71
|
+
else:
|
|
72
|
+
choice = name = None
|
|
73
|
+
if command in ("--resume", "--new"):
|
|
74
|
+
choice, args = command[2:], rest
|
|
75
|
+
elif command == "--as" and rest:
|
|
76
|
+
name, args = rest[0], rest[1:]
|
|
77
|
+
argv = args[1:] if args[:1] == ["--"] else args
|
|
78
|
+
if not argv:
|
|
79
|
+
print(USAGE, end="", file=sys.stderr)
|
|
80
|
+
sys.exit(2)
|
|
81
|
+
from .host import run
|
|
82
|
+
|
|
83
|
+
sys.exit(run(argv, choice, name))
|