moltcli 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.
moltcli/cli.py CHANGED
@@ -13,12 +13,6 @@ from .core import (
13
13
  )
14
14
 
15
15
 
16
- def get_client() -> MoltbookClient:
17
- """Create API client from config."""
18
- config = get_config()
19
- return MoltbookClient(config.api_key)
20
-
21
-
22
16
  def make_formatter(json_mode: bool) -> OutputFormatter:
23
17
  """Create output formatter."""
24
18
  return OutputFormatter(json_mode=json_mode)
@@ -33,7 +27,20 @@ def cli(ctx: click.Context, json_mode: bool):
33
27
  ctx.ensure_object(dict)
34
28
  ctx.obj["json_mode"] = json_mode
35
29
  ctx.obj["formatter"] = make_formatter(json_mode)
36
- ctx.obj["client"] = get_client()
30
+ # Client is lazily loaded when needed (commands that require auth)
31
+
32
+
33
+ def get_client() -> MoltbookClient:
34
+ """Create API client from config."""
35
+ config = get_config()
36
+ return MoltbookClient(config.api_key)
37
+
38
+
39
+ def ensure_client(ctx: click.Context) -> MoltbookClient:
40
+ """Ensure client is available in context."""
41
+ if "client" not in ctx.obj or ctx.obj["client"] is None:
42
+ ctx.obj["client"] = get_client()
43
+ return ctx.obj["client"]
37
44
 
38
45
 
39
46
  # auth command group
@@ -47,8 +54,8 @@ def auth():
47
54
  @click.pass_context
48
55
  def auth_whoami(ctx: click.Context):
49
56
  """Show current user info."""
50
- client: MoltbookClient = ctx.obj["client"]
51
- formatter: OutputFormatter = ctx.obj["formatter"]
57
+ client = ensure_client(ctx)
58
+ formatter = ctx.obj["formatter"]
52
59
  try:
53
60
  result = AuthCore(client).whoami()
54
61
  formatter.print(result)
@@ -63,8 +70,8 @@ def auth_whoami(ctx: click.Context):
63
70
  @click.pass_context
64
71
  def auth_verify(ctx: click.Context):
65
72
  """Verify API key is valid."""
66
- client: MoltbookClient = ctx.obj["client"]
67
- formatter: OutputFormatter = ctx.obj["formatter"]
73
+ client = ensure_client(ctx)
74
+ formatter = ctx.obj["formatter"]
68
75
  try:
69
76
  result = AuthCore(client).verify()
70
77
  formatter.print({"status": "valid", "message": "API key is valid"})
@@ -75,6 +82,289 @@ def auth_verify(ctx: click.Context):
75
82
  raise
76
83
 
77
84
 
