Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions .agents/skills/folo-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
---
name: folo-cli
description: Use Folo CLI to manage RSS subscriptions, lists, timeline entries, unread state, collections, feed discovery, and OPML imports or exports. Trigger when the user asks to inspect or change content in their Folo account.
---

# Folo CLI Skill

Adapted for Codex from the
[official Folo CLI skill](https://github.com/RSSNext/Folo/blob/main/apps/cli/skill.md).

## Trigger Conditions

Use this skill when a user asks to:

- Manage RSS subscriptions
- Browse timeline entries
- Read entry details or readability content
- Mark entries as read/unread
- Search feeds/lists or trending sources
- Import/export OPML
- Check unread counts

## Preconditions

1. Node.js and npm are installed so the CLI can be executed with `npx`.
2. Authentication is configured:
- `npx --yes folocli@latest login` (recommended, opens browser and auto-logins)
- or let 1Password inject `FOLO_TOKEN` into the authorized process at runtime.

Never request, reveal, print, or persist a Folo session token. Do not place it in
repository files, shell history, command arguments, logs, or chat. Follow the
repository's 1Password policy whenever token-based authentication is necessary.

## Execution Policy

- Prefer `npx --yes folocli@latest ...` for all agent runs.
- Do not require `npm install -g folocli`.
- No separate update preflight is needed. Using `folocli@latest` is the update strategy.
- If a user already has a working global `folo` binary, it is acceptable, but `npx --yes folocli@latest` remains the recommended default in docs and automation.
- Before mutations, inspect the exact target and confirm that it matches the user's request.
- Treat bulk removal, list deletion, OPML import, and batch mark-read operations as consequential changes; summarize their scope before executing them.

## Output Contract

Default output is JSON with a stable envelope:

```json
{
"ok": true,
"data": {},
"error": null
}
```

Errors return:

```json
{
"ok": false,
"data": null,
"error": {
"code": "UNAUTHORIZED",
"message": "Token is invalid or expired."
}
}
```

You can switch output mode:

- `--format json` (default)
- `--format table`
- `--format plain`

## Core Workflows

### 1. Timeline Reading

1. Fetch timeline:
- `npx --yes folocli@latest timeline --limit 10`
2. Get entry detail:
- `npx --yes folocli@latest entry get <entryId>`
3. Get readability content:
- `npx --yes folocli@latest entry read <entryId>`

### 2. Subscription Management

1. Discover:
- `npx --yes folocli@latest search discover <keyword>`
2. Add subscription:
- `npx --yes folocli@latest subscription add --feed <url>`
- or `npx --yes folocli@latest subscription add --list <listId>`
3. List subscriptions:
- `npx --yes folocli@latest subscription list`

### 3. Unread Processing

1. Check unread total:
- `npx --yes folocli@latest unread count`
2. List unread subscriptions:
- `npx --yes folocli@latest unread list`
3. Read unread entries:
- `npx --yes folocli@latest timeline --unread-only --limit 20`
4. Mark read:
- `npx --yes folocli@latest entry mark-read <entryId>`
- or batch: `npx --yes folocli@latest entry mark-all-read --view articles`

### 4. Collection Operations

- Add: `npx --yes folocli@latest collection add <entryId>`
- Remove: `npx --yes folocli@latest collection remove <entryId>`
- List: `npx --yes folocli@latest collection list --limit 20`

### 5. OPML Import / Export

- Export:
- `npx --yes folocli@latest opml export --output backup.opml`
- Import:
- `npx --yes folocli@latest opml import feeds.opml`

## Pagination Pattern

`npx --yes folocli@latest timeline` returns:

- `entries`
- `nextCursor`
- `hasNext`

Loop until `hasNext` is `false`:

1. `npx --yes folocli@latest timeline --limit 20`
2. Read `nextCursor`
3. `npx --yes folocli@latest timeline --limit 20 --cursor <nextCursor>`
4. Repeat

## Command Reference

- `npx --yes folocli@latest login [--timeout <seconds>]`
- `npx --yes folocli@latest logout`
- `npx --yes folocli@latest whoami`
- `npx --yes folocli@latest auth login [--timeout <seconds>]`
- `npx --yes folocli@latest auth logout`
- `npx --yes folocli@latest auth whoami`

- `npx --yes folocli@latest timeline [--view <type>] [--limit <n>] [--unread-only] [--cursor <datetime>]`
- `npx --yes folocli@latest timeline --feed <feedId> [--limit <n>] [--cursor <datetime>]`
- `npx --yes folocli@latest timeline --list <listId> [--limit <n>] [--cursor <datetime>]`
- `npx --yes folocli@latest timeline --category <name> [--view <type>] [--limit <n>]`

- `npx --yes folocli@latest subscription list [--view <type>] [--category <name>]`
- `npx --yes folocli@latest subscription add --feed <url> [--category <name>] [--view <type>] [--private]`
- `npx --yes folocli@latest subscription add --list <listId> [--category <name>] [--view <type>]`
- `npx --yes folocli@latest subscription remove <id> [--target feed|list|url]`
- `npx --yes folocli@latest subscription update <id> [--target feed|list] [--category <name>] [--title <title>] [--view <type>] [--private|--public]`

- `npx --yes folocli@latest entry get <entryId>`
- `npx --yes folocli@latest entry read <entryId>`
- `npx --yes folocli@latest entry mark-read <entryId>`
- `npx --yes folocli@latest entry mark-unread <entryId>`
- `npx --yes folocli@latest entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]`

- `npx --yes folocli@latest feed get <feedId|feedUrl>`
- `npx --yes folocli@latest feed refresh <feedId>`
- `npx --yes folocli@latest feed analytics <feedId>`

- `npx --yes folocli@latest list ls`
- `npx --yes folocli@latest list get <listId>`
- `npx --yes folocli@latest list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]`
- `npx --yes folocli@latest list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]`
- `npx --yes folocli@latest list delete <listId>`
- `npx --yes folocli@latest list add-feed <listId> --feed <feedId>`
- `npx --yes folocli@latest list remove-feed <listId> --feed <feedId>`

- `npx --yes folocli@latest search discover <keyword> [--type feeds|lists]`
- `npx --yes folocli@latest search rsshub <keyword> [--lang <lang>]`
- `npx --yes folocli@latest search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]`

- `npx --yes folocli@latest collection list [--limit <n>] [--cursor <datetime>]`
- `npx --yes folocli@latest collection add <entryId> [--view <type>]`
- `npx --yes folocli@latest collection remove <entryId>`

- `npx --yes folocli@latest opml export [--output <file>]`
- `npx --yes folocli@latest opml import <file> [--items <url1,url2,...>]`

- `npx --yes folocli@latest unread count`
- `npx --yes folocli@latest unread list [--view <type>]`

## Error Recovery

- `UNAUTHORIZED`
- Re-login with `npx --yes folocli@latest login`.
- If token-based login is required, use 1Password runtime injection for `FOLO_TOKEN`.
- `HTTP_4xx` / `HTTP_5xx`
- Retry with `--verbose` for request details, while ensuring no authentication material is included in captured output.
- Verify `--api-url` if using a non-default endpoint.
- `INVALID_ARGUMENT`
- Run `npx --yes folocli@latest <command> --help` to inspect accepted options.
105 changes: 91 additions & 14 deletions .github/scripts/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,20 @@
from __future__ import annotations

import argparse
import json
import re
import sys
import tomllib
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Callable

SENTINEL_START = "<!-- AUDIT:START -->"
SENTINEL_END = "<!-- AUDIT:END -->"
LIMITS_PATH = Path("audit/limits.toml")
FOLO_SNAPSHOT_PATH = Path("audit/folo-snapshot.json")
FOLO_SNAPSHOT_MAX_AGE = timedelta(days=35)

USD_TO_CNY = 7.2 # rough April 2026 rate

Expand Down Expand Up @@ -195,6 +199,15 @@ def render_full(cats: list[Category]) -> str:
"h3_active": lambda cats, h3: sum(c.active for c in cats if c.h3 == h3),
}

