diff --git a/AGENTS.md b/AGENTS.md index 49c2028..b62e7dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,11 @@ channels, members, etc. ## Key Files -- **`slack_cli/__main__.py`** — Canonical CLI source. Entry point is `main()`, exposed as the `slack-cli` console script. +- **`slack_cli/__main__.py`** — Canonical CLI source. Entry point is `main()`, exposed as the `slack-cli` console script. Supports three output formats via `-f/--format`: + - `pretty` (default) — ANSI/tree layout with URLs, for humans. + - `llm` — token-efficient text (no ANSI/URLs, grouped by channel+date, threads indented). + - `jsonl` — one JSON object per message; includes raw `ts`, `channel_id`, `user_id`. + Renderers are `render_pretty` / `render_llm` / `render_jsonl`; `cmd_messages` builds the display list once and dispatches. - **`pyproject.toml`** — Python packaging metadata. Declares `slack-cli` console script entry point and `dfindexeddb` dependency. - **`docs/indexeddb-format.md`** — Documents the on-disk format: LevelDB layer, IndexedDB databases, Blink value encoding, and the full Redux state schema with field-level detail. - **`scripts/analyze_structure.py`** — Introspects the live IndexedDB and dumps database/object-store/record-type info plus Redux state key schemas. Re-run this when the data format changes and update the docs accordingly. diff --git a/slack_cli/__main__.py b/slack_cli/__main__.py index e500111..d1dbfe1 100644 --- a/slack_cli/__main__.py +++ b/slack_cli/__main__.py @@ -218,6 +218,7 @@ def cmd_messages( count: int, unread_only: bool = False, since: datetime | None = None, + output_format: str = "pretty", ): """Print Messages From Slack State""" messages_store = state.get("messages", {}) @@ -389,6 +390,14 @@ def cmd_messages( for reply in replies: display.append((reply, 1)) + # Select Format-Specific Renderer + renderers = { + "pretty": render_pretty, + "llm": render_llm, + "jsonl": render_jsonl, + } + renderer = renderers[output_format] + # Print Last N Messages (Count depth=0 Entries Only) if len(display) > 0: # Walk Backwards to Find the Cutoff That Includes `count` Roots @@ -405,6 +414,11 @@ def cmd_messages( else: visible = [] + renderer(visible, display, domain, unread_only, since) + + +def render_pretty(visible, display, domain, unread_only, since): + """Human-Friendly Terminal Output — ANSI, Rules, Tree Glyphs""" # ANSI Palette dim = "\033[90m" ts_color = "\033[90m" @@ -525,6 +539,134 @@ def cmd_messages( print(f"--- Showing {root_count} of {total_roots} {label} ---") +def render_llm(visible, display, domain, unread_only, since): + """Token-Efficient Text Output for LLM Consumption + + Groups by channel, then by date. Drops ANSI, URLs, tree glyphs, and + repeated channel/date headers. Thread replies use 2-space indent. + """ + # Group Visible Rows Into (Channel, Root+Replies) Blocks + # Walk the flat `visible` list, pairing each depth=0 row with the + # depth=1 rows that follow it until the next root. + blocks: list[tuple[str, dict, list[dict]]] = [] + current_root: dict | None = None + current_replies: list[dict] = [] + current_channel: str = "" + for msg, depth in visible: + if depth == 0: + if current_root is not None: + blocks.append((current_channel, current_root, current_replies)) + current_root = msg + current_replies = [] + current_channel = msg.get("channel", "") + else: + current_replies.append(msg) + if current_root is not None: + blocks.append((current_channel, current_root, current_replies)) + + # Emit Blocks — Channel Header on Change, Date Header on Change + last_channel: str | None = None + last_date: str | None = None + + def fmt_row(msg: dict, indent: str = "") -> str: + if msg.get("ellipsis"): + skipped = msg.get("skipped") + if skipped: + inner = f"[… {skipped} earlier repl{'y' if skipped == 1 else 'ies'}]" + else: + inner = "[… older replies]" + return f"{indent}{inner}" + hm = msg["dt"].strftime("%H:%M") + subtype = f" [{msg['subtype']}]" if msg.get("subtype") else "" + user = msg.get("user", "?") + text = msg.get("text", "") + # Inline Single-Line Text; Indent Continuation Lines + lines = text.split("\n") if text else [""] + head = f"{indent}{hm} {user}{subtype}:" + if lines == [""]: + return head + if len(lines) == 1: + return f"{head} {lines[0]}" + cont_indent = indent + " " + return head + " " + lines[0] + "".join(f"\n{cont_indent}{ln}" for ln in lines[1:]) + + for channel, root, replies in blocks: + # Channel Header on Change + if channel != last_channel: + if last_channel is not None: + print() + print(f"== #{channel} ==") + last_channel = channel + last_date = None + + # Date Header on Change (Per Channel) + root_date = root["dt"].strftime("%Y-%m-%d") + if root_date != last_date: + print(root_date) + last_date = root_date + + # Root Row — Annotate Orphans and Thread Reply Counts Inline + if root.get("is_orphan"): + hm = root["dt"].strftime("%H:%M") + print(f" {hm} (orphan — parent uncached):") + else: + suffix = "" + if root.get("is_thread_parent"): + # Count Actual Shown Replies (Excluding Ellipsis Marker) + n = sum(1 for r in replies if not r.get("ellipsis")) + suffix_note = f" thread ({n}):" + else: + suffix_note = "" + # Splice Thread Note Before the Colon Added by fmt_row + row = fmt_row(root, indent=" ") + if suffix_note and row.endswith(":"): + row = row[:-1] + suffix_note + elif suffix_note and ": " in row: + # Multi-Line or Inline-Text Case — Insert Before First ": " + head, rest = row.split(": ", 1) + row = head + suffix_note + " " + rest + print(row) + + # Replies — 4-Space Indent + for reply in replies: + print(fmt_row(reply, indent=" ")) + + # Footer — Compact Summary + root_count = sum(1 for _, d in visible if d == 0) + total_roots = sum(1 for _, d in display if d == 0) + label = "unread" if unread_only else "msgs" + since_note = f" since={since.strftime('%Y-%m-%d %H:%M')}" if since else "" + print(f"-- {root_count}/{total_roots} {label}{since_note} --") + + +def render_jsonl(visible, display, domain, unread_only, since): + """One JSON Object Per Message — Structured Consumption""" + for msg, depth in visible: + if msg.get("ellipsis"): + obj = { + "type": "ellipsis", + "depth": depth, + "skipped": msg.get("skipped"), + } + else: + obj = { + "type": "message", + "depth": depth, + "channel": msg.get("channel"), + "channel_id": msg.get("channel_id"), + "ts": msg.get("ts"), + "thread_ts": msg.get("thread_ts"), + "datetime": msg["dt"].isoformat() if msg.get("dt") else None, + "user": msg.get("user"), + "user_id": msg.get("user_id"), + "subtype": msg.get("subtype") or None, + "text": msg.get("text"), + "is_thread_parent": msg.get("is_thread_parent", False), + "is_orphan": msg.get("is_orphan", False), + } + print(json.dumps(obj, ensure_ascii=False, default=str)) + + def cmd_channels(state: dict): """List Channels With Message Counts""" messages_store = state.get("messages", {}) @@ -591,6 +733,10 @@ def main(): "--dump", nargs="?", const="slack_state.json", help="Dump full state to JSON file" ) + parser.add_argument( + "-f", "--format", choices=["pretty", "llm", "jsonl"], default="pretty", + help="Output format: pretty (default, ANSI/tree), llm (token-efficient text), jsonl (one JSON per message)" + ) args = parser.parse_args() # Find and Decode the Blob @@ -613,7 +759,7 @@ def main(): cmd_channels(state) else: cmd_messages(state, args.channels, args.exclude_channels, - args.count, args.unread, args.since) + args.count, args.unread, args.since, args.format) if __name__ == "__main__":