85
+ @auth.command("login")
86
+ @click.argument("api_key")
87
+ @click.option("--agent-name", help="Agent name (optional)")
88
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON")
89
+ @click.pass_context
90
+ def auth_login(ctx: click.Context, api_key: str, agent_name: str, json_mode: bool):
91
+ """Save API key to credentials file.
92
+
93
+ After registering, use this command to save your API key:
94
+ moltcli auth login moltbook_xxx
95
+
96
+ The credentials will be saved to ~/.config/moltbook/credentials.json
97
+ """
98
+ from .utils import Config
99
+ formatter = OutputFormatter(json_mode=json_mode)
100
+
101
+ try:
102
+ config = Config()
103
+ config.save(api_key, agent_name if agent_name else "")
104
+ formatter.print({
105
+ "status": "saved",
106
+ "config_path": str(config.config_path),
107
+ "message": "Credentials saved successfully"
108
+ })
109
+ if not json_mode:
110
+ click.echo(f"\nCredentials saved to: {config.config_path}")
111
+ click.echo("You can now use other moltcli commands.")
112
+ except FileExistsError as e:
113
+ if json_mode:
114
+ formatter.print({"status": "error", "message": str(e)})
115
+ else:
116
+ click.echo(f"Error: {e}", err=True)
117
+ sys.exit(1)
118
+ except Exception as e:
119
+ if json_mode:
120
+ formatter.print({"status": "error", "message": str(e)})
121
+ else:
122
+ click.echo(f"Error: {e}", err=True)
123
+ sys.exit(1)
124
+
125
+
126
+ @auth.command("logout")
127
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON")
128
+ @click.pass_context
129
+ def auth_logout(ctx: click.Context, json_mode: bool):
130
+ """Remove credentials file."""
131
+ from .utils import Config
132
+ formatter = OutputFormatter(json_mode=json_mode)
133
+
134
+ try:
135
+ config = Config()
136
+ if config.exists():
137
+ config.remove()
138
+ formatter.print({
139
+ "status": "removed",
140
+ "message": "Credentials removed successfully"
141
+ })
142
+ if not json_mode:
143
+ click.echo("Credentials removed.")
144
+ else:
145
+ formatter.print({
146
+ "status": "skipped",
147
+ "message": "No credentials file found"
148
+ })
149
+ if not json_mode:
150
+ click.echo("No credentials file to remove.")
151
+ except Exception as e:
152
+ if json_mode:
153
+ formatter.print({"status": "error", "message": str(e)})
154
+ else:
155
+ click.echo(f"Error: {e}", err=True)
156
+
157
+
158
+ # Registration commands (no auth required)
159
+ @cli.command("register")
160
+ @click.argument("name")
161
+ @click.option("--description", default="", help="Agent description")
162
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON")
163
+ def register(name: str, description: str, json_mode: bool):
164
+ """Register a new agent.
165
+
166
+ This creates a new agent account. You'll receive an api_key, claim_url, and verification_code.
167
+
168
+ IMPORTANT: Save your api_key immediately! You need it for all future requests.
169
+
170
+ After registering, send the claim_url to your human to complete verification.
171
+ """
172
+ from .utils import MoltbookClient, OutputFormatter, Config
173
+ from .core import AgentCore
174
+
175
+ client = MoltbookClient("") # No auth needed for register
176
+ formatter = OutputFormatter(json_mode=json_mode)
177
+
178
+ try:
179
+ result = AgentCore(client).register(name, description)
180
+ formatter.print(result)
181
+
182
+ if "agent" in result:
183
+ agent = result["agent"]
184
+ api_key = agent.get("api_key")
185
+ claim_url = agent.get("claim_url")
186
+ verification_code = agent.get("verification_code")
187
+
188
+ # Auto-save credentials
189
+ config = Config()
190
+ config.save(api_key, name)
191
+
192
+ if json_mode:
193
+ formatter.print({
194
+ "status": "credentials_saved",
195
+ "config_path": str(config.config_path)
196
+ })
197
+ else:
198
+ click.echo("\n" + "=" * 50)
199
+ click.echo("Credentials saved to: " + str(config.config_path))
200
+ click.echo("=" * 50)
201
+ click.echo(f"\nNext steps:")
202
+ click.echo(f"1. Send this to your human to complete verification:")
203
+ click.echo(f" Claim URL: {claim_url}")
204
+ click.echo(f" Verification Code: {verification_code}")
205
+ click.echo(f"\n2. Your human will post a verification tweet,")
206
+ click.echo(f" then your account will be activated!")
207
+ click.echo(f"\n3. Check status with: moltcli status")
208
+ click.echo("=" * 50)
209
+ except FileExistsError:
210
+ if json_mode:
211
+ formatter.print({
212
+ "status": "error",
213
+ "message": "Credentials already exist. Use 'moltcli auth logout' first."
214
+ })
215
+ else:
216
+ click.echo("Error: Credentials already exist. Use 'moltcli auth logout' first.", err=True)
217
+ except Exception as e:
218
+ if json_mode:
219
+ formatter.print(handle_error(e))
220
+ else:
221
+ click.echo(f"Error: {e}", err=True)
222
+
223
+
224
+ @cli.command("status")
225
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON")
226
+ @click.pass_context
227
+ def claim_status(ctx: click.Context, json_mode: bool):
228
+ """Check your claim status (pending or claimed).
229
+
230
+ After registering, you need to be 'claimed' by your human via Twitter.
231
+ """
232
+ from .utils import OutputFormatter
233
+ from .core import AgentCore
234
+
235
+ client = ensure_client(ctx)
236
+ formatter = OutputFormatter(json_mode=json_mode)
237
+
238
+ try:
239
+ result = AgentCore(client).get_status()
240
+ formatter.print(result)
241
+
242
+ status = result.get("status", "")
243
+ if not json_mode:
244
+ if status == "pending_claim":
245
+ click.echo("\nStatus: PENDING - Awaiting human verification")
246
+ elif status == "claimed":
247
+ click.echo("\nStatus: CLAIMED - Your account is active!")
248
+ else:
249
+ click.echo(f"\nStatus: {status}")
250
+ except Exception as e:
251
+ if json_mode:
252
+ formatter.print(handle_error(e))
253
+ else:
254
+ click.echo(f"Error: {e}", err=True)
255
+
256
+
257
+ # agent command group
258
+ @cli.group()
259
+ def agent():
260
+ """Agent profile and following."""
261
+ pass
262
+
263
+
264
+ @agent.command("me")
265
+ @click.pass_context
266
+ def agent_me(ctx: click.Context):
267
+ """Get current agent info."""
268
+ client = ensure_client(ctx)
269
+ formatter: OutputFormatter = ctx.obj["formatter"]
270
+ try:
271
+ result = AgentCore(client).get_me()
272
+ formatter.print(result)
273
+ except Exception as e:
274
+ if ctx.obj["json_mode"]:
275
+ formatter.print(handle_error(e))
276
+ sys.exit(1)
277
+ raise
278
+
279
+
280
+ @agent.command("profile")
281
+ @click.argument("name")
282
+ @click.pass_context
283
+ def agent_profile(ctx: click.Context, name: str):
284
+ """Get another agent's profile."""
285
+ client = ensure_client(ctx)
286
+ formatter: OutputFormatter = ctx.obj["formatter"]
287
+ try:
288
+ result = AgentCore(client).get_profile(name)
289
+ formatter.print(result)
290
+ except Exception as e:
291
+ if ctx.obj["json_mode"]:
292
+ formatter.print(handle_error(e))
293
+ sys.exit(1)
294
+ raise
295
+
296
+
297
+ @agent.command("follow")
298
+ @click.argument("name")
299
+ @click.pass_context
300
+ def agent_follow(ctx: click.Context, name: str):
301
+ """Follow an agent."""
302
+ client = ensure_client(ctx)
303
+ formatter: OutputFormatter = ctx.obj["formatter"]
304
+ try:
305
+ result = AgentCore(client).follow(name)
306
+ formatter.print({"status": "following", "agent": name})
307
+ except Exception as e:
308
+ if ctx.obj["json_mode"]:
309
+ formatter.print(handle_error(e))
310
+ sys.exit(1)
311
+ raise
312
+
313
+
314
+ @agent.command("unfollow")
315
+ @click.argument("name")
316
+ @click.pass_context
317
+ def agent_unfollow(ctx: click.Context, name: str):
318
+ """Unfollow an agent."""
319
+ client = ensure_client(ctx)
320
+ formatter: OutputFormatter = ctx.obj["formatter"]
321
+ try:
322
+ result = AgentCore(client).unfollow(name)
323
+ formatter.print({"status": "unfollowed", "agent": name})
324
+ except Exception as e:
325
+ if ctx.obj["json_mode"]:
326
+ formatter.print(handle_error(e))
327
+ sys.exit(1)
328
+ raise
329
+
330
+
331
+ @agent.command("update")
332
+ @click.option("--description", help="Update your description")
333
+ @click.option("--metadata", help="Update metadata as JSON string")
334
+ @click.pass_context
335
+ def agent_update(ctx: click.Context, description: str, metadata: str):
336
+ """Update your agent profile.
337
+
338
+ Note: You can only update one of description or metadata at a time.
339
+ """
340
+ import json
341
+ client = ensure_client(ctx)
342
+ formatter: OutputFormatter = ctx.obj["formatter"]
343
+
344
+ meta_dict = None
345
+ if metadata:
346
+ try:
347
+ meta_dict = json.loads(metadata)
348
+ except json.JSONDecodeError:
349
+ if ctx.obj["json_mode"]:
350
+ formatter.print({"error": "Invalid JSON for --metadata"})
351
+ else:
352
+ click.echo("Error: Invalid JSON for --metadata", err=True)
353
+ return
354
+
355
+ try:
356
+ result = AgentCore(client).update_profile(
357
+ description=description if description else None,
358
+ metadata=meta_dict
359
+ )
360
+ formatter.print({"status": "updated"})
361
+ except Exception as e:
362
+ if ctx.obj["json_mode"]:
363
+ formatter.print(handle_error(e))
364
+ else:
365
+ click.echo(f"Error: {e}", err=True)
366
+
367
+
78
368
  # post command group
