jike-cli 0.1.0__tar.gz

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.
jike_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 case
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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: jike-cli
3
+ Version: 0.1.0
4
+ Summary: A CLI tool for Jike (即刻) social network
5
+ Project-URL: Homepage, https://github.com/case/jike-cli
6
+ Project-URL: Repository, https://github.com/case/jike-cli
7
+ Author: cypggsai
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,jike,social,即刻
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Communications
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: click>=8.0
22
+ Requires-Dist: qrcode>=7.0
23
+ Requires-Dist: requests>=2.28
24
+ Requires-Dist: rich>=13.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # jike-cli
28
+
29
+ A CLI tool for [Jike (即刻)](https://www.okjike.com) social network.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ # via uv
35
+ uv tool install jike-cli
36
+
37
+ # via pipx
38
+ pipx install jike-cli
39
+
40
+ # via pip
41
+ pip install jike-cli
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### Login
47
+
48
+ ```bash
49
+ jike login # Scan QR code with Jike app
50
+ jike logout # Clear saved tokens
51
+ ```
52
+
53
+ ### Browse
54
+
55
+ ```bash
56
+ jike feed # View following feed
57
+ jike feed -n 10 # Limit to 10 posts
58
+ jike search "AI" # Search posts
59
+ jike notifications # View notifications
60
+ ```
61
+
62
+ ### Post
63
+
64
+ ```bash
65
+ jike post "Hello from CLI!" # Create a post
66
+ jike delete-post POST_ID # Delete a post
67
+ ```
68
+
69
+ ### Comments
70
+
71
+ ```bash
72
+ jike comment POST_ID "Nice!" # Add comment
73
+ jike delete-comment COMMENT_ID # Delete comment
74
+ ```
75
+
76
+ ### Users
77
+
78
+ ```bash
79
+ jike profile USERNAME # View profile
80
+ jike followers USER_ID # List followers
81
+ jike following USER_ID # List following
82
+ ```
83
+
84
+ ### JSON Output
85
+
86
+ All read commands support `--json` for machine-readable output:
87
+
88
+ ```bash
89
+ jike feed --json | jq '.[] | .content'
90
+ ```
91
+
92
+ Non-TTY output (pipes) automatically uses JSON format.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ git clone https://github.com/case/jike-cli
98
+ cd jike-cli
99
+ pip install -e .
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,78 @@
1
+ # jike-cli
2
+
3
+ A CLI tool for [Jike (即刻)](https://www.okjike.com) social network.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # via uv
9
+ uv tool install jike-cli
10
+
11
+ # via pipx
12
+ pipx install jike-cli
13
+
14
+ # via pip
15
+ pip install jike-cli
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### Login
21
+
22
+ ```bash
23
+ jike login # Scan QR code with Jike app
24
+ jike logout # Clear saved tokens
25
+ ```
26
+
27
+ ### Browse
28
+
29
+ ```bash
30
+ jike feed # View following feed
31
+ jike feed -n 10 # Limit to 10 posts
32
+ jike search "AI" # Search posts
33
+ jike notifications # View notifications
34
+ ```
35
+
36
+ ### Post
37
+
38
+ ```bash
39
+ jike post "Hello from CLI!" # Create a post
40
+ jike delete-post POST_ID # Delete a post
41
+ ```
42
+
43
+ ### Comments
44
+
45
+ ```bash
46
+ jike comment POST_ID "Nice!" # Add comment
47
+ jike delete-comment COMMENT_ID # Delete comment
48
+ ```
49
+
50
+ ### Users
51
+
52
+ ```bash
53
+ jike profile USERNAME # View profile
54
+ jike followers USER_ID # List followers
55
+ jike following USER_ID # List following
56
+ ```
57
+
58
+ ### JSON Output
59
+
60
+ All read commands support `--json` for machine-readable output:
61
+
62
+ ```bash
63
+ jike feed --json | jq '.[] | .content'
64
+ ```
65
+
66
+ Non-TTY output (pipes) automatically uses JSON format.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ git clone https://github.com/case/jike-cli
72
+ cd jike-cli
73
+ pip install -e .
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "jike-cli"
7
+ version = "0.1.0"
8
+ description = "A CLI tool for Jike (即刻) social network"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "cypggsai" }]
13
+ keywords = ["jike", "即刻", "cli", "social"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Communications",
24
+ ]
25
+ dependencies = [
26
+ "click>=8.0",
27
+ "rich>=13.0",
28
+ "requests>=2.28",
29
+ "qrcode>=7.0",
30
+ ]
31
+
32
+ [project.scripts]
33
+ jike = "jike_cli.cli:cli"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/case/jike-cli"
37
+ Repository = "https://github.com/case/jike-cli"
@@ -0,0 +1,3 @@
1
+ """jike-cli — Jike (即刻) social network CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m jike_cli`."""
2
+
3
+ from jike_cli.cli import cli
4
+
5
+ cli()
@@ -0,0 +1,7 @@
1
+ """Vendored jike-skill — Jike API client (MIT license, from imHw/jike-skill)."""
2
+
3
+ from .auth import authenticate, refresh_tokens
4
+ from .client import JikeClient
5
+ from .types import TokenPair
6
+
7
+ __all__ = ["JikeClient", "TokenPair", "authenticate", "refresh_tokens"]
@@ -0,0 +1,133 @@
1
+ """Jike QR Authentication — scan-to-login flow."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ import urllib.parse
7
+ from typing import Optional
8
+
9
+ import requests
10
+
11
+ from .types import API_BASE, DEFAULT_HEADERS, TokenPair
12
+
13
+ POLL_INTERVAL_SEC = 1
14
+ POLL_TIMEOUT_SEC = 180
15
+
16
+
17
+ def _post(path: str, headers: Optional[dict] = None, **kwargs) -> requests.Response:
18
+ merged = {**DEFAULT_HEADERS, "Content-Type": "application/json"}
19
+ if headers:
20
+ merged.update(headers)
21
+ return requests.post(f"{API_BASE}{path}", headers=merged, **kwargs)
22
+
23
+
24
+ def _get(path: str) -> requests.Response:
25
+ return requests.get(f"{API_BASE}{path}", headers={**DEFAULT_HEADERS})
26
+
27
+
28
+ def create_session() -> str:
29
+ resp = _post("/sessions.create")
30
+ resp.raise_for_status()
31
+ return resp.json()["uuid"]
32
+
33
+
34
+ def build_qr_payload(uuid: str) -> str:
35
+ scan_url = f"https://www.okjike.com/account/scan?uuid={uuid}"
36
+ return (
37
+ "jike://page.jk/web?url="
38
+ + urllib.parse.quote(scan_url, safe="")
39
+ + "&displayHeader=false&displayFooter=false"
40
+ )
41
+
42
+
43
+ def render_qr(data: str) -> bool:
44
+ try:
45
+ import qrcode
46
+
47
+ qr = qrcode.QRCode(border=1)
48
+ qr.add_data(data)
49
+ qr.make(fit=True)
50
+ qr.print_ascii(out=sys.stderr)
51
+ return True
52
+ except ImportError:
53
+ return False
54
+
55
+
56
+ def _extract_tokens(resp: requests.Response) -> Optional[TokenPair]:
57
+ body: dict = {}
58
+ try:
59
+ body = resp.json()
60
+ except (ValueError, KeyError):
61
+ pass
62
+
63
+ access = (
64
+ body.get("x-jike-access-token")
65
+ or body.get("access_token")
66
+ or resp.headers.get("x-jike-access-token")
67
+ )
68
+ refresh = (
69
+ body.get("x-jike-refresh-token")
70
+ or body.get("refresh_token")
71
+ or resp.headers.get("x-jike-refresh-token")
72
+ )
73
+
74
+ if access and refresh:
75
+ return TokenPair(access_token=access, refresh_token=refresh)
76
+ return None
77
+
78
+
79
+ def poll_confirmation(uuid: str) -> Optional[TokenPair]:
80
+ attempts = POLL_TIMEOUT_SEC // POLL_INTERVAL_SEC
81
+
82
+ for _ in range(attempts):
83
+ try:
84
+ resp = _get(f"/sessions.wait_for_confirmation?uuid={uuid}")
85
+ except requests.RequestException:
86
+ time.sleep(POLL_INTERVAL_SEC)
87
+ continue
88
+
89
+ if resp.status_code == 200:
90
+ return _extract_tokens(resp)
91
+
92
+ if resp.status_code == 400:
93
+ time.sleep(POLL_INTERVAL_SEC)
94
+ continue
95
+
96
+ time.sleep(POLL_INTERVAL_SEC)
97
+
98
+ return None
99
+
100
+
101
+ def refresh_tokens(token_pair: TokenPair) -> TokenPair:
102
+ resp = _post(
103
+ "/app_auth_tokens.refresh",
104
+ headers={"x-jike-refresh-token": token_pair.refresh_token},
105
+ json={},
106
+ )
107
+ resp.raise_for_status()
108
+
109
+ return TokenPair(
110
+ access_token=resp.headers.get(
111
+ "x-jike-access-token", token_pair.access_token
112
+ ),
113
+ refresh_token=resp.headers.get(
114
+ "x-jike-refresh-token", token_pair.refresh_token
115
+ ),
116
+ )
117
+
118
+
119
+ def authenticate() -> TokenPair:
120
+ uuid = create_session()
121
+
122
+ qr_payload = build_qr_payload(uuid)
123
+ if not render_qr(qr_payload):
124
+ print("Install 'qrcode' for terminal QR code.", file=sys.stderr)
125
+ print(f"Or open: {qr_payload}", file=sys.stderr)
126
+
127
+ tokens = poll_confirmation(uuid)
128
+ if not tokens:
129
+ print("Timeout — no scan detected.", file=sys.stderr)
130
+ sys.exit(1)
131
+
132
+ tokens = refresh_tokens(tokens)
133
+ return tokens
@@ -0,0 +1,139 @@
1
+ """Jike API Client — feed, posts, comments, search, profiles, notifications."""
2
+
3
+ from typing import Optional
4
+
5
+ import requests
6
+
7
+ from .types import API_BASE, DEFAULT_HEADERS, TokenPair
8
+
9
+
10
+ class JikeClient:
11
+ """Jike API client with automatic token refresh on 401."""
12
+
13
+ def __init__(self, tokens: TokenPair):
14
+ self._tokens = tokens
15
+
16
+ @property
17
+ def tokens(self) -> TokenPair:
18
+ return self._tokens
19
+
20
+ def _headers(self) -> dict:
21
+ return {
22
+ **DEFAULT_HEADERS,
23
+ "Content-Type": "application/json",
24
+ "x-jike-access-token": self._tokens.access_token,
25
+ }
26
+
27
+ def _request(
28
+ self, method: str, path: str, retry_on_401: bool = True, **kwargs
29
+ ) -> dict:
30
+ resp = requests.request(
31
+ method, f"{API_BASE}{path}", headers=self._headers(), **kwargs
32
+ )
33
+
34
+ if resp.status_code == 401 and retry_on_401:
35
+ self._refresh()
36
+ return self._request(method, path, retry_on_401=False, **kwargs)
37
+
38
+ resp.raise_for_status()
39
+ return resp.json() if resp.content else {}
40
+
41
+ def _refresh(self) -> None:
42
+ resp = requests.post(
43
+ f"{API_BASE}/app_auth_tokens.refresh",
44
+ headers={
45
+ **DEFAULT_HEADERS,
46
+ "Content-Type": "application/json",
47
+ "x-jike-refresh-token": self._tokens.refresh_token,
48
+ },
49
+ json={},
50
+ )
51
+ resp.raise_for_status()
52
+ self._tokens = TokenPair(
53
+ access_token=resp.headers.get(
54
+ "x-jike-access-token", self._tokens.access_token
55
+ ),
56
+ refresh_token=resp.headers.get(
57
+ "x-jike-refresh-token", self._tokens.refresh_token
58
+ ),
59
+ )
60
+
61
+ def feed(self, limit: int = 20, load_more_key: Optional[str] = None) -> dict:
62
+ body: dict[str, object] = {"limit": limit}
63
+ if load_more_key:
64
+ body["loadMoreKey"] = load_more_key
65
+ return self._request(
66
+ "POST", "/1.0/personalUpdate/followingUpdates", json=body
67
+ )
68
+
69
+ def get_post(self, post_id: str) -> dict:
70
+ return self._request("GET", f"/1.0/originalPosts/get?id={post_id}")
71
+
72
+ def create_post(self, content: str, picture_keys: Optional[list] = None) -> dict:
73
+ return self._request(
74
+ "POST",
75
+ "/1.0/originalPosts/create",
76
+ json={"content": content, "pictureKeys": picture_keys or []},
77
+ )
78
+
79
+ def delete_post(self, post_id: str) -> dict:
80
+ return self._request(
81
+ "POST", "/1.0/originalPosts/remove", json={"id": post_id}
82
+ )
83
+
84
+ def add_comment(self, post_id: str, content: str) -> dict:
85
+ return self._request(
86
+ "POST",
87
+ "/1.0/comments/add",
88
+ json={
89
+ "targetType": "ORIGINAL_POST",
90
+ "targetId": post_id,
91
+ "content": content,
92
+ "syncToPersonalUpdates": False,
93
+ "pictureKeys": [],
94
+ "force": False,
95
+ },
96
+ )
97
+
98
+ def delete_comment(self, comment_id: str) -> dict:
99
+ return self._request(
100
+ "POST",
101
+ "/1.0/comments/remove",
102
+ json={"id": comment_id, "targetType": "ORIGINAL_POST"},
103
+ )
104
+
105
+ def search(
106
+ self, keyword: str, limit: int = 20, load_more_key: Optional[str] = None
107
+ ) -> dict:
108
+ body: dict[str, object] = {"keyword": keyword, "limit": limit}
109
+ if load_more_key:
110
+ body["loadMoreKey"] = load_more_key
111
+ return self._request("POST", "/1.0/search/integrate", json=body)
112
+
113
+ def profile(self, username: str) -> dict:
114
+ return self._request("GET", f"/1.0/users/profile?username={username}")
115
+
116
+ def followers(self, user_id: str, load_more_key: Optional[str] = None) -> dict:
117
+ body: dict[str, object] = {"userId": user_id}
118
+ if load_more_key:
119
+ body["loadMoreKey"] = load_more_key
120
+ return self._request(
121
+ "POST", "/1.0/userRelation/getFollowerList", json=body
122
+ )
123
+
124
+ def following(self, user_id: str, load_more_key: Optional[str] = None) -> dict:
125
+ body: dict[str, object] = {"userId": user_id}
126
+ if load_more_key:
127
+ body["loadMoreKey"] = load_more_key
128
+ return self._request(
129
+ "POST", "/1.0/userRelation/getFollowingList", json=body
130
+ )
131
+
132
+ def unread_notifications(self) -> dict:
133
+ return self._request("GET", "/1.0/notifications/unread")
134
+
135
+ def list_notifications(self, load_more_key: Optional[str] = None) -> dict:
136
+ body: dict[str, object] = {}
137
+ if load_more_key:
138
+ body["loadMoreKey"] = load_more_key
139
+ return self._request("POST", "/1.0/notifications/list", json=body)
@@ -0,0 +1,28 @@
1
+ """Shared types for the Jike client."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ API_BASE = "https://api.ruguoapp.com"
6
+
7
+ DEFAULT_HEADERS = {
8
+ "Origin": "https://web.okjike.com",
9
+ "User-Agent": (
10
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
11
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 "
12
+ "Mobile/15E148 Safari/604.1"
13
+ ),
14
+ "Accept": "application/json, text/plain, */*",
15
+ "DNT": "1",
16
+ }
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TokenPair:
21
+ access_token: str
22
+ refresh_token: str
23
+
24
+ def to_dict(self) -> dict[str, str]:
25
+ return {
26
+ "access_token": self.access_token,
27
+ "refresh_token": self.refresh_token,
28
+ }
@@ -0,0 +1,35 @@
1
+ """Token persistence — save/load/clear tokens to ~/.config/jike-cli/tokens.json."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from jike_cli._jike import TokenPair
9
+
10
+ CONFIG_DIR = Path(os.environ.get("JIKE_CLI_CONFIG_DIR", Path.home() / ".config" / "jike-cli"))
11
+ TOKEN_FILE = CONFIG_DIR / "tokens.json"
12
+
13
+
14
+ def save_tokens(tokens: TokenPair) -> None:
15
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
16
+ TOKEN_FILE.write_text(json.dumps(tokens.to_dict(), indent=2))
17
+ TOKEN_FILE.chmod(0o600)
18
+
19
+
20
+ def load_tokens() -> Optional[TokenPair]:
21
+ if not TOKEN_FILE.exists():
22
+ return None
23
+ try:
24
+ data = json.loads(TOKEN_FILE.read_text())
25
+ return TokenPair(
26
+ access_token=data["access_token"],
27
+ refresh_token=data["refresh_token"],
28
+ )
29
+ except (json.JSONDecodeError, KeyError):
30
+ return None
31
+
32
+
33
+ def clear_tokens() -> None:
34
+ if TOKEN_FILE.exists():
35
+ TOKEN_FILE.unlink()
@@ -0,0 +1,265 @@
1
+ """Click command group — all jike CLI commands."""
2
+
3
+ import sys
4
+
5
+ import click
6
+ import requests
7
+
8
+ from jike_cli import __version__
9
+ from jike_cli._jike import JikeClient, TokenPair, authenticate
10
+ from jike_cli.auth import clear_tokens, load_tokens, save_tokens
11
+ from jike_cli.formatter import (
12
+ console,
13
+ render_comment,
14
+ render_notifications,
15
+ render_posts,
16
+ render_user,
17
+ render_user_list,
18
+ )
19
+ from jike_cli.models import (
20
+ Comment,
21
+ Post,
22
+ User,
23
+ parse_feed,
24
+ parse_notifications,
25
+ parse_search,
26
+ parse_user_list,
27
+ )
28
+ from jike_cli.output import is_tty, print_json
29
+
30
+
31
+ def _get_client() -> JikeClient:
32
+ tokens = load_tokens()
33
+ if not tokens:
34
+ console.print("[red]Not logged in. Run `jike login` first.[/red]", file=sys.stderr)
35
+ raise SystemExit(1)
36
+ return JikeClient(tokens)
37
+
38
+
39
+ def _save_refreshed_tokens(client: JikeClient) -> None:
40
+ """Persist tokens if they were refreshed during the request."""
41
+ save_tokens(client.tokens)
42
+
43
+
44
+ def _use_json(json_flag: bool) -> bool:
45
+ return json_flag or not is_tty()
46
+
47
+
48
+ @click.group()
49
+ @click.version_option(__version__, prog_name="jike")
50
+ def cli() -> None:
51
+ """Jike (即刻) social network CLI."""
52
+
53
+
54
+ @cli.command()
55
+ def login() -> None:
56
+ """Login via QR code scan."""
57
+ console.print("[bold]Scan the QR code with Jike app...[/bold]")
58
+ try:
59
+ tokens = authenticate()
60
+ save_tokens(tokens)
61
+ console.print("[green]Login successful![/green]")
62
+ except requests.HTTPError as e:
63
+ console.print(f"[red]Login failed: {e}[/red]", file=sys.stderr)
64
+ raise SystemExit(1)
65
+
66
+
67
+ @cli.command()
68
+ def logout() -> None:
69
+ """Clear saved tokens."""
70
+ clear_tokens()
71
+ console.print("Logged out.")
72
+
73
+
74
+ @cli.command()
75
+ @click.option("-n", "--limit", default=20, help="Number of posts to fetch.")
76
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
77
+ def feed(limit: int, json_flag: bool) -> None:
78
+ """View your following feed."""
79
+ client = _get_client()
80
+ try:
81
+ data = client.feed(limit=limit)
82
+ _save_refreshed_tokens(client)
83
+ posts = parse_feed(data)
84
+ if _use_json(json_flag):
85
+ print_json(posts)
86
+ else:
87
+ render_posts(posts)
88
+ except requests.HTTPError as e:
89
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
90
+ raise SystemExit(1)
91
+
92
+
93
+ @cli.command()
94
+ @click.argument("content")
95
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
96
+ def post(content: str, json_flag: bool) -> None:
97
+ """Create a new post."""
98
+ client = _get_client()
99
+ try:
100
+ data = client.create_post(content)
101
+ _save_refreshed_tokens(client)
102
+ if _use_json(json_flag):
103
+ print_json(data)
104
+ else:
105
+ console.print("[green]Post created![/green]")
106
+ p = Post.from_api(data.get("data", data))
107
+ if p.id:
108
+ console.print(f"[dim]ID: {p.id}[/dim]")
109
+ except requests.HTTPError as e:
110
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
111
+ raise SystemExit(1)
112
+
113
+
114
+ @cli.command("delete-post")
115
+ @click.argument("post_id")
116
+ def delete_post(post_id: str) -> None:
117
+ """Delete a post by ID."""
118
+ client = _get_client()
119
+ try:
120
+ client.delete_post(post_id)
121
+ _save_refreshed_tokens(client)
122
+ console.print("[green]Post deleted.[/green]")
123
+ except requests.HTTPError as e:
124
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
125
+ raise SystemExit(1)
126
+
127
+
128
+ @cli.command()
129
+ @click.argument("post_id")
130
+ @click.argument("content")
131
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
132
+ def comment(post_id: str, content: str, json_flag: bool) -> None:
133
+ """Add a comment to a post."""
134
+ client = _get_client()
135
+ try:
136
+ data = client.add_comment(post_id, content)
137
+ _save_refreshed_tokens(client)
138
+ if _use_json(json_flag):
139
+ print_json(data)
140
+ else:
141
+ console.print("[green]Comment added![/green]")
142
+ c = Comment.from_api(data.get("data", data))
143
+ if c.id:
144
+ console.print(f"[dim]ID: {c.id}[/dim]")
145
+ except requests.HTTPError as e:
146
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
147
+ raise SystemExit(1)
148
+
149
+
150
+ @cli.command("delete-comment")
151
+ @click.argument("comment_id")
152
+ def delete_comment(comment_id: str) -> None:
153
+ """Delete a comment by ID."""
154
+ client = _get_client()
155
+ try:
156
+ client.delete_comment(comment_id)
157
+ _save_refreshed_tokens(client)
158
+ console.print("[green]Comment deleted.[/green]")
159
+ except requests.HTTPError as e:
160
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
161
+ raise SystemExit(1)
162
+
163
+
164
+ @cli.command()
165
+ @click.argument("keyword")
166
+ @click.option("-n", "--limit", default=20, help="Number of results.")
167
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
168
+ def search(keyword: str, limit: int, json_flag: bool) -> None:
169
+ """Search for posts."""
170
+ client = _get_client()
171
+ try:
172
+ data = client.search(keyword, limit=limit)
173
+ _save_refreshed_tokens(client)
174
+ posts = parse_search(data)
175
+ if _use_json(json_flag):
176
+ print_json(posts)
177
+ else:
178
+ render_posts(posts)
179
+ except requests.HTTPError as e:
180
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
181
+ raise SystemExit(1)
182
+
183
+
184
+ @cli.command()
185
+ @click.argument("username")
186
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
187
+ def profile(username: str, json_flag: bool) -> None:
188
+ """View a user's profile."""
189
+ client = _get_client()
190
+ try:
191
+ data = client.profile(username)
192
+ _save_refreshed_tokens(client)
193
+ user = User.from_api(data.get("user", data))
194
+ if _use_json(json_flag):
195
+ print_json(user)
196
+ else:
197
+ render_user(user)
198
+ except requests.HTTPError as e:
199
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
200
+ raise SystemExit(1)
201
+
202
+
203
+ @cli.command()
204
+ @click.argument("user_id")
205
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
206
+ def followers(user_id: str, json_flag: bool) -> None:
207
+ """List a user's followers."""
208
+ client = _get_client()
209
+ try:
210
+ data = client.followers(user_id)
211
+ _save_refreshed_tokens(client)
212
+ users = parse_user_list(data)
213
+ if _use_json(json_flag):
214
+ print_json(users)
215
+ else:
216
+ render_user_list(users, title="Followers")
217
+ except requests.HTTPError as e:
218
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
219
+ raise SystemExit(1)
220
+
221
+
222
+ @cli.command()
223
+ @click.argument("user_id")
224
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
225
+ def following(user_id: str, json_flag: bool) -> None:
226
+ """List users someone is following."""
227
+ client = _get_client()
228
+ try:
229
+ data = client.following(user_id)
230
+ _save_refreshed_tokens(client)
231
+ users = parse_user_list(data)
232
+ if _use_json(json_flag):
233
+ print_json(users)
234
+ else:
235
+ render_user_list(users, title="Following")
236
+ except requests.HTTPError as e:
237
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
238
+ raise SystemExit(1)
239
+
240
+
241
+ @cli.command()
242
+ @click.option("--unread-only", is_flag=True, help="Show only unread count.")
243
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
244
+ def notifications(unread_only: bool, json_flag: bool) -> None:
245
+ """View notifications."""
246
+ client = _get_client()
247
+ try:
248
+ if unread_only:
249
+ data = client.unread_notifications()
250
+ _save_refreshed_tokens(client)
251
+ if _use_json(json_flag):
252
+ print_json(data)
253
+ else:
254
+ console.print(f"Unread notifications: [bold]{data}[/bold]")
255
+ else:
256
+ data = client.list_notifications()
257
+ _save_refreshed_tokens(client)
258
+ notifs = parse_notifications(data)
259
+ if _use_json(json_flag):
260
+ print_json(notifs)
261
+ else:
262
+ render_notifications(notifs)
263
+ except requests.HTTPError as e:
264
+ console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
265
+ raise SystemExit(1)
@@ -0,0 +1,124 @@
1
+ """Rich terminal rendering — tables, panels, formatted output."""
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+ from jike_cli.models import Comment, Notification, Post, User
9
+
10
+ console = Console()
11
+
12
+
13
+ def _fmt_count(n: int) -> str:
14
+ if n >= 10000:
15
+ return f"{n / 10000:.1f}w"
16
+ if n >= 1000:
17
+ return f"{n / 1000:.1f}k"
18
+ return str(n)
19
+
20
+
21
+ def _truncate(s: str, max_len: int = 80) -> str:
22
+ if len(s) <= max_len:
23
+ return s
24
+ return s[: max_len - 1] + "…"
25
+
26
+
27
+ def render_post(post: Post) -> None:
28
+ name = post.user.screen_name if post.user else "Unknown"
29
+ username = f"@{post.user.username}" if post.user else ""
30
+
31
+ header = Text()
32
+ header.append(name, style="bold cyan")
33
+ header.append(f" {username}", style="dim")
34
+ header.append(f" {post.created_at[:10]}", style="dim")
35
+
36
+ content = post.content or "(no content)"
37
+ if post.link_title:
38
+ content += f"\n🔗 {post.link_title}"
39
+
40
+ stats = (
41
+ f"❤ {_fmt_count(post.like_count)} "
42
+ f"💬 {_fmt_count(post.comment_count)} "
43
+ f"🔄 {_fmt_count(post.share_count)}"
44
+ )
45
+
46
+ panel = Panel(
47
+ f"{content}\n\n[dim]{stats}[/dim]",
48
+ title=header,
49
+ subtitle=f"[dim]{post.id}[/dim]",
50
+ border_style="blue",
51
+ padding=(0, 1),
52
+ )
53
+ console.print(panel)
54
+
55
+
56
+ def render_posts(posts: list[Post]) -> None:
57
+ if not posts:
58
+ console.print("[dim]No posts found.[/dim]")
59
+ return
60
+ for post in posts:
61
+ render_post(post)
62
+
63
+
64
+ def render_user(user: User) -> None:
65
+ table = Table(show_header=False, box=None, padding=(0, 2))
66
+ table.add_column(style="bold")
67
+ table.add_column()
68
+
69
+ table.add_row("Name", user.screen_name)
70
+ table.add_row("Username", f"@{user.username}")
71
+ table.add_row("ID", user.id)
72
+ if user.bio:
73
+ table.add_row("Bio", user.bio)
74
+ if user.gender:
75
+ table.add_row("Gender", user.gender)
76
+ table.add_row("Followers", _fmt_count(user.followers_count))
77
+ table.add_row("Following", _fmt_count(user.following_count))
78
+
79
+ panel = Panel(table, title="[bold cyan]Profile[/bold cyan]", border_style="blue")
80
+ console.print(panel)
81
+
82
+
83
+ def render_user_list(users: list[User], title: str = "Users") -> None:
84
+ if not users:
85
+ console.print("[dim]No users found.[/dim]")
86
+ return
87
+
88
+ table = Table(title=title)
89
+ table.add_column("Name", style="cyan")
90
+ table.add_column("Username", style="dim")
91
+ table.add_column("Bio")
92
+
93
+ for u in users:
94
+ table.add_row(u.screen_name, f"@{u.username}", _truncate(u.bio, 50))
95
+
96
+ console.print(table)
97
+
98
+
99
+ def render_notifications(notifications: list[Notification]) -> None:
100
+ if not notifications:
101
+ console.print("[dim]No notifications.[/dim]")
102
+ return
103
+
104
+ table = Table(title="Notifications")
105
+ table.add_column("Type", style="yellow")
106
+ table.add_column("From", style="cyan")
107
+ table.add_column("Content")
108
+ table.add_column("Time", style="dim")
109
+
110
+ for n in notifications:
111
+ name = n.user.screen_name if n.user else ""
112
+ table.add_row(n.type, name, _truncate(n.content, 60), n.created_at[:10])
113
+
114
+ console.print(table)
115
+
116
+
117
+ def render_comment(comment: Comment) -> None:
118
+ name = comment.user.screen_name if comment.user else "Unknown"
119
+ console.print(
120
+ f"[bold cyan]{name}[/bold cyan] [dim]{comment.created_at[:10]}[/dim]"
121
+ )
122
+ console.print(f" {comment.content}")
123
+ console.print(f" [dim]❤ {_fmt_count(comment.like_count)} ID: {comment.id}[/dim]")
124
+ console.print()
@@ -0,0 +1,191 @@
1
+ """Data models and API response parsers."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class User:
9
+ id: str
10
+ username: str
11
+ screen_name: str
12
+ avatar_url: str = ""
13
+ bio: str = ""
14
+ gender: str = ""
15
+ is_following: bool = False
16
+ is_banned: bool = False
17
+ followers_count: int = 0
18
+ following_count: int = 0
19
+ highlights_count: int = 0
20
+
21
+ @classmethod
22
+ def from_api(cls, data: dict) -> "User":
23
+ return cls(
24
+ id=data.get("id", ""),
25
+ username=data.get("username", ""),
26
+ screen_name=data.get("screenName", ""),
27
+ avatar_url=data.get("avatarImage", {}).get("picUrl", "") if isinstance(data.get("avatarImage"), dict) else "",
28
+ bio=data.get("bio", ""),
29
+ gender=data.get("gender", ""),
30
+ is_following=data.get("following", False),
31
+ is_banned=data.get("isBanned", False),
32
+ followers_count=data.get("statsCount", {}).get("followedCount", 0) if isinstance(data.get("statsCount"), dict) else 0,
33
+ following_count=data.get("statsCount", {}).get("followingCount", 0) if isinstance(data.get("statsCount"), dict) else 0,
34
+ highlights_count=data.get("statsCount", {}).get("highlightedPersonalUpdates", 0) if isinstance(data.get("statsCount"), dict) else 0,
35
+ )
36
+
37
+ def to_dict(self) -> dict:
38
+ return {
39
+ "id": self.id,
40
+ "username": self.username,
41
+ "screen_name": self.screen_name,
42
+ "bio": self.bio,
43
+ "followers_count": self.followers_count,
44
+ "following_count": self.following_count,
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class Post:
50
+ id: str
51
+ type: str
52
+ content: str
53
+ user: Optional[User] = None
54
+ created_at: str = ""
55
+ like_count: int = 0
56
+ comment_count: int = 0
57
+ share_count: int = 0
58
+ pictures: list[str] = field(default_factory=list)
59
+ link_url: str = ""
60
+ link_title: str = ""
61
+
62
+ @classmethod
63
+ def from_api(cls, data: dict) -> "Post":
64
+ user_data = data.get("user")
65
+ pictures = []
66
+ for pic in data.get("pictureKeys", []):
67
+ if isinstance(pic, str):
68
+ pictures.append(pic)
69
+ for pic in data.get("pictures", []):
70
+ if isinstance(pic, dict) and pic.get("picUrl"):
71
+ pictures.append(pic["picUrl"])
72
+
73
+ link_info = data.get("linkInfo") or {}
74
+
75
+ return cls(
76
+ id=data.get("id", ""),
77
+ type=data.get("type", "ORIGINAL_POST"),
78
+ content=data.get("content", ""),
79
+ user=User.from_api(user_data) if user_data else None,
80
+ created_at=data.get("createdAt", ""),
81
+ like_count=data.get("likeCount", 0),
82
+ comment_count=data.get("commentCount", 0),
83
+ share_count=data.get("shareCount", 0),
84
+ pictures=pictures,
85
+ link_url=link_info.get("linkUrl", ""),
86
+ link_title=link_info.get("title", ""),
87
+ )
88
+
89
+ def to_dict(self) -> dict:
90
+ return {
91
+ "id": self.id,
92
+ "type": self.type,
93
+ "content": self.content,
94
+ "user": self.user.to_dict() if self.user else None,
95
+ "created_at": self.created_at,
96
+ "like_count": self.like_count,
97
+ "comment_count": self.comment_count,
98
+ "share_count": self.share_count,
99
+ "pictures": self.pictures,
100
+ "link_url": self.link_url,
101
+ "link_title": self.link_title,
102
+ }
103
+
104
+
105
+ @dataclass
106
+ class Comment:
107
+ id: str
108
+ content: str
109
+ user: Optional[User] = None
110
+ created_at: str = ""
111
+ like_count: int = 0
112
+
113
+ @classmethod
114
+ def from_api(cls, data: dict) -> "Comment":
115
+ user_data = data.get("user")
116
+ return cls(
117
+ id=data.get("id", ""),
118
+ content=data.get("content", ""),
119
+ user=User.from_api(user_data) if user_data else None,
120
+ created_at=data.get("createdAt", ""),
121
+ like_count=data.get("likeCount", 0),
122
+ )
123
+
124
+ def to_dict(self) -> dict:
125
+ return {
126
+ "id": self.id,
127
+ "content": self.content,
128
+ "user": self.user.to_dict() if self.user else None,
129
+ "created_at": self.created_at,
130
+ "like_count": self.like_count,
131
+ }
132
+
133
+
134
+ @dataclass
135
+ class Notification:
136
+ id: str
137
+ type: str
138
+ content: str
139
+ user: Optional[User] = None
140
+ created_at: str = ""
141
+ referral_type: str = ""
142
+
143
+ @classmethod
144
+ def from_api(cls, data: dict) -> "Notification":
145
+ user_data = data.get("actionItem", {}).get("users", [None])[0] if data.get("actionItem", {}).get("users") else None
146
+ action = data.get("actionItem", {})
147
+ return cls(
148
+ id=data.get("id", ""),
149
+ type=data.get("type", ""),
150
+ content=action.get("content", "") or data.get("content", ""),
151
+ user=User.from_api(user_data) if user_data else None,
152
+ created_at=data.get("createdAt", ""),
153
+ referral_type=data.get("referralType", ""),
154
+ )
155
+
156
+ def to_dict(self) -> dict:
157
+ return {
158
+ "id": self.id,
159
+ "type": self.type,
160
+ "content": self.content,
161
+ "user": self.user.to_dict() if self.user else None,
162
+ "created_at": self.created_at,
163
+ }
164
+
165
+
166
+ def parse_feed(data: dict) -> list[Post]:
167
+ posts = []
168
+ for item in data.get("data", []):
169
+ target = item
170
+ if item.get("type") == "REPOST" and item.get("target"):
171
+ target = item["target"]
172
+ posts.append(Post.from_api(target))
173
+ return posts
174
+
175
+
176
+ def parse_search(data: dict) -> list[Post]:
177
+ posts = []
178
+ for item in data.get("data", []):
179
+ if item.get("type") in ("ORIGINAL_POST", "REPOST"):
180
+ posts.append(Post.from_api(item))
181
+ elif item.get("target"):
182
+ posts.append(Post.from_api(item["target"]))
183
+ return posts
184
+
185
+
186
+ def parse_notifications(data: dict) -> list[Notification]:
187
+ return [Notification.from_api(n) for n in data.get("data", [])]
188
+
189
+
190
+ def parse_user_list(data: dict) -> list[User]:
191
+ return [User.from_api(u) for u in data.get("data", [])]
@@ -0,0 +1,18 @@
1
+ """Output utilities — JSON mode, TTY detection."""
2
+
3
+ import json
4
+ import sys
5
+
6
+
7
+ def is_tty() -> bool:
8
+ return sys.stdout.isatty()
9
+
10
+
11
+ def print_json(data: object) -> None:
12
+ if isinstance(data, list):
13
+ output = [item.to_dict() if hasattr(item, "to_dict") else item for item in data]
14
+ elif hasattr(data, "to_dict"):
15
+ output = data.to_dict()
16
+ else:
17
+ output = data
18
+ print(json.dumps(output, ensure_ascii=False, indent=2))