rumo-cli 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.
- rumo/__init__.py +0 -0
- rumo/app.py +496 -0
- rumo/cli.py +77 -0
- rumo/config.py +147 -0
- rumo/hook.py +47 -0
- rumo/items.py +305 -0
- rumo_cli-0.1.0.dist-info/METADATA +176 -0
- rumo_cli-0.1.0.dist-info/RECORD +11 -0
- rumo_cli-0.1.0.dist-info/WHEEL +4 -0
- rumo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rumo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
rumo/__init__.py
ADDED
|
File without changes
|
rumo/app.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""The `rumo` TUI: a Timeline (Past / Now / Later) on the left, the selected item on the right."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
17
|
+
from textual.screen import ModalScreen
|
|
18
|
+
from textual.widgets import Input, Label, ListItem, ListView, Markdown, OptionList, SelectionList, Static
|
|
19
|
+
from textual.widgets.option_list import Option
|
|
20
|
+
|
|
21
|
+
from rumo import config, items
|
|
22
|
+
from rumo.items import Item, WriteConflict
|
|
23
|
+
|
|
24
|
+
STATUS_STYLE = {"idea": "cyan", "doing": "green", "waiting": "yellow", "paused": "grey50", "done": "blue"}
|
|
25
|
+
NEW_GROUP = "+new"
|
|
26
|
+
NO_GROUP = "-none"
|
|
27
|
+
|
|
28
|
+
KEYS = Text.assemble(
|
|
29
|
+
*(
|
|
30
|
+
part
|
|
31
|
+
for key, label in [
|
|
32
|
+
("s", "status"), ("g", "group"), ("t", "tags"), ("n", "new idea"), ("e", "edit"),
|
|
33
|
+
("space", "step"), ("c", "copy resume"), ("/", "filter"), ("q", "quit"),
|
|
34
|
+
]
|
|
35
|
+
for part in ((key, "bold"), (f" {label} ", "dim"))
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def short_date(stamp: str) -> str:
|
|
44
|
+
"""`2026-10-06 17:00` -> `Oct 06`: unambiguous in any country, unlike 10/06."""
|
|
45
|
+
try:
|
|
46
|
+
return f"{MONTHS[int(stamp[5:7]) - 1]} {stamp[8:10]}"
|
|
47
|
+
except (ValueError, IndexError):
|
|
48
|
+
return stamp[:10]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------- pickers ----------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ChoiceScreen(ModalScreen[str | None]):
|
|
55
|
+
"""Pick one value; digits 1-9 pick directly, Esc cancels."""
|
|
56
|
+
|
|
57
|
+
BINDINGS = [Binding("escape", "dismiss(None)", "cancel")]
|
|
58
|
+
|
|
59
|
+
def __init__(self, title: str, choices: list[tuple[str, Text]], current: str | None = None) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.title_text = title
|
|
62
|
+
self.choices = choices
|
|
63
|
+
self.current = current
|
|
64
|
+
|
|
65
|
+
def compose(self) -> ComposeResult:
|
|
66
|
+
with Vertical(classes="dialog"):
|
|
67
|
+
yield Label(self.title_text, classes="dialog-title")
|
|
68
|
+
options = []
|
|
69
|
+
for n, (value, label) in enumerate(self.choices, start=1):
|
|
70
|
+
prefix = f"{n} " if n <= 9 else " "
|
|
71
|
+
options.append(Option(Text.assemble((prefix, "dim"), label), id=value))
|
|
72
|
+
yield OptionList(*options)
|
|
73
|
+
|
|
74
|
+
def on_mount(self) -> None:
|
|
75
|
+
option_list = self.query_one(OptionList)
|
|
76
|
+
values = [v for v, _ in self.choices]
|
|
77
|
+
if self.current in values:
|
|
78
|
+
option_list.highlighted = values.index(self.current)
|
|
79
|
+
option_list.focus()
|
|
80
|
+
|
|
81
|
+
def on_key(self, event) -> None:
|
|
82
|
+
if event.character and event.character.isdigit() and event.character != "0":
|
|
83
|
+
n = int(event.character)
|
|
84
|
+
if n <= len(self.choices):
|
|
85
|
+
event.stop()
|
|
86
|
+
self.dismiss(self.choices[n - 1][0])
|
|
87
|
+
|
|
88
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
89
|
+
self.dismiss(event.option.id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TextScreen(ModalScreen[str | None]):
|
|
93
|
+
BINDINGS = [Binding("escape", "dismiss(None)", "cancel")]
|
|
94
|
+
|
|
95
|
+
def __init__(self, title: str, placeholder: str = "") -> None:
|
|
96
|
+
super().__init__()
|
|
97
|
+
self.title_text = title
|
|
98
|
+
self.placeholder = placeholder
|
|
99
|
+
|
|
100
|
+
def compose(self) -> ComposeResult:
|
|
101
|
+
with Vertical(classes="dialog"):
|
|
102
|
+
yield Label(self.title_text, classes="dialog-title")
|
|
103
|
+
yield Input(placeholder=self.placeholder)
|
|
104
|
+
|
|
105
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
106
|
+
self.dismiss(event.value.strip() or None)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TagScreen(ModalScreen[list[str] | None]):
|
|
110
|
+
"""Space toggles a tag, Enter applies, Esc cancels."""
|
|
111
|
+
|
|
112
|
+
BINDINGS = [
|
|
113
|
+
Binding("escape", "dismiss(None)", "cancel"),
|
|
114
|
+
Binding("enter", "apply", "apply", priority=True),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
def __init__(self, tags: list[str], selected: list[str]) -> None:
|
|
118
|
+
super().__init__()
|
|
119
|
+
self.tags = tags
|
|
120
|
+
self.selected = selected
|
|
121
|
+
|
|
122
|
+
def compose(self) -> ComposeResult:
|
|
123
|
+
with Vertical(classes="dialog"):
|
|
124
|
+
yield Label("Tags (space toggles, enter applies)", classes="dialog-title")
|
|
125
|
+
yield SelectionList[str](*((t, t, t in self.selected) for t in self.tags))
|
|
126
|
+
|
|
127
|
+
def action_apply(self) -> None:
|
|
128
|
+
chosen = set(self.query_one(SelectionList).selected)
|
|
129
|
+
self.dismiss([t for t in self.tags if t in chosen])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class StepList(ListView):
|
|
133
|
+
BINDINGS = [Binding("space", "app.toggle_step", "toggle step")]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------- app ----------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class RumoApp(App):
|
|
140
|
+
TITLE = "rumo"
|
|
141
|
+
CSS = """
|
|
142
|
+
Screen { layout: vertical; }
|
|
143
|
+
#filter { display: none; }
|
|
144
|
+
#filter.on { display: block; }
|
|
145
|
+
#main { height: 1fr; }
|
|
146
|
+
#timeline { width: 45%; height: 1fr; border: round $primary-darken-1; }
|
|
147
|
+
#timeline:focus { border: round $primary; }
|
|
148
|
+
#detail { border: round $secondary-darken-1; padding: 0 1; }
|
|
149
|
+
#header { margin-bottom: 1; }
|
|
150
|
+
.section { text-style: bold; color: $accent; margin-top: 1; }
|
|
151
|
+
#steps, #sessions { height: auto; max-height: 12; }
|
|
152
|
+
Markdown { margin: 0; padding: 0; }
|
|
153
|
+
#legend, #keys { height: auto; padding: 0 1; }
|
|
154
|
+
ModalScreen { align: center middle; }
|
|
155
|
+
.dialog { width: 60; height: auto; max-height: 80%; border: round $primary; background: $surface; padding: 0 1; }
|
|
156
|
+
.dialog-title { text-style: bold; margin-bottom: 1; }
|
|
157
|
+
.dialog OptionList, .dialog SelectionList { height: auto; max-height: 20; }
|
|
158
|
+
"""
|
|
159
|
+
BINDINGS = [
|
|
160
|
+
Binding("q", "quit", "quit"),
|
|
161
|
+
Binding("s", "status", "status"),
|
|
162
|
+
Binding("g", "group", "group"),
|
|
163
|
+
Binding("t", "tags", "tags"),
|
|
164
|
+
Binding("n", "new_idea", "new idea"),
|
|
165
|
+
Binding("e", "edit", "edit"),
|
|
166
|
+
Binding("c", "copy_resume", "copy resume"),
|
|
167
|
+
Binding("slash", "filter", "filter"),
|
|
168
|
+
Binding("escape", "clear_filter", "clear filter", show=False),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def __init__(self, config_path: Path | None = None, today: date | None = None) -> None:
|
|
172
|
+
super().__init__()
|
|
173
|
+
self.config_path = config_path or config.default_path()
|
|
174
|
+
self.cfg = config.load(self.config_path)
|
|
175
|
+
self.today = today
|
|
176
|
+
self.items: dict[str, Item] = {}
|
|
177
|
+
self.current: str | None = None
|
|
178
|
+
self.filter_text = ""
|
|
179
|
+
self.show_older = False
|
|
180
|
+
self.mtimes: dict[str, float] = {}
|
|
181
|
+
|
|
182
|
+
def compose(self) -> ComposeResult:
|
|
183
|
+
yield Input(placeholder="filter by title, tag, group or text (Esc clears)", id="filter")
|
|
184
|
+
with Horizontal(id="main"):
|
|
185
|
+
yield OptionList(id="timeline")
|
|
186
|
+
with VerticalScroll(id="detail"):
|
|
187
|
+
yield Static(id="header")
|
|
188
|
+
yield Markdown(id="body")
|
|
189
|
+
yield Label("Steps", classes="section")
|
|
190
|
+
yield StepList(id="steps")
|
|
191
|
+
yield Label("Sessions (c copies the resume command)", classes="section")
|
|
192
|
+
yield ListView(id="sessions")
|
|
193
|
+
yield Static(id="legend")
|
|
194
|
+
yield Static(KEYS, id="keys")
|
|
195
|
+
|
|
196
|
+
def on_mount(self) -> None:
|
|
197
|
+
self.cfg.folder.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
if not self.cfg.error:
|
|
199
|
+
config.ensure_file(self.config_path, self.cfg.folder, self.cfg.language)
|
|
200
|
+
self.reload()
|
|
201
|
+
self.query_one("#timeline", OptionList).focus()
|
|
202
|
+
self.set_interval(2.0, self.check_changes)
|
|
203
|
+
|
|
204
|
+
# ---------- data ----------
|
|
205
|
+
|
|
206
|
+
def read_mtimes(self) -> dict[str, float]:
|
|
207
|
+
files = list(self.cfg.folder.glob("*.md"))
|
|
208
|
+
if self.config_path.exists():
|
|
209
|
+
files.append(self.config_path)
|
|
210
|
+
mtimes = {}
|
|
211
|
+
for path in files:
|
|
212
|
+
try:
|
|
213
|
+
mtimes[str(path)] = path.stat().st_mtime
|
|
214
|
+
except OSError:
|
|
215
|
+
continue
|
|
216
|
+
return mtimes
|
|
217
|
+
|
|
218
|
+
def check_changes(self) -> None:
|
|
219
|
+
if self.read_mtimes() != self.mtimes:
|
|
220
|
+
self.reload()
|
|
221
|
+
|
|
222
|
+
def reload(self) -> None:
|
|
223
|
+
self.cfg = config.load(self.config_path)
|
|
224
|
+
self.mtimes = self.read_mtimes()
|
|
225
|
+
self.items = {i.slug: i for i in items.list_items(self.cfg.folder)}
|
|
226
|
+
self.build_timeline()
|
|
227
|
+
self.show_detail()
|
|
228
|
+
self.query_one("#legend", Static).update(self.legend())
|
|
229
|
+
|
|
230
|
+
def group_style(self, item: Item) -> str:
|
|
231
|
+
group = self.cfg.groups.get(item.group)
|
|
232
|
+
return config.PALETTE.get(group.color, "") if group else ""
|
|
233
|
+
|
|
234
|
+
def matches(self, item: Item) -> bool:
|
|
235
|
+
term = self.filter_text.lower().strip()
|
|
236
|
+
if not term:
|
|
237
|
+
return True
|
|
238
|
+
group = self.cfg.groups.get(item.group)
|
|
239
|
+
haystack = [item.title, item.slug, item.status, item.group, group.name if group else "", *item.tags]
|
|
240
|
+
haystack += item.sections.values()
|
|
241
|
+
return term in " ".join(haystack).lower()
|
|
242
|
+
|
|
243
|
+
def write(self, operation: Callable[[], object]) -> None:
|
|
244
|
+
try:
|
|
245
|
+
operation()
|
|
246
|
+
except WriteConflict:
|
|
247
|
+
self.notify("The item just changed on disk. Reloaded; try again.", severity="warning")
|
|
248
|
+
self.reload()
|
|
249
|
+
|
|
250
|
+
# ---------- timeline ----------
|
|
251
|
+
|
|
252
|
+
def build_timeline(self) -> None:
|
|
253
|
+
timeline = self.query_one("#timeline", OptionList)
|
|
254
|
+
visible = [i for i in self.items.values() if self.matches(i)]
|
|
255
|
+
tl = items.timeline(visible, today=self.today, show_older=self.show_older)
|
|
256
|
+
|
|
257
|
+
options: list[Option] = []
|
|
258
|
+
if not self.items:
|
|
259
|
+
options.append(Option(Text(
|
|
260
|
+
"No items yet. Press n to capture an idea, or run /rumo new in an agent session.", "dim"
|
|
261
|
+
), id="empty", disabled=True))
|
|
262
|
+
if tl.past or tl.older:
|
|
263
|
+
options.append(self.header("Past", f"last {items.PAST_WINDOW_DAYS} days" if not self.show_older else "all", "past"))
|
|
264
|
+
if tl.older:
|
|
265
|
+
options.append(Option(Text(f" … {tl.older} older (enter to show)", "dim italic"), id="older"))
|
|
266
|
+
options += [Option(self.row(i, short_date(i.done_at)), id=f"item:{i.slug}") for i in tl.past]
|
|
267
|
+
if tl.now:
|
|
268
|
+
options.append(self.header("Now", str(len(tl.now)), "now"))
|
|
269
|
+
options += [Option(self.row(i, i.status, next_step=True), id=f"item:{i.slug}") for i in tl.now]
|
|
270
|
+
if tl.later:
|
|
271
|
+
options.append(self.header("Later", str(len(tl.later)), "later"))
|
|
272
|
+
options += [Option(self.row(i, i.status), id=f"item:{i.slug}") for i in tl.later]
|
|
273
|
+
|
|
274
|
+
timeline.clear_options()
|
|
275
|
+
timeline.add_options(options)
|
|
276
|
+
|
|
277
|
+
ids = [o.id for o in options]
|
|
278
|
+
target = f"item:{self.current}" if self.current else None
|
|
279
|
+
if target not in ids:
|
|
280
|
+
first_now = f"item:{tl.now[0].slug}" if tl.now else None
|
|
281
|
+
first_any = next((i for i in ids if i and i.startswith("item:")), None)
|
|
282
|
+
target = first_now or first_any
|
|
283
|
+
self.current = target.removeprefix("item:") if target else None
|
|
284
|
+
if target:
|
|
285
|
+
timeline.highlighted = ids.index(target)
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def header(name: str, note: str, key: str) -> Option:
|
|
289
|
+
return Option(Text.assemble((name, "bold"), (f" {note}", "dim")), id=f"header:{key}", disabled=True)
|
|
290
|
+
|
|
291
|
+
def row(self, item: Item, prefix: str, next_step: bool = False) -> Text:
|
|
292
|
+
text = Text.assemble((f"{prefix:<8}", STATUS_STYLE.get(prefix, "dim")), (item.title, self.group_style(item)))
|
|
293
|
+
if item.steps:
|
|
294
|
+
done = sum(s.done for s in item.steps)
|
|
295
|
+
text.append(f" {done}/{len(item.steps)}", "dim")
|
|
296
|
+
if next_step and (step := item.next_step):
|
|
297
|
+
text.append(f"\n ↳ {step.text}", "dim")
|
|
298
|
+
return text
|
|
299
|
+
|
|
300
|
+
def legend(self) -> Text:
|
|
301
|
+
text = Text()
|
|
302
|
+
if self.cfg.error:
|
|
303
|
+
text.append(f"Config error, using defaults: {self.cfg.error}\n", "bold red")
|
|
304
|
+
if not self.cfg.groups:
|
|
305
|
+
return text.append("No groups yet. Press g on an item to create one.", "dim")
|
|
306
|
+
for group in self.cfg.groups.values():
|
|
307
|
+
text.append("● ", config.PALETTE.get(group.color, ""))
|
|
308
|
+
text.append(group.name + " ")
|
|
309
|
+
return text
|
|
310
|
+
|
|
311
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
312
|
+
if event.option_list.id != "timeline":
|
|
313
|
+
return
|
|
314
|
+
option_id = event.option.id or ""
|
|
315
|
+
if option_id.startswith("item:") and option_id.removeprefix("item:") != self.current:
|
|
316
|
+
self.current = option_id.removeprefix("item:")
|
|
317
|
+
self.show_detail()
|
|
318
|
+
|
|
319
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
320
|
+
if event.option_list.id == "timeline" and event.option.id == "older":
|
|
321
|
+
self.show_older = True
|
|
322
|
+
self.build_timeline()
|
|
323
|
+
|
|
324
|
+
# ---------- detail ----------
|
|
325
|
+
|
|
326
|
+
def current_item(self) -> Item | None:
|
|
327
|
+
return self.items.get(self.current) if self.current else None
|
|
328
|
+
|
|
329
|
+
def show_detail(self) -> None:
|
|
330
|
+
item = self.current_item()
|
|
331
|
+
header = self.query_one("#header", Static)
|
|
332
|
+
body = self.query_one("#body", Markdown)
|
|
333
|
+
steps = self.query_one("#steps", ListView)
|
|
334
|
+
sessions = self.query_one("#sessions", ListView)
|
|
335
|
+
step_index, session_index = steps.index, sessions.index
|
|
336
|
+
steps.clear()
|
|
337
|
+
sessions.clear()
|
|
338
|
+
if item is None:
|
|
339
|
+
header.update(Text("Nothing selected.", "dim"))
|
|
340
|
+
body.update("")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
line = Text.assemble((item.title, f"bold {self.group_style(item)}"), "\n", (item.status, STATUS_STYLE[item.status]))
|
|
344
|
+
if group := self.cfg.groups.get(item.group):
|
|
345
|
+
line.append(" group ", "dim")
|
|
346
|
+
line.append(group.name, config.PALETTE.get(group.color, ""))
|
|
347
|
+
if item.tags:
|
|
348
|
+
line.append(" tags ", "dim")
|
|
349
|
+
for tag in item.tags:
|
|
350
|
+
line.append(tag + " ", "" if tag in self.cfg.tags else "dim")
|
|
351
|
+
if updated := item.meta.get("updated"):
|
|
352
|
+
line.append(f" updated {updated}", "dim")
|
|
353
|
+
header.update(line)
|
|
354
|
+
|
|
355
|
+
parts = []
|
|
356
|
+
for name in ("left off", "context"):
|
|
357
|
+
if content := item.sections.get(name):
|
|
358
|
+
parts.append(f"**{name.capitalize()}**\n\n{content}")
|
|
359
|
+
body.update("\n\n".join(parts))
|
|
360
|
+
|
|
361
|
+
steps.extend(ListItem(Label(self.step_label(s))) for s in item.steps)
|
|
362
|
+
sessions.extend(
|
|
363
|
+
ListItem(Label(Text.assemble((s.when, "dim"), " ", s.repo, " ", (s.id[:8], "cyan"), (" closed" if s.closed else "", "dim"))))
|
|
364
|
+
for s in reversed(item.sessions)
|
|
365
|
+
)
|
|
366
|
+
if item.steps:
|
|
367
|
+
steps.index = min(step_index or 0, len(item.steps) - 1)
|
|
368
|
+
if item.sessions:
|
|
369
|
+
sessions.index = min(session_index or 0, len(item.sessions) - 1)
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def step_label(step: items.Step) -> Text:
|
|
373
|
+
kind = f"[{step.kind}] " if step.kind else ""
|
|
374
|
+
if step.done:
|
|
375
|
+
return Text(f"[x] {kind}{step.text}", "dim strike")
|
|
376
|
+
return Text.assemble("[ ] ", (kind, "yellow" if step.kind == "manual" else "dim"), step.text)
|
|
377
|
+
|
|
378
|
+
# ---------- actions ----------
|
|
379
|
+
|
|
380
|
+
def action_toggle_step(self) -> None:
|
|
381
|
+
item = self.current_item()
|
|
382
|
+
index = self.query_one("#steps", ListView).index
|
|
383
|
+
if item is None or index is None or index >= len(item.steps):
|
|
384
|
+
return
|
|
385
|
+
step = item.steps[index]
|
|
386
|
+
self.write(lambda: items.toggle_step(item, step))
|
|
387
|
+
|
|
388
|
+
def action_status(self) -> None:
|
|
389
|
+
item = self.current_item()
|
|
390
|
+
if item is None:
|
|
391
|
+
return
|
|
392
|
+
choices = [(s, Text(s, STATUS_STYLE[s])) for s in items.STATUSES]
|
|
393
|
+
|
|
394
|
+
def chosen(status: str | None) -> None:
|
|
395
|
+
if status and status != item.status:
|
|
396
|
+
self.write(lambda: items.set_status(item, status))
|
|
397
|
+
|
|
398
|
+
self.push_screen(ChoiceScreen(f"Status of “{item.title}”", choices, item.status), chosen)
|
|
399
|
+
|
|
400
|
+
def action_group(self) -> None:
|
|
401
|
+
item = self.current_item()
|
|
402
|
+
if item is None:
|
|
403
|
+
return
|
|
404
|
+
choices = [
|
|
405
|
+
(g.key, Text(g.name, config.PALETTE.get(g.color, ""))) for g in self.cfg.groups.values()
|
|
406
|
+
]
|
|
407
|
+
choices += [(NO_GROUP, Text("no group", "dim")), (NEW_GROUP, Text("+ new group…", "italic"))]
|
|
408
|
+
|
|
409
|
+
def chosen(key: str | None) -> None:
|
|
410
|
+
if key == NEW_GROUP:
|
|
411
|
+
self.new_group(item)
|
|
412
|
+
elif key is not None:
|
|
413
|
+
self.write(lambda: items.set_group(item, None if key == NO_GROUP else key))
|
|
414
|
+
|
|
415
|
+
self.push_screen(ChoiceScreen(f"Group of “{item.title}”", choices, item.group or NO_GROUP), chosen)
|
|
416
|
+
|
|
417
|
+
def new_group(self, item: Item) -> None:
|
|
418
|
+
def named(name: str | None) -> None:
|
|
419
|
+
if not name:
|
|
420
|
+
return
|
|
421
|
+
colors = [(c, Text(f"● {c}", hex_)) for c, hex_ in config.PALETTE.items()]
|
|
422
|
+
|
|
423
|
+
def colored(color: str | None) -> None:
|
|
424
|
+
if color:
|
|
425
|
+
key = config.add_group(self.config_path, name, color)
|
|
426
|
+
self.write(lambda: items.set_group(item, key))
|
|
427
|
+
|
|
428
|
+
self.push_screen(ChoiceScreen(f"Color for “{name}”", colors), colored)
|
|
429
|
+
|
|
430
|
+
self.push_screen(TextScreen("New group name", "e.g. Auth migration"), named)
|
|
431
|
+
|
|
432
|
+
def action_tags(self) -> None:
|
|
433
|
+
item = self.current_item()
|
|
434
|
+
if item is None:
|
|
435
|
+
return
|
|
436
|
+
tags = list(self.cfg.tags) + [t for t in item.tags if t not in self.cfg.tags]
|
|
437
|
+
|
|
438
|
+
def chosen(selected: list[str] | None) -> None:
|
|
439
|
+
if selected is not None and selected != item.tags:
|
|
440
|
+
self.write(lambda: items.set_tags(item, selected))
|
|
441
|
+
|
|
442
|
+
self.push_screen(TagScreen(tags, item.tags), chosen)
|
|
443
|
+
|
|
444
|
+
def action_new_idea(self) -> None:
|
|
445
|
+
def created(title: str | None) -> None:
|
|
446
|
+
if title:
|
|
447
|
+
path = items.create_idea(self.cfg.folder, title, now=datetime.now())
|
|
448
|
+
self.current = path.stem
|
|
449
|
+
self.reload()
|
|
450
|
+
|
|
451
|
+
self.push_screen(TextScreen("New idea", "a short title"), created)
|
|
452
|
+
|
|
453
|
+
def action_edit(self) -> None:
|
|
454
|
+
item = self.current_item()
|
|
455
|
+
if item is None:
|
|
456
|
+
return
|
|
457
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "vi")
|
|
458
|
+
with self.suspend():
|
|
459
|
+
subprocess.run([*shlex.split(editor, posix=os.name != "nt"), str(item.path)], check=False)
|
|
460
|
+
self.reload()
|
|
461
|
+
|
|
462
|
+
def action_copy_resume(self) -> None:
|
|
463
|
+
item = self.current_item()
|
|
464
|
+
if item is None or not item.sessions:
|
|
465
|
+
self.notify("This item has no sessions.", severity="warning")
|
|
466
|
+
return
|
|
467
|
+
sessions = self.query_one("#sessions", ListView)
|
|
468
|
+
recent = list(reversed(item.sessions))
|
|
469
|
+
index = sessions.index if sessions.has_focus and sessions.index is not None else 0
|
|
470
|
+
command = recent[index].resume_command
|
|
471
|
+
if shutil.which("pbcopy"):
|
|
472
|
+
subprocess.run(["pbcopy"], input=command.encode(), check=False)
|
|
473
|
+
else:
|
|
474
|
+
self.copy_to_clipboard(command) # OSC 52: works in most terminals outside macOS
|
|
475
|
+
self.notify(f"Copied: {command}")
|
|
476
|
+
|
|
477
|
+
def action_filter(self) -> None:
|
|
478
|
+
field = self.query_one("#filter", Input)
|
|
479
|
+
field.add_class("on")
|
|
480
|
+
field.focus()
|
|
481
|
+
|
|
482
|
+
def action_clear_filter(self) -> None:
|
|
483
|
+
field = self.query_one("#filter", Input)
|
|
484
|
+
field.value = ""
|
|
485
|
+
field.remove_class("on")
|
|
486
|
+
self.query_one("#timeline", OptionList).focus()
|
|
487
|
+
|
|
488
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
489
|
+
if event.input.id == "filter":
|
|
490
|
+
self.filter_text = event.value
|
|
491
|
+
self.build_timeline()
|
|
492
|
+
self.show_detail()
|
|
493
|
+
|
|
494
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
495
|
+
if event.input.id == "filter":
|
|
496
|
+
self.query_one("#timeline", OptionList).focus()
|
rumo/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The `rumo` command: opens the Timeline.
|
|
2
|
+
|
|
3
|
+
Other subcommands exist for the agent and the hook, and are kept out of
|
|
4
|
+
the help on purpose: `hook`, `index`, `path`, `group-add`, `--version`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
USAGE = """rumo: your work, past, now and later.
|
|
12
|
+
|
|
13
|
+
rumo open the Timeline
|
|
14
|
+
|
|
15
|
+
Inside an AI agent session, type /rumo for the agent commands.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> None:
|
|
20
|
+
args = sys.argv[1:] if argv is None else argv
|
|
21
|
+
command = args[0] if args else ""
|
|
22
|
+
|
|
23
|
+
# Late imports: `rumo hook` runs on every prompt and must not pay for Textual.
|
|
24
|
+
if command == "":
|
|
25
|
+
from rumo.app import RumoApp
|
|
26
|
+
|
|
27
|
+
RumoApp().run()
|
|
28
|
+
elif command == "hook":
|
|
29
|
+
from rumo.hook import main as hook
|
|
30
|
+
|
|
31
|
+
hook()
|
|
32
|
+
elif command == "path":
|
|
33
|
+
from rumo import config
|
|
34
|
+
|
|
35
|
+
print(config.load().folder)
|
|
36
|
+
elif command == "index":
|
|
37
|
+
_index()
|
|
38
|
+
elif command == "group-add":
|
|
39
|
+
_group_add(args[1:])
|
|
40
|
+
elif command in ("-V", "--version"):
|
|
41
|
+
from importlib.metadata import version
|
|
42
|
+
|
|
43
|
+
print(f"rumo {version('rumo-cli')}")
|
|
44
|
+
elif command in ("-h", "--help", "help"):
|
|
45
|
+
print(USAGE, end="")
|
|
46
|
+
else:
|
|
47
|
+
print(USAGE, end="", file=sys.stderr)
|
|
48
|
+
raise SystemExit(2)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _index() -> None:
|
|
52
|
+
"""Everything the agent needs in one call: where items live, the content language, tags, groups and items."""
|
|
53
|
+
from rumo import config, items
|
|
54
|
+
|
|
55
|
+
cfg = config.load()
|
|
56
|
+
print(f"# folder: {cfg.folder}")
|
|
57
|
+
print(f"# config: {cfg.path}")
|
|
58
|
+
print(f"# language: {cfg.language}")
|
|
59
|
+
print(f"# tags: {', '.join(cfg.tags)}")
|
|
60
|
+
print(f"# groups: {', '.join(f'{g.key} ({g.name}, {g.color})' for g in cfg.groups.values()) or '-'}")
|
|
61
|
+
print(f"# colors: {', '.join(config.PALETTE)}")
|
|
62
|
+
if listing := items.index(items.list_items(cfg.folder)):
|
|
63
|
+
print(listing)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _group_add(rest: list[str]) -> None:
|
|
67
|
+
"""`rumo group-add "<name>" <color>`: append a group to the config and print its key."""
|
|
68
|
+
from rumo import config
|
|
69
|
+
|
|
70
|
+
if len(rest) != 2 or not rest[0].strip() or rest[1] not in config.PALETTE:
|
|
71
|
+
print(f'usage: rumo group-add "<name>" <{"|".join(config.PALETTE)}>', file=sys.stderr)
|
|
72
|
+
raise SystemExit(2)
|
|
73
|
+
print(config.add_group(config.load().path, rest[0].strip(), rest[1]))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
rumo/config.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""User config: config.toml in the place each system expects (see `default_path`), or $RUMO_CONFIG.
|
|
2
|
+
|
|
3
|
+
folder = "~/rumo"
|
|
4
|
+
language = "pt-BR" # language of the content the agent writes
|
|
5
|
+
tags = ["bug", "feature"] # fixed list the agent picks from
|
|
6
|
+
|
|
7
|
+
[groups.auth]
|
|
8
|
+
name = "Migração auth" # content: any language
|
|
9
|
+
color = "blue" # one of PALETTE
|
|
10
|
+
|
|
11
|
+
A missing file means defaults; a malformed one means defaults plus `error`, never a crash.
|
|
12
|
+
Stdlib only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import tomllib
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from rumo.items import slugify
|
|
26
|
+
|
|
27
|
+
# Named colors that read well on dark and light terminals.
|
|
28
|
+
PALETTE = {
|
|
29
|
+
"blue": "#5b8cff",
|
|
30
|
+
"green": "#3fbf7f",
|
|
31
|
+
"yellow": "#d9a520",
|
|
32
|
+
"orange": "#ec8a3c",
|
|
33
|
+
"red": "#e05d5d",
|
|
34
|
+
"magenta": "#d561b5",
|
|
35
|
+
"purple": "#a77ee8",
|
|
36
|
+
"cyan": "#2fb8c8",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
DEFAULT_TAGS = [
|
|
40
|
+
"bug", "feature", "refactor", "research", "spike", "review",
|
|
41
|
+
"infra", "deploy", "meeting", "docs", "support", "tech-debt",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Group:
|
|
47
|
+
key: str
|
|
48
|
+
name: str
|
|
49
|
+
color: str # a PALETTE name; anything else renders in the default color
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Config:
|
|
54
|
+
path: Path
|
|
55
|
+
folder: Path
|
|
56
|
+
language: str = "en"
|
|
57
|
+
tags: list[str] = field(default_factory=lambda: list(DEFAULT_TAGS))
|
|
58
|
+
groups: dict[str, Group] = field(default_factory=dict)
|
|
59
|
+
error: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_path(env: Mapping[str, str] | None = None, windows: bool | None = None) -> Path:
|
|
63
|
+
"""$RUMO_CONFIG, else %APPDATA%\\rumo on Windows, else $XDG_CONFIG_HOME/rumo (default ~/.config/rumo)."""
|
|
64
|
+
env = os.environ if env is None else env
|
|
65
|
+
windows = os.name == "nt" if windows is None else windows
|
|
66
|
+
if override := env.get("RUMO_CONFIG"):
|
|
67
|
+
return Path(override).expanduser()
|
|
68
|
+
if windows:
|
|
69
|
+
base = Path(env["APPDATA"]) if env.get("APPDATA") else Path.home() / "AppData" / "Roaming"
|
|
70
|
+
else:
|
|
71
|
+
base = Path(env["XDG_CONFIG_HOME"]) if env.get("XDG_CONFIG_HOME") else Path.home() / ".config"
|
|
72
|
+
return base / "rumo" / "config.toml"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load(path: Path | None = None) -> Config:
|
|
76
|
+
path = path or default_path()
|
|
77
|
+
data: dict = {}
|
|
78
|
+
error = None
|
|
79
|
+
try:
|
|
80
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
pass
|
|
83
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
|
|
84
|
+
error = f"{path}: {e}"
|
|
85
|
+
|
|
86
|
+
folder = os.environ.get("RUMO_DIR") or data.get("folder") or "~/rumo"
|
|
87
|
+
cfg = Config(path=path, folder=Path(str(folder)).expanduser(), language=language_from_env(), error=error)
|
|
88
|
+
if isinstance(language := data.get("language"), str) and language.strip():
|
|
89
|
+
cfg.language = language.strip()
|
|
90
|
+
if isinstance(tags := data.get("tags"), list):
|
|
91
|
+
cfg.tags = [str(t) for t in tags]
|
|
92
|
+
groups = data.get("groups")
|
|
93
|
+
for key, value in (groups.items() if isinstance(groups, dict) else []):
|
|
94
|
+
if isinstance(value, dict):
|
|
95
|
+
cfg.groups[key] = Group(key, str(value.get("name") or key), str(value.get("color", "")))
|
|
96
|
+
return cfg
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def language_from_env(env: Mapping[str, str] | None = None) -> str:
|
|
100
|
+
"""The system language as a tag: `pt_BR.UTF-8` -> `pt-BR`; anything unusable -> `en`."""
|
|
101
|
+
env = os.environ if env is None else env
|
|
102
|
+
value = env.get("LC_ALL") or env.get("LANG") or ""
|
|
103
|
+
m = re.match(r"^([a-z]{2})(?:_([A-Z]{2}))?", value)
|
|
104
|
+
if not m:
|
|
105
|
+
return "en"
|
|
106
|
+
return f"{m.group(1)}-{m.group(2)}" if m.group(2) else m.group(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ensure_file(path: Path, folder: Path, language: str) -> None:
|
|
110
|
+
"""Write a default config the first time, so there is a file to edit. Never overwrites."""
|
|
111
|
+
if path.exists():
|
|
112
|
+
return
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
path.write_text(default_text(folder, language), encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def add_group(path: Path, name: str, color: str) -> str:
|
|
118
|
+
"""Append a [groups.<key>] block as text (no TOML writer needed) and return the new key."""
|
|
119
|
+
existing = load(path).groups
|
|
120
|
+
base = slugify(name)
|
|
121
|
+
key, n = base, 2
|
|
122
|
+
while key in existing:
|
|
123
|
+
key, n = f"{base}-{n}", n + 1
|
|
124
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
125
|
+
block = f'\n[groups.{key}]\nname = {toml_str(name)}\ncolor = {toml_str(color)}\n'
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
path.write_text(text.rstrip("\n") + "\n" + block if text.strip() else block.lstrip("\n"), encoding="utf-8")
|
|
128
|
+
return key
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def default_text(folder: Path, language: str) -> str:
|
|
132
|
+
tags = ", ".join(toml_str(t) for t in DEFAULT_TAGS)
|
|
133
|
+
return (
|
|
134
|
+
"# rumo config. Colors: " + ", ".join(PALETTE) + "\n"
|
|
135
|
+
f"folder = {toml_str(str(folder))}\n"
|
|
136
|
+
f"language = {toml_str(language)}\n"
|
|
137
|
+
f"tags = [{tags}]\n"
|
|
138
|
+
"\n"
|
|
139
|
+
"# Groups connect related items; the title takes the group color.\n"
|
|
140
|
+
"# [groups.auth]\n"
|
|
141
|
+
'# name = "Auth migration"\n'
|
|
142
|
+
'# color = "blue"\n'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def toml_str(value: str) -> str:
|
|
147
|
+
return json.dumps(value, ensure_ascii=False) # a JSON string is a valid TOML basic string
|
rumo/hook.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Claude Code `UserPromptSubmit` hook: remind a linked session to keep its item up to date.
|
|
2
|
+
|
|
3
|
+
The link is the session id written in an item's Sessions section (by `/rumo new` or
|
|
4
|
+
`/rumo link`). A line ending in `| closed` (from park/unlink) no longer links, and after
|
|
5
|
+
`/clear` the id changes, so the reminder stops on its own.
|
|
6
|
+
|
|
7
|
+
The reminder carries the update rules, so they reach the agent exactly when they matter and
|
|
8
|
+
nothing has to be added to the user's global instructions.
|
|
9
|
+
Runs on every prompt: stdlib only, UTF-8 regardless of the console, silent on anything unexpected.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from rumo import config, items
|
|
18
|
+
|
|
19
|
+
RULES = (
|
|
20
|
+
"When you finish this response, if anything changed (step done, new step, change of direction, "
|
|
21
|
+
"blocker), update the item with the Edit tool: rewrite Left off (the next concrete action), check "
|
|
22
|
+
"or add Steps (`- [ ] [agent] ...` or `- [ ] [manual] ...` for work done outside the agent; work "
|
|
23
|
+
"with more than one stage and anything not done now becomes a step) and set `updated`. "
|
|
24
|
+
"Conversation-only replies need no update. Write content in the configured language (`rumo index`); "
|
|
25
|
+
"keep keys, sections, statuses and tags in English. Never set `done` without every step checked and "
|
|
26
|
+
"the user's confirmation. To stop updating from this session, append `| closed` to its line in Sessions."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
try:
|
|
32
|
+
payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))
|
|
33
|
+
session_id = payload.get("session_id", "")
|
|
34
|
+
except (ValueError, OSError, AttributeError):
|
|
35
|
+
return
|
|
36
|
+
if not session_id:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
linked = []
|
|
40
|
+
for item in items.list_items(config.load().folder):
|
|
41
|
+
if any(s.id == session_id and not s.closed for s in item.sessions):
|
|
42
|
+
linked.append(f"{item.path} (status: {item.status})")
|
|
43
|
+
|
|
44
|
+
if linked:
|
|
45
|
+
message = "This session is linked to rumo item(s): " + "; ".join(linked) + ". " + RULES + "\n"
|
|
46
|
+
sys.stdout.buffer.write(message.encode("utf-8"))
|
|
47
|
+
sys.stdout.flush()
|
rumo/items.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Items: one Markdown file per piece of work, in a single local folder.
|
|
2
|
+
|
|
3
|
+
There is no index or database. Every reader (the TUI, the hook, the agent) reads the
|
|
4
|
+
folder, and every write checks that the file did not change since it was read, so a
|
|
5
|
+
human in the TUI and an agent session never overwrite each other.
|
|
6
|
+
|
|
7
|
+
Stdlib only: the hook imports this module on every prompt.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import unicodedata
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import date, datetime, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
STATUSES = ("idea", "doing", "waiting", "paused", "done")
|
|
22
|
+
|
|
23
|
+
TIME_FORMAT = "%Y-%m-%d %H:%M"
|
|
24
|
+
PAST_WINDOW_DAYS = 14
|
|
25
|
+
|
|
26
|
+
_STEP_RE = re.compile(r"^- \[( |x|X)\] (?:\[(agent|manual)\] )?(.*)$")
|
|
27
|
+
_SECTION_RE = re.compile(r"^## (.+?)\s*$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WriteConflict(Exception):
|
|
31
|
+
"""The file changed on disk after it was read."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Step:
|
|
36
|
+
done: bool
|
|
37
|
+
kind: str # "agent", "manual" or "" when the line has no marker
|
|
38
|
+
text: str
|
|
39
|
+
line: int # index of the line in the file
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Session:
|
|
44
|
+
when: str
|
|
45
|
+
repo: str
|
|
46
|
+
id: str
|
|
47
|
+
folder: str
|
|
48
|
+
closed: bool
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def resume_command(self) -> str:
|
|
52
|
+
return self.resume_command_for(windows=os.name == "nt")
|
|
53
|
+
|
|
54
|
+
def resume_command_for(self, windows: bool) -> str:
|
|
55
|
+
"""A command that reopens the agent session from its original folder, in the user's shell."""
|
|
56
|
+
if windows: # PowerShell, where `&&` is missing before 7 and single quotes differ
|
|
57
|
+
return f'cd "{self.folder}"; claude --resume {self.id}'
|
|
58
|
+
return f"cd {shlex.quote(self.folder)} && claude --resume {self.id}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Item:
|
|
63
|
+
path: Path
|
|
64
|
+
mtime: float
|
|
65
|
+
meta: dict[str, str]
|
|
66
|
+
sections: dict[str, str]
|
|
67
|
+
steps: list[Step] = field(default_factory=list)
|
|
68
|
+
sessions: list[Session] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def slug(self) -> str:
|
|
72
|
+
return self.path.stem
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def title(self) -> str:
|
|
76
|
+
return self.meta.get("title") or self.slug
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def status(self) -> str:
|
|
80
|
+
status = self.meta.get("status", "")
|
|
81
|
+
return status if status in STATUSES else "doing"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def group(self) -> str:
|
|
85
|
+
return self.meta.get("group", "")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def tags(self) -> list[str]:
|
|
89
|
+
return _parse_list(self.meta.get("tags", ""))
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def done_at(self) -> str:
|
|
93
|
+
"""When the item was finished; hand-edited done items without `done:` fall back to `updated`."""
|
|
94
|
+
return self.meta.get("done") or self.meta.get("updated", "")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def next_step(self) -> Step | None:
|
|
98
|
+
return next((s for s in self.steps if not s.done), None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load(path: Path) -> Item:
|
|
102
|
+
mtime = path.stat().st_mtime
|
|
103
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
104
|
+
meta, body_start = _read_frontmatter(lines)
|
|
105
|
+
|
|
106
|
+
sections: dict[str, list[str]] = {}
|
|
107
|
+
steps: list[Step] = []
|
|
108
|
+
sessions: list[Session] = []
|
|
109
|
+
current = ""
|
|
110
|
+
for i in range(body_start, len(lines)):
|
|
111
|
+
line = lines[i]
|
|
112
|
+
if m := _SECTION_RE.match(line):
|
|
113
|
+
current = m.group(1).strip().lower()
|
|
114
|
+
sections[current] = []
|
|
115
|
+
continue
|
|
116
|
+
if not current:
|
|
117
|
+
continue
|
|
118
|
+
sections[current].append(line)
|
|
119
|
+
if current == "steps" and (m := _STEP_RE.match(line)):
|
|
120
|
+
steps.append(Step(m.group(1) != " ", m.group(2) or "", m.group(3), i))
|
|
121
|
+
elif current == "sessions" and line.startswith("- "):
|
|
122
|
+
parts = [p.strip() for p in line[2:].split("|")]
|
|
123
|
+
if len(parts) >= 4:
|
|
124
|
+
closed = len(parts) >= 5 and parts[4].lower() == "closed"
|
|
125
|
+
sessions.append(Session(parts[0], parts[1], parts[2], parts[3], closed))
|
|
126
|
+
|
|
127
|
+
return Item(
|
|
128
|
+
path=path,
|
|
129
|
+
mtime=mtime,
|
|
130
|
+
meta=meta,
|
|
131
|
+
sections={k: "\n".join(v).strip() for k, v in sections.items()},
|
|
132
|
+
steps=steps,
|
|
133
|
+
sessions=sessions,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Timeline:
|
|
139
|
+
past: list[Item] # done, oldest first so the list flows into Now
|
|
140
|
+
older: int # done items hidden by the Past window
|
|
141
|
+
now: list[Item] # doing, then waiting
|
|
142
|
+
later: list[Item] # paused, then ideas
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def timeline(all_items: list[Item], today: date | None = None, show_older: bool = False) -> Timeline:
|
|
146
|
+
cutoff = ((today or date.today()) - timedelta(days=PAST_WINDOW_DAYS)).isoformat()
|
|
147
|
+
done = sorted((i for i in all_items if i.status == "done"), key=lambda i: i.done_at)
|
|
148
|
+
past = done if show_older else [i for i in done if i.done_at[:10] >= cutoff]
|
|
149
|
+
|
|
150
|
+
def by_status(order: tuple[str, ...]) -> list[Item]:
|
|
151
|
+
chosen = sorted((i for i in all_items if i.status in order), key=lambda i: i.meta.get("updated", ""), reverse=True)
|
|
152
|
+
return sorted(chosen, key=lambda i: order.index(i.status)) # stable: keeps recency inside each status
|
|
153
|
+
|
|
154
|
+
return Timeline(past, len(done) - len(past), by_status(("doing", "waiting")), by_status(("paused", "idea")))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def index(all_items: list[Item]) -> str:
|
|
158
|
+
"""One line per item, for an agent to judge similarity without opening every file."""
|
|
159
|
+
lines = []
|
|
160
|
+
for item in all_items:
|
|
161
|
+
parts = [item.slug, item.status, item.title]
|
|
162
|
+
if item.group:
|
|
163
|
+
parts.append(f"group: {item.group}")
|
|
164
|
+
if item.tags:
|
|
165
|
+
parts.append(f"tags: {', '.join(item.tags)}")
|
|
166
|
+
if left_off := item.sections.get("left off", "").split("\n")[0].strip():
|
|
167
|
+
parts.append(f"left off: {left_off[:120]}")
|
|
168
|
+
lines.append(" | ".join(parts))
|
|
169
|
+
return "\n".join(lines)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def list_items(folder: Path) -> list[Item]:
|
|
173
|
+
found = []
|
|
174
|
+
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
|
175
|
+
try:
|
|
176
|
+
found.append(load(path))
|
|
177
|
+
except (OSError, UnicodeDecodeError):
|
|
178
|
+
continue # being written or unreadable: it shows up on the next read
|
|
179
|
+
return found
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def slugify(title: str) -> str:
|
|
183
|
+
ascii_title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
|
|
184
|
+
return re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-") or "item"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def create_idea(folder: Path, title: str, now: datetime | None = None) -> Path:
|
|
188
|
+
"""Create a small item with status `idea`; it grows into a full item when work starts."""
|
|
189
|
+
stamp = _stamp(now)
|
|
190
|
+
base = f"{stamp[:10]}-{slugify(title)}"
|
|
191
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
path, n = folder / f"{base}.md", 2
|
|
193
|
+
while path.exists():
|
|
194
|
+
path, n = folder / f"{base}-{n}.md", n + 1
|
|
195
|
+
text = f"---\ntitle: {title.strip()}\nstatus: idea\ntags: []\ncreated: {stamp}\nupdated: {stamp}\n---\n"
|
|
196
|
+
with path.open("x", encoding="utf-8") as f: # "x": never overwrite a file created meanwhile
|
|
197
|
+
f.write(text)
|
|
198
|
+
return path
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def set_status(item: Item, status: str, now: datetime | None = None) -> None:
|
|
202
|
+
if status not in STATUSES:
|
|
203
|
+
raise ValueError(f"unknown status: {status}")
|
|
204
|
+
stamp = _stamp(now)
|
|
205
|
+
|
|
206
|
+
def edit(lines: list[str], _: int) -> None:
|
|
207
|
+
_set_meta(lines, "status", status)
|
|
208
|
+
_set_meta(lines, "done", stamp if status == "done" else None)
|
|
209
|
+
|
|
210
|
+
_save(item, edit, stamp)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def set_group(item: Item, group: str | None, now: datetime | None = None) -> None:
|
|
214
|
+
_save(item, lambda lines, _: _set_meta(lines, "group", group or None), _stamp(now))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def set_tags(item: Item, tags: list[str], now: datetime | None = None) -> None:
|
|
218
|
+
_save(item, lambda lines, _: _set_meta(lines, "tags", f"[{', '.join(tags)}]"), _stamp(now))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def toggle_step(item: Item, step: Step, now: datetime | None = None) -> None:
|
|
222
|
+
def edit(lines: list[str], offset: int) -> None:
|
|
223
|
+
i = step.line + offset
|
|
224
|
+
m = _STEP_RE.match(lines[i]) if i < len(lines) else None
|
|
225
|
+
if not m or m.group(3) != step.text:
|
|
226
|
+
raise WriteConflict(item.path.name)
|
|
227
|
+
mark = " " if m.group(1) != " " else "x"
|
|
228
|
+
lines[i] = f"- [{mark}] " + lines[i][6:]
|
|
229
|
+
|
|
230
|
+
_save(item, edit, _stamp(now))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _stamp(now: datetime | None) -> str:
|
|
234
|
+
return (now or datetime.now()).strftime(TIME_FORMAT)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _save(item: Item, edit: Callable[[list[str], int], None], stamp: str) -> None:
|
|
238
|
+
"""Apply `edit` to the file's lines and bump `updated`, unless the file changed since it was read.
|
|
239
|
+
|
|
240
|
+
`edit` gets the offset to add to body line numbers recorded at load time: a file without
|
|
241
|
+
frontmatter gets one prepended, and an unclosed one gets its closing `---` inserted.
|
|
242
|
+
"""
|
|
243
|
+
if item.path.stat().st_mtime != item.mtime:
|
|
244
|
+
raise WriteConflict(item.path.name)
|
|
245
|
+
lines = item.path.read_text(encoding="utf-8").splitlines()
|
|
246
|
+
offset = 0
|
|
247
|
+
if not lines or lines[0].strip() != "---":
|
|
248
|
+
lines[:0] = ["---", "---"]
|
|
249
|
+
offset = 2
|
|
250
|
+
else:
|
|
251
|
+
end, closed = _frontmatter_end(lines)
|
|
252
|
+
if not closed:
|
|
253
|
+
lines.insert(end, "---")
|
|
254
|
+
offset = 1
|
|
255
|
+
edit(lines, offset)
|
|
256
|
+
_set_meta(lines, "updated", stamp)
|
|
257
|
+
tmp = item.path.with_name(item.path.name + ".tmp")
|
|
258
|
+
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
259
|
+
os.replace(tmp, item.path)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _frontmatter_end(lines: list[str]) -> tuple[int, bool]:
|
|
263
|
+
"""Index of the line that ends the frontmatter (which starts at line 0) and whether it is a real `---`.
|
|
264
|
+
|
|
265
|
+
A hand edit may drop the closing `---`; then the frontmatter ends at the first line that
|
|
266
|
+
cannot be a `key: value` (a blank line or a section heading).
|
|
267
|
+
"""
|
|
268
|
+
for i in range(1, len(lines)):
|
|
269
|
+
line = lines[i].strip()
|
|
270
|
+
if line == "---":
|
|
271
|
+
return i, True
|
|
272
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
273
|
+
return i, False
|
|
274
|
+
return len(lines), False
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _set_meta(lines: list[str], key: str, value: str | None) -> None:
|
|
278
|
+
"""Set (or remove, when value is None) a frontmatter key, keeping every other line."""
|
|
279
|
+
end, _ = _frontmatter_end(lines)
|
|
280
|
+
for i in range(1, end):
|
|
281
|
+
if lines[i].partition(":")[0].strip() == key:
|
|
282
|
+
if value is None:
|
|
283
|
+
del lines[i]
|
|
284
|
+
else:
|
|
285
|
+
lines[i] = f"{key}: {value}"
|
|
286
|
+
return
|
|
287
|
+
if value is not None:
|
|
288
|
+
lines.insert(end, f"{key}: {value}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_list(value: str) -> list[str]:
|
|
292
|
+
value = value.strip().removeprefix("[").removesuffix("]")
|
|
293
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _read_frontmatter(lines: list[str]) -> tuple[dict[str, str], int]:
|
|
297
|
+
if not lines or lines[0].strip() != "---":
|
|
298
|
+
return {}, 0
|
|
299
|
+
end, closed = _frontmatter_end(lines)
|
|
300
|
+
meta: dict[str, str] = {}
|
|
301
|
+
for line in lines[1:end]:
|
|
302
|
+
key, sep, value = line.partition(":")
|
|
303
|
+
if sep:
|
|
304
|
+
meta[key.strip()] = value.strip()
|
|
305
|
+
return meta, end + 1 if closed else end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rumo-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Your dev work, past, now and later: Markdown files, a terminal Timeline, and AI agents that keep it current
|
|
5
|
+
Project-URL: Homepage, https://github.com/pedrobuzzi/rumo
|
|
6
|
+
Project-URL: Issues, https://github.com/pedrobuzzi/rumo/issues
|
|
7
|
+
Author: Pedro Buzzi
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,claude-code,markdown,productivity,tasks,todo,tui,worklog
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: textual>=1.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# rumo
|
|
23
|
+
|
|
24
|
+
**Your work, past, now and later.** rumo is a local, file-based organizer for a developer's own
|
|
25
|
+
work. Every piece of work is a Markdown file on your machine. Run `rumo` to see it all as a
|
|
26
|
+
Timeline, and let your AI agent sessions keep it up to date while you work.
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
- **Now**: what you are doing or waiting on, with the next step of each item.
|
|
31
|
+
- **Past**: what you finished in the last two weeks (answers "what did I do?" for a standup).
|
|
32
|
+
- **Later**: ideas and paused work, so nothing you meant to do gets lost.
|
|
33
|
+
|
|
34
|
+
Items can belong to a **group** (an initiative): the title takes the group color and the footer
|
|
35
|
+
shows a legend. The interface is in English; the content (titles, notes, steps) is in your own
|
|
36
|
+
language.
|
|
37
|
+
|
|
38
|
+
Nothing leaves your machine. There is no server, no database and no sync with Jira or ClickUp
|
|
39
|
+
(ask your agent to update those if you want).
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
**1. The `rumo` command** (pick one):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install rumo-cli # macOS, Linux, Windows (https://docs.astral.sh/uv/)
|
|
47
|
+
pipx install rumo-cli # macOS, Linux, Windows
|
|
48
|
+
brew install pedrobuzzi/tap/rumo # macOS, Linux
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then run `rumo`. The first run creates `~/rumo/` for your items and a config file with your
|
|
52
|
+
system language.
|
|
53
|
+
|
|
54
|
+
**2. Your AI agent** (optional, but this is where rumo shines):
|
|
55
|
+
|
|
56
|
+
- **Claude Code**, full experience (the agent keeps linked items up to date):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
/plugin marketplace add pedrobuzzi/rumo
|
|
60
|
+
/plugin install rumo@rumo
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- **Codex, Gemini CLI, Cursor, GitHub Copilot and other agents** that read `SKILL.md` files,
|
|
64
|
+
simple mode (create items and ideas, list and search them; no automatic updates):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx skills add pedrobuzzi/rumo
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Update**: `uv tool upgrade rumo-cli` (or `pipx upgrade rumo-cli`, `brew upgrade rumo`), and
|
|
71
|
+
`/plugin marketplace update rumo` in Claude Code.
|
|
72
|
+
|
|
73
|
+
**Uninstall**: `/plugin uninstall rumo@rumo`, then `uv tool uninstall rumo-cli` (or
|
|
74
|
+
`pipx uninstall rumo-cli`, `brew uninstall rumo`). Your items in `~/rumo/` are never touched.
|
|
75
|
+
|
|
76
|
+
## Use
|
|
77
|
+
|
|
78
|
+
**In the terminal**: `rumo` opens the Timeline.
|
|
79
|
+
|
|
80
|
+
| Key | Action |
|
|
81
|
+
|---|---|
|
|
82
|
+
| ↑ ↓ | move through the Timeline |
|
|
83
|
+
| Tab | switch between Timeline, steps and sessions |
|
|
84
|
+
| `s` | change status (1-5 picks directly) |
|
|
85
|
+
| `g` | set the group, or create a new one with a color |
|
|
86
|
+
| `t` | toggle tags |
|
|
87
|
+
| `n` | capture a new idea |
|
|
88
|
+
| `e` | open the item in `$EDITOR` |
|
|
89
|
+
| space | check or uncheck the selected step |
|
|
90
|
+
| `c` | copy the command that resumes the selected agent session |
|
|
91
|
+
| `/` | filter by title, tag, group or text (Esc clears) |
|
|
92
|
+
| `q` | quit |
|
|
93
|
+
|
|
94
|
+
**In an agent session**: type `/rumo` for a menu that fits the moment, or go straight to:
|
|
95
|
+
|
|
96
|
+
| Command | What it does |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `/rumo new` | Track this session's work as an item. Looks for a similar idea first and offers to promote it. |
|
|
99
|
+
| `/rumo idea <text>` | Note something for later without leaving the topic. |
|
|
100
|
+
| `/rumo link [item]` | Continue an existing item in this session. |
|
|
101
|
+
| `/rumo now` | List what is in progress, with the next step of each. |
|
|
102
|
+
| `/rumo find <text>` | Search past, current and future items. |
|
|
103
|
+
| `/rumo status` | Show where you left off on the linked item. |
|
|
104
|
+
| `/rumo step <text>` | Add a step to the linked item. |
|
|
105
|
+
| `/rumo park [reason]` | Pause the item with an accurate "Left off" and stop updating it here. |
|
|
106
|
+
| `/rumo done` | Finish the item (checks the steps, asks first). |
|
|
107
|
+
| `/rumo unlink` | Stop updating the item from this session. |
|
|
108
|
+
|
|
109
|
+
`link`, `status`, `step`, `park` and `unlink` need Claude Code with the plugin, because they rely on
|
|
110
|
+
its session id and hook. Other agents get the rest.
|
|
111
|
+
|
|
112
|
+
When you create an item or an idea, the agent checks whether it belongs to an existing group. If
|
|
113
|
+
other ungrouped items look like the same initiative, it suggests a new group (name and color) and
|
|
114
|
+
applies it only if you agree.
|
|
115
|
+
|
|
116
|
+
While a session is linked, the agent updates the item after every response in which something
|
|
117
|
+
changed. `/clear` starts a new, unlinked session; `claude --resume <id>` picks the old one up again.
|
|
118
|
+
|
|
119
|
+
## Item format
|
|
120
|
+
|
|
121
|
+
```markdown
|
|
122
|
+
---
|
|
123
|
+
title: Redirect after logout
|
|
124
|
+
status: doing
|
|
125
|
+
group: auth
|
|
126
|
+
tags: [bug, frontend]
|
|
127
|
+
created: 2026-10-11 10:08
|
|
128
|
+
updated: 2026-10-11 16:30
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Left off
|
|
132
|
+
Point the redirect to the home page; the e2e test is still missing.
|
|
133
|
+
|
|
134
|
+
## Context
|
|
135
|
+
Users land on a protected page after logging out.
|
|
136
|
+
|
|
137
|
+
## Steps
|
|
138
|
+
- [x] [agent] Fix the logout handler
|
|
139
|
+
- [ ] [manual] Ask for a PR review
|
|
140
|
+
- [ ] [agent] e2e test for logout
|
|
141
|
+
|
|
142
|
+
## Sessions
|
|
143
|
+
- 2026-10-11 10:08 | web-app (main) | 7134aad0-4ba6-4583-9503-c08e98d28270 | /Users/you/repo/web-app
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- Statuses: `idea`, `doing`, `waiting`, `paused`, `done`. `done: <date>` is set when an item is finished.
|
|
147
|
+
- Steps are `[agent]` (the AI can do it) or `[manual]` (you or someone else does it).
|
|
148
|
+
- An idea can be just the frontmatter; it grows when you start working on it.
|
|
149
|
+
- A session line ending in `| closed` no longer updates the item.
|
|
150
|
+
|
|
151
|
+
## Config
|
|
152
|
+
|
|
153
|
+
`config.toml` lives in `~/.config/rumo/` on macOS and Linux (or `$XDG_CONFIG_HOME/rumo/`) and in
|
|
154
|
+
`%APPDATA%\rumo\` on Windows. `rumo index` prints its path.
|
|
155
|
+
|
|
156
|
+
```toml
|
|
157
|
+
folder = "~/rumo"
|
|
158
|
+
language = "pt-BR" # language of the content the agent writes; defaults to your system language
|
|
159
|
+
tags = ["bug", "feature", "refactor", "research", "spike", "review",
|
|
160
|
+
"infra", "deploy", "meeting", "docs", "support", "tech-debt"]
|
|
161
|
+
|
|
162
|
+
[groups.auth]
|
|
163
|
+
name = "Auth migration"
|
|
164
|
+
color = "blue" # blue, green, yellow, orange, red, magenta, purple, cyan
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`RUMO_DIR` and `RUMO_CONFIG` override the folder and the config path.
|
|
168
|
+
|
|
169
|
+
## Contributing
|
|
170
|
+
|
|
171
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the layout, tests, trying the plugin locally and how
|
|
172
|
+
releases work.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
rumo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
rumo/app.py,sha256=kmPIdwlb1JLdvbgzw9B6QmWLTAKGizs914QyLiP1kIQ,19713
|
|
3
|
+
rumo/cli.py,sha256=ABnKtx_lSKdrGMHs6_15f_44heJmB_trf-JW3YEBwuY,2361
|
|
4
|
+
rumo/config.py,sha256=u4szi5GT0Sw1JhaSytaSfglMGMMrdVG6kGfExKrOLn8,5182
|
|
5
|
+
rumo/hook.py,sha256=6iXvUc3xtAUaM0VVZ78e_A4cZVnATu9hIyEInkeJxQw,2082
|
|
6
|
+
rumo/items.py,sha256=PqcvrFO3x-yuJMAv3hitEYjw86LaEzbmlJo2mjg5Lo4,10722
|
|
7
|
+
rumo_cli-0.1.0.dist-info/METADATA,sha256=dXYdMRgXERaw1AdM8pJGRksHZ-x_ULr8Dy7z4i493Do,6554
|
|
8
|
+
rumo_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
rumo_cli-0.1.0.dist-info/entry_points.txt,sha256=dgLTBmdLBqXNofuG0FF6vxkht27rMqF4R5Kg7R_Lve8,39
|
|
10
|
+
rumo_cli-0.1.0.dist-info/licenses/LICENSE,sha256=NJ_6PjU8Tz0ohCibbfwKuUgfqouLxiLbET5q27TyYfQ,1074
|
|
11
|
+
rumo_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pedro Buzzi Filho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|