79
369
  @cli.group()
80
370
  def post():
@@ -90,7 +380,7 @@ def post():
90
380
  @click.pass_context
91
381
  def post_create(ctx: click.Context, submolt: str, title: str, content: str, url: str):
92
382
  """Create a new post."""
93
- client: MoltbookClient = ctx.obj["client"]
383
+ client = ensure_client(ctx)
94
384
  formatter: OutputFormatter = ctx.obj["formatter"]
95
385
  try:
96
386
  result = PostCore(client).create(submolt=submolt, title=title, content=content, url=url)
@@ -107,7 +397,7 @@ def post_create(ctx: click.Context, submolt: str, title: str, content: str, url:
107
397
  @click.pass_context
108
398
  def post_get(ctx: click.Context, post_id: str):
109
399
  """Get a post by ID."""
110
- client: MoltbookClient = ctx.obj["client"]
400
+ client = ensure_client(ctx)
111
401
  formatter: OutputFormatter = ctx.obj["formatter"]
112
402
  try:
113
403
  result = PostCore(client).get(post_id)
@@ -124,7 +414,7 @@ def post_get(ctx: click.Context, post_id: str):
124
414
  @click.pass_context
125
415
  def post_delete(ctx: click.Context, post_id: str):
126
416
  """Delete a post."""
127
- client: MoltbookClient = ctx.obj["client"]
417
+ client = ensure_client(ctx)
128
418
  formatter: OutputFormatter = ctx.obj["formatter"]
129
419
  try:
130
420
  result = PostCore(client).delete(post_id)
@@ -149,8 +439,11 @@ def comment():
149
439
  @click.option("--parent", help="Parent comment ID for replies")
150
440
  @click.pass_context
151
441
  def comment_create(ctx: click.Context, post_id: str, content: str, parent: str):
152
- """Create a comment on a post."""
153
- client: MoltbookClient = ctx.obj["client"]
442
+ """Create a comment on a post.
443
+
444
+ Use --parent to reply to a specific comment.
445
+ """
446
+ client = ensure_client(ctx)
154
447
  formatter: OutputFormatter = ctx.obj["formatter"]
155
448
  try:
156
449
  result = CommentCore(client).create(post_id=post_id, content=content, parent_id=parent)
@@ -162,13 +455,32 @@ def comment_create(ctx: click.Context, post_id: str, content: str, parent: str):
162
455
  raise
163
456
 
164
457
 
458
+ @comment.command("reply")
459
+ @click.argument("post_id")
460
+ @click.argument("parent_id")
461
+ @click.option("--content", required=True, help="Reply content")
462
+ @click.pass_context
463
+ def comment_reply(ctx: click.Context, post_id: str, parent_id: str, content: str):
464
+ """Reply to a comment."""
465
+ client = ensure_client(ctx)
466
+ formatter: OutputFormatter = ctx.obj["formatter"]
467
+ try:
468
+ result = CommentCore(client).create(post_id=post_id, content=content, parent_id=parent_id)
469
+ formatter.print(result)
470
+ except Exception as e:
471
+ if ctx.obj["json_mode"]:
472
+ formatter.print(handle_error(e))
473
+ sys.exit(1)
474
+ raise
475
+
476
+
165
477
  @comment.command("list")
166
478
  @click.argument("post_id")
167
479
  @click.option("--limit", default=50, help="Max comments to show")
168
480
  @click.pass_context
169
481
  def comment_list(ctx: click.Context, post_id: str, limit: int):
170
482
  """List comments for a post."""
171
- client: MoltbookClient = ctx.obj["client"]
483
+ client = ensure_client(ctx)
172
484
  formatter: OutputFormatter = ctx.obj["formatter"]
173
485
  try:
174
486
  result = CommentCore(client).list_by_post(post_id, limit=limit)
@@ -187,14 +499,14 @@ def feed():
187
499
  pass
188
500
 
189
501
 
190
- @feed.command()
502
+ @feed.command("get")
191
503
  @click.option("--sort", type=click.Choice(["hot", "new"]), default="hot", help="Sort order")
192
504
  @click.option("--limit", default=20, help="Max posts to show")
193
505
  @click.option("--submolt", help="Filter by submolt")
194
506
  @click.pass_context
195
507
  def feed_get(ctx: click.Context, sort: str, limit: int, submolt: str):
196
508
  """Get feed posts."""
197
- client: MoltbookClient = ctx.obj["client"]
509
+ client = ensure_client(ctx)
198
510
  formatter: OutputFormatter = ctx.obj["formatter"]
199
511
  try:
200
512
  result = FeedCore(client).get(sort=sort, limit=limit, submolt=submolt)
@@ -211,7 +523,7 @@ def feed_get(ctx: click.Context, sort: str, limit: int, submolt: str):
211
523
  @click.pass_context
212
524
  def feed_hot(ctx: click.Context, limit: int):
213
525
  """Get hot posts."""
214
- client: MoltbookClient = ctx.obj["client"]
526
+ client = ensure_client(ctx)
215
527
  formatter: OutputFormatter = ctx.obj["formatter"]
216
528
  try:
217
529
  result = FeedCore(client).get_hot(limit=limit)
@@ -228,7 +540,7 @@ def feed_hot(ctx: click.Context, limit: int):
228
540
  @click.pass_context
229
541
  def feed_new(ctx: click.Context, limit: int):
230
542
  """Get newest posts."""
231
- client: MoltbookClient = ctx.obj["client"]
543
+ client = ensure_client(ctx)
232
544
  formatter: OutputFormatter = ctx.obj["formatter"]
233
545
  try:
234
546
  result = FeedCore(client).get_new(limit=limit)
@@ -247,17 +559,17 @@ def search():
247
559
  pass
248
560
 
249
561
 
250
- @search.command()
562
+ @search.command("query")
251
563
  @click.argument("query")
252
- @click.option("--type", type=click.Choice(["posts", "users"]), default="posts", help="Search type")
564
+ @click.option("--type", "search_type", type=click.Choice(["posts", "users"]), default="posts", help="Search type")
253
565
  @click.option("--limit", default=20, help="Max results")
254
566
  @click.pass_context
255
- def search_query(ctx: click.Context, query: str, type_: str, limit: int):
567
+ def search_query(ctx: click.Context, query: str, search_type: str, limit: int):
256
568
  """Search posts or users."""
257
- client: MoltbookClient = ctx.obj["client"]
569
+ client = ensure_client(ctx)
258
570
  formatter: OutputFormatter = ctx.obj["formatter"]
259
571
  try:
260
- result = SearchCore(client).search(query=query, type_=type_, limit=limit)
572
+ result = SearchCore(client).search(query=query, type_=search_type, limit=limit)
261
573
  formatter.print(result)
262
574
  except Exception as e:
263
575
  if ctx.obj["json_mode"]:
@@ -275,15 +587,15 @@ def vote():
275
587
 
276
588
  @vote.command("up")
277
589
  @click.argument("item_id")
278
- @click.option("--type", type=click.Choice(["post", "comment"]), default="post", help="Item type")
590
+ @click.option("--type", "item_type", type=click.Choice(["post", "comment"]), default="post", help="Item type")
279
591
  @click.pass_context
280
- def vote_up(ctx: click.Context, item_id: str, type_: str):
592
+ def vote_up(ctx: click.Context, item_id: str, item_type: str):
281
593
  """Upvote a post or comment."""
282
- client: MoltbookClient = ctx.obj["client"]
594
+ client = ensure_client(ctx)
283
595
  formatter: OutputFormatter = ctx.obj["formatter"]
284
596
  try:
285
- result = VoteCore(client).upvote(item_id, type_=type_)
286
- formatter.print({"status": "upvoted", "id": item_id})
597
+ result = VoteCore(client).upvote(item_id, type_=item_type)
598
+ formatter.print({"status": "upvoted", "id": item_id, "type": item_type})
287
599
  except Exception as e:
288
600
  if ctx.obj["json_mode"]:
289
601
  formatter.print(handle_error(e))
@@ -293,15 +605,49 @@ def vote_up(ctx: click.Context, item_id: str, type_: str):
293
605
 
294
606
  @vote.command("down")
295
607
  @click.argument("item_id")
296
- @click.option("--type", type=click.Choice(["post", "comment"]), default="post", help="Item type")
608
+ @click.option("--type", "item_type", type=click.Choice(["post", "comment"]), default="post", help="Item type")
297
609
  @click.pass_context
298
- def vote_down(ctx: click.Context, item_id: str, type_: str):
610
+ def vote_down(ctx: click.Context, item_id: str, item_type: str):
299
611
  """Downvote a post or comment."""
300
- client: MoltbookClient = ctx.obj["client"]
612
+ client = ensure_client(ctx)
613
+ formatter: OutputFormatter = ctx.obj["formatter"]
614
+ try:
615
+ result = VoteCore(client).downvote(item_id, type_=item_type)
616
+ formatter.print({"status": "downvoted", "id": item_id, "type": item_type})
617
+ except Exception as e:
618
+ if ctx.obj["json_mode"]:
619
+ formatter.print(handle_error(e))
620
+ sys.exit(1)
621
+ raise
622
+
623
+
624
+ @vote.command("up-comment")
625
+ @click.argument("comment_id")
626
+ @click.pass_context
627
+ def vote_up_comment(ctx: click.Context, comment_id: str):
628
+ """Upvote a comment."""
629
+ client = ensure_client(ctx)
630
+ formatter: OutputFormatter = ctx.obj["formatter"]
631
+ try:
632
+ result = VoteCore(client).upvote_comment(comment_id)
633
+ formatter.print({"status": "upvoted", "id": comment_id, "type": "comment"})
634
+ except Exception as e:
635
+ if ctx.obj["json_mode"]:
636
+ formatter.print(handle_error(e))
637
+ sys.exit(1)
638
+ raise
639
+
640
+
641
+ @vote.command("down-comment")
642
+ @click.argument("comment_id")
643
+ @click.pass_context
644
+ def vote_down_comment(ctx: click.Context, comment_id: str):
645
+ """Downvote a comment."""
646
+ client = ensure_client(ctx)
301
647
  formatter: OutputFormatter = ctx.obj["formatter"]
302
648
  try:
303
- result = VoteCore(client).downvote(item_id, type_=type_)
304
- formatter.print({"status": "downvoted", "id": item_id})
649
+ result = VoteCore(client).downvote_comment(comment_id)
650
+ formatter.print({"status": "downvoted", "id": comment_id, "type": "comment"})
305
651
  except Exception as e:
306
652
  if ctx.obj["json_mode"]:
307
653
  formatter.print(handle_error(e))
@@ -321,7 +667,7 @@ def submolts():
321
667
  @click.pass_context
322
668
  def submolts_list(ctx: click.Context, limit: int):
323
669
  """List all submolts."""
324
- client: MoltbookClient = ctx.obj["client"]
670
+ client = ensure_client(ctx)
325
671
  formatter: OutputFormatter = ctx.obj["formatter"]
326
672
  try:
327
673
  result = SubmoltsCore(client).list(limit=limit)
@@ -333,12 +679,67 @@ def submolts_list(ctx: click.Context, limit: int):
333
679
  raise
334
680
 
335
681
 
682
+ @submolts.command("get")
683
+ @click.argument("name")
684
+ @click.pass_context
685
+ def submolts_get(ctx: click.Context, name: str):
686
+ """Get submolt info."""
687
+ client = ensure_client(ctx)
688
+ formatter: OutputFormatter = ctx.obj["formatter"]
689
+ try:
690
+ result = SubmoltsCore(client).get(name)
691
+ formatter.print(result)
692
+ except Exception as e:
693
+ if ctx.obj["json_mode"]:
694
+ formatter.print(handle_error(e))
695
+ sys.exit(1)
696
+ raise
697
+
698
+
699
+ @submolts.command("create")
700
+ @click.argument("name")
701
+ @click.argument("display_name")
702
+ @click.option("--description", default="", help="Submolt description")
703
+ @click.pass_context
704
+ def submolts_create(ctx: click.Context, name: str, display_name: str, description: str):
705
+ """Create a new submolt."""
706
+ client = ensure_client(ctx)
707
+ formatter: OutputFormatter = ctx.obj["formatter"]
708
+ try:
709
+ result = SubmoltsCore(client).create(name, display_name, description)
710
+ formatter.print({"status": "created", "name": name, "display_name": display_name})
711
+ except Exception as e:
712
+ if ctx.obj["json_mode"]:
713
+ formatter.print(handle_error(e))
714
+ sys.exit(1)
715
+ raise
716
+
717
+
718
+ @submolts.command("feed")
719
+ @click.argument("name")
720
+ @click.option("--sort", type=click.Choice(["hot", "new", "top", "rising"]), default="hot", help="Sort order")
721
+ @click.option("--limit", default=20, help="Max posts")
722
+ @click.pass_context
723
+ def submolts_feed(ctx: click.Context, name: str, sort: str, limit: int):
724
+ """Get posts from a submolt."""
725
+ client = ensure_client(ctx)
726
+ formatter: OutputFormatter = ctx.obj["formatter"]
727
+ try:
728
+ result = SubmoltsCore(client).feed(name, sort=sort, limit=limit)
729
+ formatter.print(result)
730
+ except Exception as e:
731
+ if ctx.obj["json_mode"]:
732
+ formatter.print(handle_error(e))
733
+ sys.exit(1)
734
+ raise
735
+
736
+
336
737
  @submolts.command("subscribe")
337
738
  @click.argument("name")
338
739
  @click.pass_context
339
740
  def submolts_subscribe(ctx: click.Context, name: str):
340
741
  """Subscribe to a submolt."""
341
- client: MoltbookClient = ctx.obj["client"]
742
+ client = ensure_client(ctx)
342
743
  formatter: OutputFormatter = ctx.obj["formatter"]
343
744
  try:
344
745
  result = SubmoltsCore(client).subscribe(name)
@@ -355,7 +756,7 @@ def submolts_subscribe(ctx: click.Context, name: str):
355
756
  @click.pass_context
356
757
  def submolts_unsubscribe(ctx: click.Context, name: str):
357
758
  """Unsubscribe from a submolt."""
358
- client: MoltbookClient = ctx.obj["client"]
759
+ client = ensure_client(ctx)
359
760
  formatter: OutputFormatter = ctx.obj["formatter"]
360
761
  try:
361
762
  result = SubmoltsCore(client).unsubscribe(name)
@@ -367,6 +768,23 @@ def submolts_unsubscribe(ctx: click.Context, name: str):
367
768
  raise
368
769
 
369
770
 
771
+ @submolts.command("trending")
772
+ @click.option("--limit", default=10, help="Max submolts to show")
773
+ @click.pass_context
774
+ def submolts_trending(ctx: click.Context, limit: int):
775
+ """Get trending submolts."""
776
+ client = ensure_client(ctx)
777
+ formatter: OutputFormatter = ctx.obj["formatter"]
778
+ try:
779
+ result = SubmoltsCore(client).trending(limit=limit)
780
+ formatter.print(result)
781
+ except Exception as e:
782
+ if ctx.obj["json_mode"]:
783
+ formatter.print(handle_error(e))
784
+ sys.exit(1)
785
+ raise
786
+
787
+
370
788
  def main():
371
789
  """Entry point."""
372
790
  cli()
moltcli/core/__init__.py CHANGED
@@ -7,6 +7,7 @@ from .search import SearchCore
7
7
  from .vote import VoteCore
8
8
  from .submolts import SubmoltsCore
9
9
  from .auth import AuthCore
10
+ from .agent import AgentCore
10
11
 
11
12
  __all__ = [
12
13
  "PostCore",
@@ -16,4 +17,5 @@ __all__ = [
16
17
  "VoteCore",
17
18
  "SubmoltsCore",
18
19
  "AuthCore",
20
+ "AgentCore",
19
21
  ]
moltcli/core/agent.py ADDED
@@ -0,0 +1,60 @@
1
+ """Agent core logic."""
2
+ from typing import Optional
3
+ from ..utils.api_client import MoltbookClient
4
+
5
+
6
+ class AgentCore:
7
+ """Handle agent operations."""
8
+
9
+ def __init__(self, client: MoltbookClient):
10
+ self._client = client
11
+
12
+ def register(self, name: str, description: str = "") -> dict:
13
+ """Register a new agent.
14
+
15
+ This does NOT require authentication - it's how new agents sign up.
16
+
17
+ Args:
18
+ name: Unique agent name
19
+ description: Agent description
20
+
21
+ Returns:
22
+ Contains api_key, claim_url, and verification_code.
23
+ IMPORTANT: Save the api_key immediately!
24
+ """
25
+ return self._client.post("/agents/register", json_data={
26
+ "name": name,
27
+ "description": description,
28
+ })
29
+
30
+ def get_status(self) -> dict:
31
+ """Check claim status (pending or claimed)."""
32
+ return self._client.get("/agents/status")
33
+
34
+ def get_me(self) -> dict:
35
+ """Get current agent info."""
36
+ return self._client.get("/agents/me")
37
+
38
+ def get_profile(self, name: str) -> dict:
39
+ """Get another agent's profile."""
40
+ return self._client.get(f"/agents/profile?name={name}")
41
+
42
+ def follow(self, name: str) -> dict:
43
+ """Follow an agent."""
44
+ return self._client.post(f"/agents/{name}/follow")
45
+
46
+ def unfollow(self, name: str) -> dict:
47
+ """Unfollow an agent."""
48
+ return self._client.delete(f"/agents/{name}/follow")
49
+
50
+ def update_profile(self, description: str = None, metadata: dict = None) -> dict:
51
+ """Update current agent profile.
52
+
53
+ Note: API only allows one of description or metadata at a time.
54
+ """
55
+ data = {}
56
+ if description is not None:
57
+ data["description"] = description
58
+ if metadata is not None:
59
+ data["metadata"] = metadata
60
+ return self._client.patch("/agents/me", json_data=data)
moltcli/core/auth.py CHANGED
@@ -9,12 +9,12 @@ class AuthCore:
9
9
  self._client = client
10
10
 
11
11
  def whoami(self) -> dict:
12
- """Get current user info."""
13
- return self._client.get("/auth/whoami")
12
+ """Get current user info. Uses /agents/me endpoint."""
13
+ return self._client.get("/agents/me")
14
14
 
15
15
  def verify(self) -> dict:
16
- """Verify API key is valid."""
17
- return self._client.get("/auth/verify")
16
+ """Verify API key is valid. Uses /agents/me endpoint."""
17
+ return self._client.get("/agents/me")
18
18
 
19
19
  def refresh(self) -> dict:
20
20
  """Refresh authentication token."""
moltcli/core/submolts.py CHANGED
@@ -17,13 +17,40 @@ class SubmoltsCore:
17
17
  """Get a submolt by name."""
18
18
  return self._client.get(f"/submolts/{name}")
19
19
 
20
+ def create(self, name: str, display_name: str, description: str = "") -> dict:
21
+ """Create a new submolt.
22
+
23
+ Args:
24
+ name: Unique name (e.g., "aithoughts")
25
+ display_name: Display name (e.g., "AI Thoughts")
26
+ description: Submolt description
27
+ """
28
+ return self._client.post("/submolts", json_data={
29
+ "name": name,
30
+ "display_name": display_name,
31
+ "description": description,
32
+ })
33
+
34
+ def feed(self, name: str, sort: str = "hot", limit: int = 20) -> dict:
35
+ """Get feed from a specific submolt.
36
+
37
+ Args:
38
+ name: Submolt name
39
+ sort: Sort order (hot, new, top, rising)
40
+ limit: Max posts to return
41
+ """
42
+ return self._client.get(
43
+ f"/submolts/{name}/feed",
44
+ params={"sort": sort, "limit": limit}
45
+ )
46
+
20
47
  def subscribe(self, name: str) -> dict:
21
48
  """Subscribe to a submolt."""
22
- return self._client.post("/submolts/subscribe", json_data={"name": name})
49
+ return self._client.post(f"/submolts/{name}/subscribe")
23
50
 
24
51
  def unsubscribe(self, name: str) -> dict:
25
52
  """Unsubscribe from a submolt."""
26
- return self._client.post("/submolts/unsubscribe", json_data={"name": name})
53
+ return self._client.delete(f"/submolts/{name}/subscribe")
27
54
 
28
55
  def get_subscribed(self) -> dict:
29
56
  """Get user's subscribed submolts."""
moltcli/core/vote.py CHANGED
@@ -20,11 +20,25 @@ class VoteCore:
20
20
  return self._vote(item_id, self.DOWN, type_)
21
21
 
22
22
  def _vote(self, item_id: str, direction: str, type_: str) -> dict:
23
- """Internal vote method."""
24
- return self._client.post(
25
- "/votes", json_data={"item_id": item_id, "type": type_, "direction": direction}
26
- )
27
-
28
- def remove_vote(self, item_id: str, type_: str = "post") -> dict:
29
- """Remove a vote."""
30
- return self._client.delete(f"/votes/{item_id}?type={type_}")
23
+ """Internal vote method.
24
+
25
+ Endpoints from skill.md:
26
+ - POST /posts/{id}/upvote
27
+ - POST /posts/{id}/downvote
28
+ - POST /comments/{id}/upvote
29
+ - POST /comments/{id}/downvote
30
+ """
31
+ direction_full = "upvote" if direction == "up" else "downvote"
32
+ if type_ == "post":
33
+ endpoint = f"/posts/{item_id}/{direction_full}"
34
+ else:
35
+ endpoint = f"/comments/{item_id}/{direction_full}"
36
+ return self._client.post(endpoint)
37
+
38
+ def upvote_comment(self, comment_id: str) -> dict:
39
+ """Upvote a comment. Shortcut for: upvote(comment_id, type='comment')"""
40
+ return self._vote(comment_id, self.UP, "comment")
41
+
42
+ def downvote_comment(self, comment_id: str) -> dict:
43
+ """Downvote a comment. Shortcut for: downvote(comment_id, type='comment')"""
44
+ return self._vote(comment_id, self.DOWN, "comment")
@@ -37,13 +37,21 @@ class MoltbookClient:
37
37
  )
38
38
 
39
39
  if not response.ok:
40
- self._handle_error_response(response)
40
+ self._handle_error_response(response, endpoint)
41
41
 
42
42
  return response.json()
43
43
 
44
- def _handle_error_response(self, response):
44
+ def _handle_error_response(self, response, endpoint: str = ""):
45
45
  """Handle API error response with specific error types."""
46
46
  status = response.status_code
47
+ response_text = response.text.strip()
48
+
49
+ # Determine endpoint type for rate limit
50
+ endpoint_type = "general"
51
+ if "/posts" in endpoint or "/post" in endpoint:
52
+ endpoint_type = "post"
53
+ elif "/vote" in endpoint or "/upvote" in endpoint or "/downvote" in endpoint:
54
+ endpoint_type = "vote"
47
55
 
48
56
  # Rate limit (429)
49
57
  if status == 429:
@@ -52,11 +60,12 @@ class MoltbookClient:
52
60
  retry_after=retry_after,
53
61
  limit=limit,
54
62
  remaining=remaining,
63
+ endpoint_type=endpoint_type,
55
64
  )
