feat(cli): add --format flag for pretty, llm, and jsonl output
This commit is contained in:
@@ -24,7 +24,11 @@ channels, members, etc.
|
|||||||
|
|
||||||
## Key Files
|
## 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.
|
- **`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.
|
- **`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.
|
- **`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.
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ def cmd_messages(
|
|||||||
count: int,
|
count: int,
|
||||||
unread_only: bool = False,
|
unread_only: bool = False,
|
||||||
since: datetime | None = None,
|
since: datetime | None = None,
|
||||||
|
output_format: str = "pretty",
|
||||||
):
|
):
|
||||||
"""Print Messages From Slack State"""
|
"""Print Messages From Slack State"""
|
||||||
messages_store = state.get("messages", {})
|
messages_store = state.get("messages", {})
|
||||||
@@ -389,6 +390,14 @@ def cmd_messages(
|
|||||||
for reply in replies:
|
for reply in replies:
|
||||||
display.append((reply, 1))
|
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)
|
# Print Last N Messages (Count depth=0 Entries Only)
|
||||||
if len(display) > 0:
|
if len(display) > 0:
|
||||||
# Walk Backwards to Find the Cutoff That Includes `count` Roots
|
# Walk Backwards to Find the Cutoff That Includes `count` Roots
|
||||||
@@ -405,6 +414,11 @@ def cmd_messages(
|
|||||||
else:
|
else:
|
||||||
visible = []
|
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
|
# ANSI Palette
|
||||||
dim = "\033[90m"
|
dim = "\033[90m"
|
||||||
ts_color = "\033[90m"
|
ts_color = "\033[90m"
|
||||||
@@ -525,6 +539,134 @@ def cmd_messages(
|
|||||||
print(f"--- Showing {root_count} of {total_roots} {label} ---")
|
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):
|
def cmd_channels(state: dict):
|
||||||
"""List Channels With Message Counts"""
|
"""List Channels With Message Counts"""
|
||||||
messages_store = state.get("messages", {})
|
messages_store = state.get("messages", {})
|
||||||
@@ -591,6 +733,10 @@ def main():
|
|||||||
"--dump", nargs="?", const="slack_state.json",
|
"--dump", nargs="?", const="slack_state.json",
|
||||||
help="Dump full state to JSON file"
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Find and Decode the Blob
|
# Find and Decode the Blob
|
||||||
@@ -613,7 +759,7 @@ def main():
|
|||||||
cmd_channels(state)
|
cmd_channels(state)
|
||||||
else:
|
else:
|
||||||
cmd_messages(state, args.channels, args.exclude_channels,
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user