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 ADDED
@@ -0,0 +1,2 @@
1
+ """hill-ops: runs a terminal app above a strip for its settings, help and commands,
2
+ in a private tmux, the two talking over a channel."""
hill_ops/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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))