56
65
 
57
66
  # Authentication errors (401, 403)
58
67
  if status in (401, 403):
59
- raise AuthError(f"Authentication failed: {response.text}")
68
+ raise AuthError(f"Authentication failed: {response_text or 'No response body'}")
60
69
 
61
70
  # Not found (404)
62
71
  if status == 404:
@@ -69,8 +78,18 @@ class MoltbookClient:
69
78
  else:
70
79
  raise NotFoundError("resource")
71
80
 
81
+ # Method not allowed (405)
82
+ if status == 405:
83
+ # Try to get error message from response body
84
+ try:
85
+ error_body = response.json()
86
+ error_msg = error_body.get("error", error_body.get("message", response_text))
87
+ except Exception:
88
+ error_msg = response_text or f"Method not allowed for {endpoint}"
89
+ raise Exception(f"API error {status}: {error_msg}")
90
+
72
91
  # Other errors
73
- raise Exception(f"API error: {status} {response.text}")
92
+ raise Exception(f"API error {status}: {response_text or 'No response body'}")
74
93
 
75
94
  def get(self, endpoint: str, params: Optional[dict] = None) -> dict:
76
95
  """GET request."""
@@ -85,3 +104,7 @@ class MoltbookClient:
85
104
  def delete(self, endpoint: str) -> dict:
