fanfou 0.1.0

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.
package/dist/config.js ADDED
@@ -0,0 +1,81 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
4
+ // Default consumer credentials shared with the existing Fanfou clients.
5
+ export const DEFAULT_CONSUMER_KEY = "175d9183cc2a7298abed2ca2280daa2a";
6
+ export const DEFAULT_CONSUMER_SECRET = "7c541eab37d4a8be432119c3fcf5c3a0";
7
+ export function configDir() {
8
+ if (process.env.FANFOU_CONFIG_DIR)
9
+ return process.env.FANFOU_CONFIG_DIR;
10
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
11
+ return join(base, "fanfou");
12
+ }
13
+ function configPath() {
14
+ return join(configDir(), "config.json");
15
+ }
16
+ function emptyConfig() {
17
+ return { currentProfile: "default", profiles: {} };
18
+ }
19
+ export function loadConfig() {
20
+ const path = configPath();
21
+ if (!existsSync(path))
22
+ return emptyConfig();
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
25
+ if (!parsed.profiles)
26
+ parsed.profiles = {};
27
+ if (!parsed.currentProfile)
28
+ parsed.currentProfile = "default";
29
+ return parsed;
30
+ }
31
+ catch {
32
+ return emptyConfig();
33
+ }
34
+ }
35
+ export function saveConfig(config) {
36
+ const dir = configDir();
37
+ mkdirSync(dir, { recursive: true });
38
+ const path = configPath();
39
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
40
+ try {
41
+ chmodSync(path, 0o600);
42
+ }
43
+ catch {
44
+ /* best effort */
45
+ }
46
+ }
47
+ export function resolveProfileName(explicit) {
48
+ return explicit || process.env.FANFOU_PROFILE || loadConfig().currentProfile || "default";
49
+ }
50
+ /** Merge stored profile with environment overrides; env always wins. */
51
+ export function resolveProfile(explicit) {
52
+ const config = loadConfig();
53
+ const name = resolveProfileName(explicit);
54
+ const stored = config.profiles[name] ?? {};
55
+ const profile = {
56
+ consumerKey: process.env.FANFOU_CONSUMER_KEY || stored.consumerKey || DEFAULT_CONSUMER_KEY,
57
+ consumerSecret: process.env.FANFOU_CONSUMER_SECRET || stored.consumerSecret || DEFAULT_CONSUMER_SECRET,
58
+ token: process.env.FANFOU_OAUTH_TOKEN || stored.token,
59
+ tokenSecret: process.env.FANFOU_OAUTH_TOKEN_SECRET || stored.tokenSecret,
60
+ user: stored.user,
61
+ };
62
+ return { name, profile };
63
+ }
64
+ export function saveProfile(name, profile, setCurrent = true) {
65
+ const config = loadConfig();
66
+ config.profiles[name] = { ...config.profiles[name], ...profile };
67
+ if (setCurrent)
68
+ config.currentProfile = name;
69
+ saveConfig(config);
70
+ }
71
+ export function clearProfile(name) {
72
+ const config = loadConfig();
73
+ delete config.profiles[name];
74
+ if (config.currentProfile === name)
75
+ config.currentProfile = "default";
76
+ saveConfig(config);
77
+ }
78
+ export function listProfiles() {
79
+ const config = loadConfig();
80
+ return { current: config.currentProfile, names: Object.keys(config.profiles) };
81
+ }
package/dist/index.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs, flagBool, flagString } from "./args.js";
3
+ import { buildRootCommand } from "./commands.js";
4
+ import { UsageError } from "./commands.js";
5
+ import { FanfouClient, FanfouHttpError } from "./client.js";
6
+ import { resolveProfile } from "./config.js";
7
+ import { collectBooleanFlags, commandSchema, renderHelpText, resolveCommand, } from "./registry.js";
8
+ import { isOutputFormat, printData, printError } from "./output.js";
9
+ const VERSION = "0.1.0";
10
+ function resolveFormat(flags) {
11
+ if (flagBool(flags, "json"))
12
+ return "json";
13
+ const explicit = flagString(flags, "format");
14
+ if (explicit) {
15
+ if (isOutputFormat(explicit))
16
+ return explicit;
17
+ throw new UsageError(`未知的输出格式:${explicit}(可选 json|ndjson|table|raw)`);
18
+ }
19
+ return "json";
20
+ }
21
+ async function main() {
22
+ const argv = process.argv.slice(2);
23
+ const root = buildRootCommand();
24
+ const booleanFlags = collectBooleanFlags(root);
25
+ const { positionals, flags } = parseArgs(argv, booleanFlags);
26
+ if (flagBool(flags, "version")) {
27
+ process.stdout.write(VERSION + "\n");
28
+ return 0;
29
+ }
30
+ let format;
31
+ try {
32
+ format = resolveFormat(flags);
33
+ }
34
+ catch (err) {
35
+ printError({ type: "usage", message: err.message }, "json");
36
+ return 2;
37
+ }
38
+ const { command, path, rest } = resolveCommand(root, positionals);
39
+ const wantsHelp = flagBool(flags, "help");
40
+ const hasRun = typeof command.run === "function";
41
+ if (wantsHelp || !hasRun) {
42
+ if (format === "json") {
43
+ printData(commandSchema(command, path), "json");
44
+ }
45
+ else {
46
+ process.stdout.write(renderHelpText(command, path) + "\n");
47
+ }
48
+ // No runnable command and no help requested = usage error exit code.
49
+ return hasRun || wantsHelp ? 0 : positionals.length === 0 ? 0 : 2;
50
+ }
51
+ const dryRun = flagBool(flags, "dry-run");
52
+ const profileName = flagString(flags, "profile") ?? "";
53
+ const { name, profile } = resolveProfile(profileName || undefined);
54
+ const client = new FanfouClient({
55
+ consumerKey: profile.consumerKey,
56
+ consumerSecret: profile.consumerSecret,
57
+ token: profile.token,
58
+ tokenSecret: profile.tokenSecret,
59
+ dryRun,
60
+ });
61
+ if (command.requiresAuth && !client.isAuthenticated && !dryRun) {
62
+ printError({
63
+ type: "auth_required",
64
+ message: `命令 "${path.join(" ")}" 需要登录`,
65
+ hint: "先运行:fanfou auth login -u <用户名> -p <密码>,或用 --dry-run 预览请求",
66
+ }, format);
67
+ return 3;
68
+ }
69
+ const ctx = {
70
+ client,
71
+ args: rest,
72
+ flags,
73
+ format,
74
+ dryRun,
75
+ profileName: name,
76
+ };
77
+ const result = await command.run(ctx);
78
+ printData(result, format);
79
+ return 0;
80
+ }
81
+ main()
82
+ .then((code) => process.exit(code))
83
+ .catch((err) => {
84
+ const format = "json";
85
+ if (err instanceof UsageError) {
86
+ printError({ type: "usage", message: err.message }, format);
87
+ process.exit(2);
88
+ }
89
+ if (err instanceof FanfouHttpError) {
90
+ printError({
91
+ type: "http_error",
92
+ message: err.message.split("\n")[0] ?? err.message,
93
+ status: err.status,
94
+ body: err.body.slice(0, 1000),
95
+ }, format);
96
+ process.exit(1);
97
+ }
98
+ printError({ type: "error", message: err?.message ?? String(err) }, format);
99
+ process.exit(1);
100
+ });
package/dist/oauth1.js ADDED
@@ -0,0 +1,73 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ /** RFC 3986 percent-encoding (unreserved set only). */
3
+ export function percentEncode(value) {
4
+ return encodeURIComponent(value).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
5
+ }
6
+ function baseURLString(url) {
7
+ const scheme = url.protocol.replace(/:$/, "");
8
+ const host = url.hostname;
9
+ let portPart = "";
10
+ if (url.port &&
11
+ !((scheme === "http" && url.port === "80") || (scheme === "https" && url.port === "443"))) {
12
+ portPart = `:${url.port}`;
13
+ }
14
+ const path = url.pathname && url.pathname.length > 0 ? url.pathname : "/";
15
+ return `${scheme}://${host}${portPart}${path}`;
16
+ }
17
+ function queryPairs(url) {
18
+ const pairs = [];
19
+ for (const [name, value] of url.searchParams.entries()) {
20
+ pairs.push([name, value]);
21
+ }
22
+ return pairs;
23
+ }
24
+ /**
25
+ * Fanfou signs the base string with an http:// scheme even though requests go over
26
+ * https. Mirror the iOS client's `fanfouSignatureURL` quirk so signatures validate.
27
+ */
28
+ export function fanfouSignatureURL(url) {
29
+ if (url.protocol === "https:" && url.hostname.endsWith("fanfou.com")) {
30
+ const copy = new URL(url.toString());
31
+ copy.protocol = "http:";
32
+ return copy;
33
+ }
34
+ return url;
35
+ }
36
+ export function sign(baseString, consumerSecret, tokenSecret) {
37
+ const key = `${percentEncode(consumerSecret)}&${percentEncode(tokenSecret)}`;
38
+ return createHmac("sha1", key).update(baseString, "utf8").digest("base64");
39
+ }
40
+ export function authorizationHeader(opts) {
41
+ const nonce = opts.nonce ?? randomBytes(16).toString("hex");
42
+ const timestamp = opts.timestampSeconds ?? String(Math.floor(Date.now() / 1000));
43
+ const oauthParameters = [
44
+ ["oauth_consumer_key", opts.consumerKey],
45
+ ["oauth_nonce", nonce],
46
+ ["oauth_signature_method", "HMAC-SHA1"],
47
+ ["oauth_timestamp", timestamp],
48
+ ["oauth_version", "1.0"],
49
+ ];
50
+ if (opts.token && opts.token.length > 0) {
51
+ oauthParameters.push(["oauth_token", opts.token]);
52
+ }
53
+ if (opts.extraParameters) {
54
+ oauthParameters.push(...opts.extraParameters);
55
+ }
56
+ const allParameters = [
57
+ ...oauthParameters,
58
+ ...queryPairs(opts.url),
59
+ ...(opts.bodyParameters ?? []),
60
+ ];
61
+ const encoded = allParameters.map(([k, v]) => [percentEncode(k), percentEncode(v)]);
62
+ encoded.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
63
+ const signingParameters = encoded.map(([k, v]) => `${k}=${v}`).join("&");
64
+ const baseString = [
65
+ opts.method.toUpperCase(),
66
+ percentEncode(baseURLString(opts.signatureURL ?? opts.url)),
67
+ percentEncode(signingParameters),
68
+ ].join("&");
69
+ const signature = sign(baseString, opts.consumerSecret, opts.tokenSecret ?? "");
70
+ const headerParameters = [...oauthParameters, ["oauth_signature", signature]];
71
+ return ("OAuth " +
72
+ headerParameters.map(([k, v]) => `${percentEncode(k)}="${percentEncode(v)}"`).join(", "));
73
+ }
package/dist/output.js ADDED
@@ -0,0 +1,80 @@
1
+ export function isOutputFormat(value) {
2
+ return value === "json" || value === "ndjson" || value === "table" || value === "raw";
3
+ }
4
+ function asArray(data) {
5
+ return Array.isArray(data) ? data : null;
6
+ }
7
+ function truncate(value, max) {
8
+ const oneLine = value.replace(/\s+/g, " ").trim();
9
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
10
+ }
11
+ /** Best-effort one-line summary for a Fanfou status or user object. */
12
+ function summarizeRow(item) {
13
+ if (item == null || typeof item !== "object")
14
+ return null;
15
+ const o = item;
16
+ // Status-like
17
+ if (typeof o["text"] === "string" && o["user"] && typeof o["user"] === "object") {
18
+ const user = o["user"];
19
+ const name = user["name"] ?? user["id"] ?? "?";
20
+ const id = o["id"] ?? "";
21
+ const created = o["created_at"] ?? "";
22
+ const fav = o["favorited"] ? " ★" : "";
23
+ return `${id}\t@${name}${fav}\t${truncate(o["text"], 70)}\t${created}`;
24
+ }
25
+ // Direct message-like
26
+ if (typeof o["text"] === "string" && (o["sender_id"] || o["sender"])) {
27
+ const sender = o["sender"]?.["name"] ?? o["sender_id"];
28
+ const id = o["id"] ?? "";
29
+ return `${id}\t@${sender}\t${truncate(o["text"], 70)}`;
30
+ }
31
+ // User-like
32
+ if (typeof o["id"] === "string" && (o["screen_name"] || o["name"])) {
33
+ const followers = o["followers_count"] ?? "";
34
+ const statuses = o["statuses_count"] ?? "";
35
+ return `${o["id"]}\t${o["name"] ?? ""}\tfollowers=${followers}\tstatuses=${statuses}`;
36
+ }
37
+ return null;
38
+ }
39
+ function renderTable(data) {
40
+ const arr = asArray(data);
41
+ if (arr) {
42
+ const lines = arr.map((item) => summarizeRow(item) ?? JSON.stringify(item));
43
+ return lines.join("\n");
44
+ }
45
+ return summarizeRow(data) ?? JSON.stringify(data, null, 2);
46
+ }
47
+ export function formatOutput(data, format) {
48
+ switch (format) {
49
+ case "json":
50
+ return JSON.stringify(data, null, 2);
51
+ case "ndjson": {
52
+ const arr = asArray(data);
53
+ if (arr)
54
+ return arr.map((item) => JSON.stringify(item)).join("\n");
55
+ return JSON.stringify(data);
56
+ }
57
+ case "table":
58
+ return renderTable(data);
59
+ case "raw":
60
+ return typeof data === "string" ? data : JSON.stringify(data, null, 2);
61
+ }
62
+ }
63
+ export function printData(data, format) {
64
+ if (data === undefined)
65
+ return;
66
+ const out = formatOutput(data, format);
67
+ if (out.length > 0)
68
+ process.stdout.write(out + "\n");
69
+ }
70
+ export function printError(error, format) {
71
+ if (format === "table" || format === "raw") {
72
+ let line = `错误 (${error.type}): ${error.message}`;
73
+ if (error.hint)
74
+ line += `\n提示: ${error.hint}`;
75
+ process.stderr.write(line + "\n");
76
+ }
77
+ else {
78
+ process.stderr.write(JSON.stringify({ error }, null, 2) + "\n");
79
+ }
80
+ }
@@ -0,0 +1,122 @@
1
+ export const GLOBAL_BOOLEAN_FLAGS = ["help", "dry-run", "verbose", "quiet", "version", "json", "no-color"];
2
+ export function collectBooleanFlags(root) {
3
+ const set = new Set(GLOBAL_BOOLEAN_FLAGS);
4
+ const walk = (cmd) => {
5
+ for (const flag of cmd.flags ?? []) {
6
+ if (flag.type === "boolean") {
7
+ set.add(flag.name);
8
+ if (flag.alias)
9
+ set.add(flag.alias);
10
+ }
11
+ }
12
+ for (const sub of cmd.subcommands ?? [])
13
+ walk(sub);
14
+ };
15
+ walk(root);
16
+ return set;
17
+ }
18
+ export function resolveCommand(root, positionals) {
19
+ let command = root;
20
+ const path = [];
21
+ let i = 0;
22
+ while (i < positionals.length) {
23
+ const token = positionals[i];
24
+ const next = command.subcommands?.find((c) => c.name === token);
25
+ if (!next)
26
+ break;
27
+ command = next;
28
+ path.push(token);
29
+ i++;
30
+ }
31
+ return { command, path, rest: positionals.slice(i) };
32
+ }
33
+ export function commandSchema(command, path) {
34
+ return {
35
+ name: command.name,
36
+ path: ["fanfou", ...path].join(" "),
37
+ summary: command.summary,
38
+ description: command.description,
39
+ requiresAuth: command.requiresAuth ?? false,
40
+ mutates: command.mutates ?? false,
41
+ arguments: (command.args ?? []).map((a) => ({
42
+ name: a.name,
43
+ description: a.description,
44
+ required: a.required ?? false,
45
+ variadic: a.variadic ?? false,
46
+ })),
47
+ flags: (command.flags ?? []).map((f) => ({
48
+ name: f.name,
49
+ alias: f.alias,
50
+ type: f.type,
51
+ description: f.description,
52
+ required: f.required ?? false,
53
+ default: f.default,
54
+ })),
55
+ subcommands: (command.subcommands ?? []).map((s) => ({ name: s.name, summary: s.summary })),
56
+ examples: command.examples ?? [],
57
+ };
58
+ }
59
+ function usageLine(command, path) {
60
+ const parts = ["fanfou", ...path];
61
+ if (command.subcommands && command.subcommands.length > 0)
62
+ parts.push("<command>");
63
+ for (const arg of command.args ?? []) {
64
+ const token = arg.variadic ? `${arg.name}...` : arg.name;
65
+ parts.push(arg.required ? `<${token}>` : `[${token}]`);
66
+ }
67
+ if ((command.flags ?? []).length > 0)
68
+ parts.push("[flags]");
69
+ return parts.join(" ");
70
+ }
71
+ export function renderHelpText(command, path) {
72
+ const lines = [];
73
+ const title = path.length > 0 ? path.join(" ") : "fanfou";
74
+ lines.push(`${title} — ${command.summary}`);
75
+ lines.push("");
76
+ if (command.description) {
77
+ lines.push(command.description);
78
+ lines.push("");
79
+ }
80
+ lines.push("Usage:");
81
+ lines.push(` ${usageLine(command, path)}`);
82
+ if (command.args && command.args.length > 0) {
83
+ lines.push("");
84
+ lines.push("Arguments:");
85
+ for (const arg of command.args) {
86
+ const flag = arg.required ? " (required)" : "";
87
+ lines.push(` ${arg.name.padEnd(18)} ${arg.description}${flag}`);
88
+ }
89
+ }
90
+ if (command.flags && command.flags.length > 0) {
91
+ lines.push("");
92
+ lines.push("Flags:");
93
+ for (const flag of command.flags) {
94
+ const alias = flag.alias ? `, -${flag.alias}` : "";
95
+ const valueHint = flag.type === "boolean" ? "" : ` <${flag.type}>`;
96
+ const head = `--${flag.name}${alias}${valueHint}`;
97
+ const req = flag.required ? " (required)" : "";
98
+ const def = flag.default !== undefined ? ` [default: ${flag.default}]` : "";
99
+ lines.push(` ${head.padEnd(26)} ${flag.description}${req}${def}`);
100
+ }
101
+ }
102
+ if (command.subcommands && command.subcommands.length > 0) {
103
+ lines.push("");
104
+ lines.push("Commands:");
105
+ for (const sub of command.subcommands) {
106
+ lines.push(` ${sub.name.padEnd(18)} ${sub.summary}`);
107
+ }
108
+ }
109
+ if (command.examples && command.examples.length > 0) {
110
+ lines.push("");
111
+ lines.push("Examples:");
112
+ for (const ex of command.examples)
113
+ lines.push(` ${ex}`);
114
+ }
115
+ lines.push("");
116
+ lines.push("Global flags:");
117
+ lines.push(" --format, -o <json|ndjson|table|raw> Output format (default: json)");
118
+ lines.push(" --profile <name> Account profile to use");
119
+ lines.push(" --dry-run, -n Print the request without sending it");
120
+ lines.push(" --help, -h Show help (add --format json for schema)");
121
+ return lines.join("\n");
122
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "fanfou",
3
+ "version": "0.1.0",
4
+ "description": "An LLM-friendly command line for the Fanfou (饭否) API, with a bundled agent skill.",
5
+ "type": "module",
6
+ "bin": {
7
+ "fanfou": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "skills",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22.6.0"
16
+ },
17
+ "scripts": {
18
+ "dev": "node src/index.ts",
19
+ "build": "tsc -p tsconfig.json",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "test": "node --test 'test/**/*.test.ts'",
22
+ "install-skill": "node scripts/install-skill.mjs",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "fanfou",
27
+ "饭否",
28
+ "cli",
29
+ "llm",
30
+ "agent",
31
+ "oauth1"
32
+ ],
33
+ "license": "MIT",
34
+ "author": "Leaking",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Leaking/fanfou-cli.git"
38
+ },
39
+ "homepage": "https://github.com/Leaking/fanfou-cli#readme",
40
+ "bugs": "https://github.com/Leaking/fanfou-cli/issues",
41
+ "devDependencies": {
42
+ "@types/node": "^22.10.0",
43
+ "typescript": "^5.8.0"
44
+ }
45
+ }
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: fanfou
3
+ description: >-
4
+ Read and write Fanfou (饭否) through the `fanfou` command-line tool. Use when the
5
+ user wants to browse their Fanfou home/public/mentions timeline, post / reply /
6
+ repost / delete a status (发饭/回复/转发), favorite (收藏), follow or search users,
7
+ send direct messages (私信), or call any Fanfou API endpoint. Triggers on "饭否",
8
+ "fanfou", "发饭", "刷饭", or requests mentioning the fanfou CLI.
9
+ ---
10
+
11
+ # Fanfou (饭否) via the `fanfou` CLI
12
+
13
+ `fanfou` is an LLM-friendly command line over the Fanfou API. It prints **JSON to
14
+ stdout by default**, so parse stdout as JSON. Errors are JSON on **stderr** with a
15
+ non-zero exit code: `{ "error": { "type", "message", "status?", "body?", "hint?" } }`.
16
+
17
+ ## Running the CLI
18
+
19
+ Install the CLI once, then call `fanfou` directly:
20
+
21
+ ```bash
22
+ npm install -g fanfou # global; or one-off without installing: npx fanfou <command>
23
+ fanfou <command> ...
24
+ ```
25
+
26
+ (Developing from a clone? Node ≥ 22.6 runs the source with no build:
27
+ `node src/index.ts <command> ...`.)
28
+
29
+ Discover anything at runtime — every command supports `--help` (and
30
+ `--help --format json` returns a machine-readable schema of args/flags):
31
+
32
+ ```bash
33
+ fanfou --help # top-level command list
34
+ fanfou status post --help # text help for one command
35
+ fanfou timeline home --help --format json # schema for tooling
36
+ ```
37
+
38
+ ## Three layers (pick the simplest that fits)
39
+
40
+ 1. **Shortcuts** — `+`-prefixed, high-frequency, smart defaults:
41
+ `+timeline`, `+post`, `+reply`, `+repost`, `+mentions`, `+me`, `+search`, `+fav`, `+dm`.
42
+ 2. **Resource commands** — full coverage grouped by resource:
43
+ `auth`, `timeline`, `status`, `favorite`, `user`, `friendship`, `dm`, `account`, `search`.
44
+ 3. **Raw API** — `fanfou api <GET|POST> <path> [--query k=v&..] [--form k=v&..]`
45
+ for any endpoint not covered above.
46
+
47
+ ## Authentication (required for most commands)
48
+
49
+ Two flows are supported; tokens are stored per-profile under `~/.config/fanfou`.
50
+
51
+ ```bash
52
+ # XAuth (simplest, recommended for automation): username + password
53
+ fanfou auth login -u <用户名/邮箱> -p <密码>
54
+ # or via env, to keep secrets out of argv / shell history:
55
+ FANFOU_USERNAME=... FANFOU_PASSWORD=... fanfou auth login
56
+
57
+ # OAuth web flow (two scriptable steps):
58
+ fanfou auth oauth-url # prints authorize_url + request token
59
+ # -> open authorize_url in a browser, approve
60
+ fanfou auth oauth-exchange --token <request_token> --secret <request_token_secret> [--verifier <code>]
61
+
62
+ fanfou auth status # check login state (no network)
63
+ fanfou auth whoami # verify against the server
64
+ fanfou auth logout
65
+ ```
66
+
67
+ Multiple accounts: `fanfou auth login --profile work ...`, then add
68
+ `--profile work` to any command, or `fanfou auth use work` to set the default.
69
+
70
+ ## Common recipes
71
+
72
+ ```bash
73
+ fanfou +timeline --count 10 # home timeline, latest 10
74
+ fanfou timeline mentions --count 5 # who mentioned me
75
+ fanfou +post "今天天气不错" # post a status (≤140 chars)
76
+ fanfou +reply <status-id> "说得对" # reply (auto-prepends @name)
77
+ fanfou +repost <status-id> # repost ("转@用户 原文" if no text)
78
+ fanfou status delete <status-id> # delete your own status
79
+ fanfou +fav <status-id> # favorite
80
+ fanfou user show <login-id> # someone's profile
81
+ fanfou user follow <login-id> # follow
82
+ fanfou +search "关键词" # search public statuses
83
+ fanfou +dm <login-id> "在吗" # send a direct message
84
+ fanfou api GET statuses/home_timeline.json --query count=3 # raw endpoint
85
+ ```
86
+
87
+ ### Output formats
88
+
89
+ `--format` / `-o`: `json` (default), `ndjson` (one object per line; good for
90
+ piping/streaming arrays), `table` (compact human summary), `raw` (response text
91
+ as-is). Example: `fanfou +timeline --format table`.
92
+
93
+ ## Important behaviors & gotchas
94
+
95
+ - **JSON by default.** Parse stdout as JSON. On error, read the JSON on stderr and
96
+ check the exit code (`2` usage, `3` auth-required, `1` other/HTTP).
97
+ - **Preview before writing.** Every state-changing command supports `--dry-run`
98
+ (`-n`): it prints the exact signed request `{method,url,form,...}` without
99
+ sending it. Use this to confirm a destructive call (e.g. `status delete`) first.
100
+ - **Status IDs can start with `-`** (e.g. `-A_ycI00_Kc`). The parser handles this,
101
+ but if you ever build args dynamically and hit trouble, use `--` to end flag
102
+ parsing: `fanfou status delete -- -A_ycI00_Kc`.
103
+ - **140-character limit** on statuses; longer text is rejected by the server.
104
+ - **`id` means loginname**, not the numeric/`rawid`. Pass the string `id` field.
105
+ - **Destructive ops** (`status delete`, `dm delete`, `user block`, `unfollow`)
106
+ act on the live account — confirm intent, and prefer `--dry-run` to preview.
107
+ - **Reading is safe**; writing posts to the real account. Don't post test/spam
108
+ content to a user's real account without being asked.
109
+
110
+ ## Endpoint coverage map (resource command → Fanfou API)
111
+
112
+ | Command | API |
113
+ | --- | --- |
114
+ | `timeline home/public/user/mentions/context/photos` | `statuses/*_timeline`, `statuses/context_timeline`, `photos/user_timeline` |
115
+ | `status show/post/reply/repost/delete/photo` | `statuses/show`, `statuses/update`, `statuses/destroy`, `photos/upload` |
116
+ | `favorite list/add/remove` | `favorites`, `favorites/create/<id>`, `favorites/destroy/<id>` |
117
+ | `user show/friends/followers/follow/unfollow/search/block/unblock/blocks/blocked` | `users/*`, `friendships/create|destroy`, `search/users`, `blocks/*` |
118
+ | `friendship requests/accept/deny/exists` | `friendships/requests|accept|deny|exists` |
119
+ | `dm list/thread/inbox/sent/send/delete` | `direct_messages/*` |
120
+ | `account verify/notification/update-profile/update-avatar` | `account/*` |
121
+ | `search statuses/users` | `search/public_timeline`, `search/users` |
122
+ | anything else | `fanfou api <METHOD> <path>` |