FOLO_METRICS: dict[str, tuple[str, ...]] = {
"folo_attention_minutes": ("attention", "budgeted_minutes_per_week"),
"folo_core_count": ("lanes", "core", "source_count"),
"folo_changelog_count": ("lanes", "changelog", "source_count"),
"folo_uncategorized_count": ("totals", "uncategorized_sources"),
"folo_abnormal_count": ("totals", "abnormal_sources"),
"folo_core_max_source_share": ("lanes", "core", "max_source_share_percent"),
}


def load_limits(path: Path) -> list[dict[str, Any]]:
if not path.exists():
Expand All @@ -204,8 +217,50 @@ def load_limits(path: Path) -> list[dict[str, Any]]:
return data.get("items", [])


def compute_metric(item: dict[str, Any], cats: list[Category]) -> float:
def load_folo_snapshot(
path: Path = FOLO_SNAPSHOT_PATH,
*,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Load a fresh Folo snapshot; missing, invalid, or stale means unavailable."""
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
generated = datetime.fromisoformat(
str(data["generated_at"]).replace("Z", "+00:00")
).astimezone(UTC)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
return None
current = (now or datetime.now(UTC)).astimezone(UTC)
if generated > current + timedelta(minutes=5):
return None
if current - generated > FOLO_SNAPSHOT_MAX_AGE:
return None
return data


def _lookup_number(data: dict[str, Any], path: tuple[str, ...]) -> float | None:
value: Any = data
for key in path:
if not isinstance(value, dict) or key not in value:
return None
value = value[key]
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)


def compute_metric(
item: dict[str, Any],
cats: list[Category],
folo_snapshot: dict[str, Any] | None = None,
) -> float | None:
name = item["metric"]
if name in FOLO_METRICS:
if folo_snapshot is None:
return None
return _lookup_number(folo_snapshot, FOLO_METRICS[name])
fn = METRICS.get(name)
if fn is None:
raise ValueError(f"unknown metric: {name!r} in item {item.get('name', '?')}")
Expand All @@ -214,22 +269,39 @@ def compute_metric(item: dict[str, Any], cats: list[Category]) -> float:
return fn(cats)


def format_value(value: float) -> str:
return str(round(value))


def status_cell(current: float, limit: float | None) -> str:
def format_value(value: float | None, unit: str | None = None) -> str:
if value is None:
return "N/A"
rounded = str(int(value)) if value.is_integer() else f"{value:.1f}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid Python 3.12-only integer formatting

On Python 3.11, which is also explicitly selected elsewhere in this repository, the non-Folo count metrics reach this function as int values and int has no is_integer() method. As a result, python3 .github/scripts/audit.py --update README.md crashes while rendering the first integer-valued row, which also breaks the documented pre-commit hook on Python 3.11; normalize the value to float or test it without calling this method directly.

AGENTS.md reference: AGENTS.md:L36-L42

Useful? React with 👍 / 👎.

if unit == "minutes":
return f"{rounded} 分钟"
if unit == "percent":
return f"{rounded}%"
return rounded


def status_cell(
current: float | None,
limit: float | None,
unit: str | None = None,
) -> str:
if current is None:
return "⚪ N/A"
if limit is None:
return "—"
diff = current - limit
if diff > 0:
return f"🚨 超 {format_value(diff)}"
return f"🚨 超 {format_value(diff, unit)}"
if diff == 0:
return "🟡 持平"
return f"✅ 留白 {format_value(-diff)}"
return f"✅ 留白 {format_value(-diff, unit)}"


def render_inline(cats: list[Category], limits: list[dict[str, Any]]) -> str:
def render_inline(
cats: list[Category],
limits: list[dict[str, Any]],
folo_snapshot: dict[str, Any] | None = None,
) -> str:
"""Compact limits dashboard for embedding in README.md between sentinels."""
out: list[str] = []
out.append("### 📊 体量盘点")
Expand All @@ -238,6 +310,10 @@ def render_inline(cats: list[Category], limits: list[dict[str, Any]]) -> str:
"> 由 [.github/scripts/audit.py](.github/scripts/audit.py) "
"依据 [audit/limits.toml](audit/limits.toml) 自动生成,pre-commit hook 刷新。"
)
if folo_snapshot is None and any(
item.get("metric") in FOLO_METRICS for item in limits
):
out.append("> Folo 快照缺失或已超过 35 天;相关指标显示 `N/A`,不会按 0 处理。")
out.append("")

if not limits:
Expand All @@ -247,13 +323,14 @@ def render_inline(cats: list[Category], limits: list[dict[str, Any]]) -> str:
out.append("| # | 维度 | 当前 | 上限 | 状态 | 备注 |")
out.append("|---|------|------|------|------|------|")
for i, item in enumerate(limits, 1):
current = compute_metric(item, cats)
current = compute_metric(item, cats, folo_snapshot)
limit = item.get("limit")
limit_cell = format_value(float(limit)) if limit is not None else "—"
status = status_cell(current, float(limit) if limit is not None else None)
unit = item.get("unit")
limit_cell = format_value(float(limit), unit) if limit is not None else "—"
status = status_cell(current, float(limit) if limit is not None else None, unit)
note = item.get("note", "")
out.append(
f"| {i} | {item['name']} | {format_value(current)} | "
f"| {i} | {item['name']} | {format_value(current, unit)} | "
f"{limit_cell} | {status} | {note} |"
)

Expand All @@ -272,7 +349,7 @@ def update_in_place(readme: Path, limits_path: Path) -> bool:

cats = audit(readme)
limits = load_limits(limits_path)
inline = render_inline(cats, limits)
inline = render_inline(cats, limits, load_folo_snapshot())
new_block = f"{SENTINEL_START}\n\n{inline}\n\n{SENTINEL_END}"

pattern = re.compile(
Expand Down
Loading
Loading