86
105
  """DELETE request."""
87
106
  return self._request("DELETE", endpoint)
107
+
108
+ def patch(self, endpoint: str, json_data: Optional[dict] = None) -> dict:
109
+ """PATCH request."""
110
+ return self._request("PATCH", endpoint, json_data=json_data)
moltcli/utils/config.py CHANGED
@@ -8,11 +8,14 @@ from typing import Optional
8
8
  class Config:
9
9
  """Config loader for credentials and settings."""
10
10
 
11
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "moltbook"
12
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "credentials.json"
13
+
11
14
  def __init__(self, config_path: Optional[str] = None):
12
15
  if config_path is None:
13
16
  config_path = os.environ.get(
14
17
  "MOLTCLI_CONFIG_PATH",
15
- str(Path(__file__).parent.parent.parent / "credentials.json")
18
+ str(self.DEFAULT_CONFIG_FILE)
16
19
  )
17
20
  self.config_path = Path(config_path)
18
21
  self._config: Optional[dict] = None
@@ -44,6 +47,53 @@ class Config:
44
47
  """Get config value by key."""
45
48
  return self.config.get(key, default)
46
49
 
50
+ def save(self, api_key: str, agent_name: str = "") -> None:
51
+ """Save credentials to config file.
52
+
53
+ Does NOT overwrite existing files.
54
+
55
+ Args:
56
+ api_key: Moltbook API key
57
+ agent_name: Agent name (optional)
58
+
59
+ Raises:
60
+ FileExistsError: If config file already exists
61
+ """
62
+ # Ensure config directory exists
63
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ if self.config_path.exists():
66
+ raise FileExistsError(
67
+ f"Credentials file already exists: {self.config_path}\n"
68
+ f"Use 'moltcli auth logout' first to remove existing credentials."
69
+ )
70
+
71
+ config = {"api_key": api_key}
72
+ if agent_name:
73
+ config["agent_name"] = agent_name
74
+
75
+ with open(self.config_path, "x") as f:
76
+ json.dump(config, f, indent=2)
77
+
78
+ # Clear cached config
79
+ self._config = None
80
+
81
+ def remove(self) -> bool:
82
+ """Remove config file.
83
+
84
+ Returns:
85
+ True if file was removed, False if it didn't exist.
86
+ """
87
+ if self.config_path.exists():
88
+ self.config_path.unlink()
89
+ self._config = None
90
+ return True
91
+ return False
92
+
93
+ def exists(self) -> bool:
94
+ """Check if config file exists."""
95
+ return self.config_path.exists()
96
+
47
97
 
48
98
  # Global config instance
49
99
  _config: Optional[Config] = None
moltcli/utils/errors.py CHANGED
@@ -51,11 +51,17 @@ class NotFoundError(MoltCLIError):
51
51
  class RateLimitError(MoltCLIError):
52
52
  """Rate limit exceeded."""
53
53
 
54
+ # Default wait times for common scenarios
55
+ DEFAULT_WAIT_POST = 1800 # 30 minutes for posting
56
+ DEFAULT_WAIT_VOTE = 10 # 10 seconds for voting
57
+ DEFAULT_WAIT_GENERAL = 60 # 60 seconds for general requests
58
+
54
59
  def __init__(
55
60
  self,
56
61
  retry_after: Optional[int] = None,
57
62
  limit: Optional[int] = None,
58
63
  remaining: Optional[int] = None,
64
+ endpoint_type: str = "general",
59
65
  ):
60
66
  """Initialize rate limit error.
61
67
 
@@ -63,22 +69,29 @@ class RateLimitError(MoltCLIError):
63
69
  retry_after: Seconds until retry is allowed
64
70
  limit: Rate limit maximum requests
65
71
  remaining: Remaining requests in window
72
+ endpoint_type: Type of endpoint (post, vote, general)
66
73
  """
67
74
  self.retry_after = retry_after
68
75
  self.limit = limit
69
76
  self.remaining = remaining
70
77
 
78
+ # Get default wait time based on endpoint type
79
+ default_wait = getattr(self, f"DEFAULT_WAIT_{endpoint_type.upper()}", self.DEFAULT_WAIT_GENERAL)
80
+ wait_time = retry_after or default_wait
81
+
71
82
  # Build message
72
83
  if retry_after:
73
84
  message = f"Rate limit exceeded. Retry after {retry_after} seconds."
74
85
  else:
75
- message = "Rate limit exceeded. Please slow down."
86
+ message = f"Rate limit exceeded. Please wait about {wait_time} seconds."
76
87
 
77
88
  # Build suggestion
78
- if retry_after:
89
+ if remaining is not None and remaining == 0:
90
+ suggestion = f"Rate limit reached (0/{limit} remaining). Wait {wait_time} seconds."
91
+ elif retry_after:
79
92
  suggestion = f"Wait {retry_after} seconds before retrying."
80
93
  else:
81
- suggestion = "Wait a moment before retrying. Use --delay to space requests."
94
+ suggestion = f"Wait ~{wait_time} seconds before retrying. Consider adding --wait flag for auto-delay."
82
95
 
83
96
  super().__init__(message, RATE_LIMIT, suggestion)
84
97
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moltcli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI tool for Moltbook social network, AI Agent friendly
5
5
  Author: MoltCLI Team
6
6
  License: MIT
@@ -0,0 +1,22 @@
1
+ moltcli/__init__.py,sha256=e7p2Cat3JV5cvVi0A3XGjA8fhwg9p00mWktYc-RJcsM,77
2
+ moltcli/cli.py,sha256=UX5YqEpaSi0f3oMsYJy4wmmFr99dJlA3NYDV5OSovrg,24718
3
+ moltcli/core/__init__.py,sha256=JnkWgO3F3dYBKLOkwg1N7P2RDB31iR4g35gCtFkRMYA,425
4
+ moltcli/core/agent.py,sha256=bZ73jZxZhoNDXLOVwqthrXUiLgCD1u-1WNRemj3gsPA,1920
5
+ moltcli/core/auth.py,sha256=HmZH2KClOqTIhp93iL-fplbuECzQzILI8L2H4w16FSA,618
6
+ moltcli/core/comment.py,sha256=4zG-Zus3skOjBQklOg6D1wknFZyIyoywm1XgfzEXEIw,1090
7
+ moltcli/core/feed.py,sha256=GfXMP8ciLrh1_noNgeDntT0XjUQANMvbZ4xeLBjzK50,813
8
+ moltcli/core/post.py,sha256=bKIGn6NGDHyQoXekV1JCSIAsFGdGdXHmdpgDIjgY3Ho,1163
9
+ moltcli/core/search.py,sha256=_MkbaXXbAAEUL4pj_-E5P-AoAljSSuu59-o-PeYvWQs,832
10
+ moltcli/core/submolts.py,sha256=NVm_3Ld8MgP0XkiPqIqEJuzb88SiWr0jdlw8lVvWIYo,2005
11
+ moltcli/core/vote.py,sha256=Lle39DHCOqS7Paf6x3BRhfAxiGpwtkGRRZOKNvdbccs,1527
12
+ moltcli/utils/__init__.py,sha256=-bfVKSN8rxaSrTkb3oYG6wmD_GYoR7xn-IMo6v2WxeA,526
13
+ moltcli/utils/api_client.py,sha256=omNVpE-XlpjzBTQ5GU72B3gIUtlsPeesgxwttnn9vuM,3707
14
+ moltcli/utils/config.py,sha256=lDshmadh2j4jQdqR-GWJXH2-vypjd4vWnOoV32nxnYg,3078
15
+ moltcli/utils/errors.py,sha256=UmUEpeFpps2OSprtF83Qe49oQbVD8P0Exubmh4aIVBA,4835
16
+ moltcli/utils/formatter.py,sha256=kLYCPJw1IsanGYKXA8XGEm8mDAnC_AlIYQwN5bFypCc,1313
17
+ moltcli-0.2.0.dist-info/licenses/LICENSE,sha256=EOduU9kNb_dRzzPmt7gJsVSxqTCYJeYl5C2POxT0rj8,1069
18
+ moltcli-0.2.0.dist-info/METADATA,sha256=Zvruf9eFcX5AdPKyE9hGN4cpki_MSijQnf_-Lh2sljg,2585
19
+ moltcli-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
+ moltcli-0.2.0.dist-info/entry_points.txt,sha256=Cn6XRCOpVqQHJYqbjOCJ9LfePx6VYR_Gdn6MsoMtLq4,45
21
+ moltcli-0.2.0.dist-info/top_level.txt,sha256=3BuZXxTPE63yrySE7ET5eCy32LneFysqMiG-pfEQifw,8
22
+ moltcli-0.2.0.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- moltcli/__init__.py,sha256=e7p2Cat3JV5cvVi0A3XGjA8fhwg9p00mWktYc-RJcsM,77
2
- moltcli/cli.py,sha256=3S7QNzYxGJAaIs7dfYYIP73v75D6NukW7rTG_PUjnkE,11006
3
- moltcli/core/__init__.py,sha256=Ow___Urg5hRu2AY0W9YNZihc8ILHmWLzE59BE-yR_lM,379
4
- moltcli/core/auth.py,sha256=ZFpfQeE-Jyt75tHqb_eYJgwlUgb_sQzXO-rVNg5Q3qE,570
5
- moltcli/core/comment.py,sha256=4zG-Zus3skOjBQklOg6D1wknFZyIyoywm1XgfzEXEIw,1090
6
- moltcli/core/feed.py,sha256=GfXMP8ciLrh1_noNgeDntT0XjUQANMvbZ4xeLBjzK50,813
7
- moltcli/core/post.py,sha256=bKIGn6NGDHyQoXekV1JCSIAsFGdGdXHmdpgDIjgY3Ho,1163
8
- moltcli/core/search.py,sha256=_MkbaXXbAAEUL4pj_-E5P-AoAljSSuu59-o-PeYvWQs,832
9
- moltcli/core/submolts.py,sha256=Fgq-AZQI03seWJGjTwN08OJM-n9_lQkY2oi6hG7N5Wg,1161
10
- moltcli/core/vote.py,sha256=P5rbGnY-oeTf3nDBoKHDpkBA-jg8pm-X9re4JbYbEOk,966
11
- moltcli/utils/__init__.py,sha256=-bfVKSN8rxaSrTkb3oYG6wmD_GYoR7xn-IMo6v2WxeA,526
12
- moltcli/utils/api_client.py,sha256=Q5EZzvHwYE8Fr-G2_Yk3LrmKiAaCTP2eIhFT3blDwh8,2617
13
- moltcli/utils/config.py,sha256=TctyM814QmSLDawQMPM2bNbac_3cto0Ewiao8Qt8w4U,1607
14
- moltcli/utils/errors.py,sha256=8JmQ1SbP00DGl3XwGa0_zgqypiiVzHHkaw5r61CwV5U,4100
15
- moltcli/utils/formatter.py,sha256=kLYCPJw1IsanGYKXA8XGEm8mDAnC_AlIYQwN5bFypCc,1313
16
- moltcli-0.1.0.dist-info/licenses/LICENSE,sha256=EOduU9kNb_dRzzPmt7gJsVSxqTCYJeYl5C2POxT0rj8,1069
17
- moltcli-0.1.0.dist-info/METADATA,sha256=wUIt8SHrXhA_sOVhsBRXHRgGVd0znEf-IhmyAYJdIEo,2585
18
- moltcli-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
- moltcli-0.1.0.dist-info/entry_points.txt,sha256=Cn6XRCOpVqQHJYqbjOCJ9LfePx6VYR_Gdn6MsoMtLq4,45
20
- moltcli-0.1.0.dist-info/top_level.txt,sha256=3BuZXxTPE63yrySE7ET5eCy32LneFysqMiG-pfEQifw,8
21
- moltcli-0.1.0.dist-info/RECORD,,