perplexity-web-mcp-cli 0.11.2__tar.gz → 0.12.1__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.
Files changed (42) hide show
  1. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/PKG-INFO +2 -2
  2. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/README.md +1 -1
  3. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/pyproject.toml +1 -1
  4. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/ai_doc.py +11 -8
  5. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/main.py +21 -18
  6. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/skill.py +63 -39
  7. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/council.py +9 -13
  8. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/data/SKILL.md +9 -6
  9. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/data/references/mcp-tools.md +1 -1
  10. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/mcp/server.py +32 -21
  11. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/shared.py +85 -20
  12. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/__init__.py +0 -0
  13. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/api/__init__.py +0 -0
  14. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/api/responses.py +0 -0
  15. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/api/server.py +0 -0
  16. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/api/session_manager.py +0 -0
  17. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/api/tool_calling.py +0 -0
  18. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/__init__.py +0 -0
  19. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/auth.py +0 -0
  20. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/doctor.py +0 -0
  21. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/hack.py +0 -0
  22. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/cli/setup.py +0 -0
  23. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/config.py +0 -0
  24. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/constants.py +0 -0
  25. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/core.py +0 -0
  26. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/data/references/api-endpoints.md +0 -0
  27. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/data/references/models.md +0 -0
  28. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/enums.py +0 -0
  29. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/exceptions.py +0 -0
  30. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/http.py +0 -0
  31. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/limits.py +0 -0
  32. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/logging.py +0 -0
  33. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/mcp/__init__.py +0 -0
  34. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/mcp/__main__.py +0 -0
  35. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/models.py +0 -0
  36. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/py.typed +0 -0
  37. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/rate_limits.py +0 -0
  38. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/resilience.py +0 -0
  39. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/router.py +0 -0
  40. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/sessions.py +0 -0
  41. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/token_store.py +0 -0
  42. {perplexity_web_mcp_cli-0.11.2 → perplexity_web_mcp_cli-0.12.1}/src/perplexity_web_mcp/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: perplexity-web-mcp-cli
3
- Version: 0.11.2
3
+ Version: 0.12.1
4
4
  Summary: CLI, MCP server, and Anthropic/OpenAI API-compatible interface for Perplexity AI.
5
5
  Keywords: perplexity,ai,mcp,anthropic,api,client
6
6
  Author: Jacob BD
@@ -233,7 +233,7 @@ pwm research "NVIDIA competitive landscape" -s finance --json
233
233
  Query multiple models in parallel and get a synthesized consensus. Each model costs 1 Pro Search. Default synthesis uses Sonar 2 (also 1 Pro Search).
234
234
 
235
235
  ```bash
236
- # Default: GPT-5.4, Claude Opus, Gemini Pro + Sonar 2 synthesis (4 Pro Searches)
236
+ # Default: GPT-5.4, Claude Sonnet, Gemini Pro + Sonar 2 synthesis (4 Pro Searches)
237
237
  pwm council "What are best practices for microservices?"
238
238
  ```
239
239
 
@@ -196,7 +196,7 @@ pwm research "NVIDIA competitive landscape" -s finance --json
196
196
  Query multiple models in parallel and get a synthesized consensus. Each model costs 1 Pro Search. Default synthesis uses Sonar 2 (also 1 Pro Search).
197
197
 
198
198
  ```bash
199
- # Default: GPT-5.4, Claude Opus, Gemini Pro + Sonar 2 synthesis (4 Pro Searches)
199
+ # Default: GPT-5.4, Claude Sonnet, Gemini Pro + Sonar 2 synthesis (4 Pro Searches)
200
200
  pwm council "What are best practices for microservices?"
201
201
  ```
202
202
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "perplexity-web-mcp-cli"
3
- version = "0.11.2"
3
+ version = "0.12.1"
4
4
  description = "CLI, MCP server, and Anthropic/OpenAI API-compatible interface for Perplexity AI."
5
5
  authors = [{ name = "Jacob BD" }]
6
6
  license = "MIT"
@@ -66,7 +66,7 @@ MODEL COUNCIL
66
66
  pwm council "query" --json Output as JSON
67
67
 
68
68
  Each model in the council costs 1 Pro Search, plus 1 for synthesis. Default = 4 Pro Searches.
69
- Available models: gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26
69
+ Available models: sonar, gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26
70
70
  Thinking toggle: -t / --thinking (gpt54, gpt55, claude_sonnet, claude_opus, kimi_k26 support toggle;
71
71
  gemini_pro and nemotron are always thinking)
72
72
 
@@ -168,12 +168,13 @@ QUERY TOOLS (each call costs 1 Pro Search query unless noted):
168
168
  pplx_ask(query, source_focus="web")
169
169
  Auto-selects best model. 1 PRO SEARCH per call.
170
170
 
171
- pplx_council(query, source_focus="web", models="gpt54,claude_opus,gemini_pro",
171
+ pplx_council(query, source_focus="web", models="gpt54,claude_sonnet,gemini_pro",
172
172
  synthesize=True, thinking=False, chairman="sonar")
173
173
  Model Council — N PRO SEARCHES (1 per model selected).
174
174
  BEFORE CALLING: You MUST ask the user which models and how many.
175
- Available: gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26.
176
- Default: 3 models (GPT-5.4, Claude Opus, Gemini Pro) + synthesis = 4 Pro Searches.
175
+ Available: sonar, gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26.
176
+ Max-only: gpt55, claude_opus. Exclude these when Subscription is Pro.
177
+ Default: 3 Pro-compatible models (GPT-5.4, Claude Sonnet, Gemini Pro) + synthesis = 4 Pro Searches.
177
178
  Synthesis uses Sonar 2 by default. Set chairman to override.
178
179
  Non-sonar chairman costs 1 extra Pro Search.
179
180
  Set synthesize=False to skip synthesis entirely.
@@ -200,9 +201,9 @@ QUERY TOOLS (each call costs 1 Pro Search query unless noted):
200
201
  All query tools accept source_focus: "none", "web", "academic", "social",
201
202
  "finance", "all". Use "none" for model-only queries without web search.
202
203
 
203
- All query tools also accept an optional `conversation_id` (str) parameter.
204
- The server returns `[Conversation ID: <uuid>]` at the end of each response.
205
- Extract this UUID and pass it to the next query to maintain context across
204
+ All query tools also accept an optional `conversation_id` (str) parameter.
205
+ The server returns `[Conversation ID: <uuid>]` at the end of each response.
206
+ Extract this UUID and pass it to the next query to maintain context across
206
207
  multiple turns. State is retained in memory for 1 hour.
207
208
 
208
209
  USAGE TOOL (1):
@@ -287,7 +288,9 @@ MANDATORY PROTOCOL:
287
288
  3. ESCALATE ONLY WHEN NEEDED: Use 'standard' for multi-source synthesis,
288
289
  'detailed' for complex analysis, 'research' only when user requests it.
289
290
  4. NEVER USE DEEP RESEARCH AUTONOMOUSLY — always ask the user first.
290
- 5. COUNCIL: Before calling pplx_council, ASK the user which models and how
291
+ 5. SUBSCRIPTION-AWARE MODELS: Read the Subscription line from pplx_usage().
292
+ If it is Pro, exclude Max-only models: gpt55 and claude_opus.
293
+ 6. COUNCIL: Before calling pplx_council, ASK the user which models and how
291
294
  many. Each model = 1 Pro Search. List available models for them to choose.
292
295
 
293
296
  WHEN TO USE EACH INTENT:
@@ -27,14 +27,16 @@ import rich_click as click
27
27
 
28
28
  from perplexity_web_mcp.exceptions import AuthenticationError, RateLimitError
29
29
  from perplexity_web_mcp.shared import (
30
+ COUNCIL_DEFAULT_MODELS_STR,
30
31
  COUNCIL_DISPLAY_NAMES,
32
+ COUNCIL_ELIGIBLE_MODEL_NAMES,
31
33
  MODEL_MAP,
32
34
  MODEL_NAMES,
33
35
  SOURCE_FOCUS_NAMES,
34
- THINKING_TOGGLEABLE,
35
36
  Models,
36
37
  SourceFocusName,
37
38
  ask,
39
+ build_council_model_list,
38
40
  get_limit_cache,
39
41
  resolve_model,
40
42
  )
@@ -230,7 +232,7 @@ def _cmd_research_impl(query, source, json_output):
230
232
 
231
233
  # ── Council ────────────────────────────────────────────────────────────────
232
234
 
233
- COUNCIL_MODEL_NAMES = ("gpt54", "gpt55", "claude_sonnet", "claude_opus", "gemini_pro", "nemotron")
235
+ COUNCIL_MODEL_NAMES = COUNCIL_ELIGIBLE_MODEL_NAMES
234
236
 
235
237
 
236
238
  @cli.command()
@@ -239,7 +241,7 @@ COUNCIL_MODEL_NAMES = ("gpt54", "gpt55", "claude_sonnet", "claude_opus", "gemini
239
241
  "-m",
240
242
  "--models",
241
243
  "models_str",
242
- default="gpt54,claude_opus,gemini_pro",
244
+ default=COUNCIL_DEFAULT_MODELS_STR,
243
245
  help=f"Comma-separated models ({', '.join(COUNCIL_MODEL_NAMES)}).",
244
246
  )
245
247
  @click.option("-t", "--thinking", is_flag=True, help="Enable extended thinking mode.")
@@ -301,14 +303,8 @@ def _cmd_council_impl(query, models_str, source, synthesize, json_output, thinki
301
303
 
302
304
  # Build model list (None = use defaults)
303
305
  model_list = None
304
- if models_str != "gpt54,claude_opus,gemini_pro":
305
- model_list = []
306
- for name in model_names:
307
- resolved = resolve_model(name, thinking=thinking)
308
- display = COUNCIL_DISPLAY_NAMES.get(name, name)
309
- if thinking and name in THINKING_TOGGLEABLE:
310
- display += " Thinking"
311
- model_list.append((display, resolved))
306
+ if models_str != COUNCIL_DEFAULT_MODELS_STR:
307
+ model_list = build_council_model_list(model_names, thinking=thinking)
312
308
 
313
309
  synthesis_model = resolve_model(chairman) if chairman != "sonar" else None
314
310
 
@@ -454,16 +450,23 @@ def _cmd_usage_impl(refresh):
454
450
 
455
451
  # ── Account Info ───────────────────────────────────────────────────────
456
452
  settings = cache.get_user_settings(force_refresh=refresh)
457
- if settings:
453
+ from perplexity_web_mcp.cli.auth import get_user_info
454
+
455
+ user_info = get_user_info(token)
456
+ if settings or user_info:
458
457
  table = Table(title="👤 Account", show_header=True, header_style="bold cyan")
459
458
  table.add_column("Field", style="bold")
460
459
  table.add_column("Value", justify="right")
461
460
 
462
- tier = (settings.subscription_tier or "unknown").title()
463
- status = settings.subscription_status
464
- table.add_row("Subscription", f"[bold]{tier}[/] ({status})")
465
- table.add_row("Total Queries", f"{settings.query_count:,}")
466
- table.add_row("Pro Queries", f"{settings.query_count_copilot:,}")
461
+ if user_info:
462
+ table.add_row("Subscription", f"[bold]{user_info.tier_display}[/]")
463
+
464
+ if settings:
465
+ billing = settings.subscription_tier or "unknown"
466
+ status = settings.subscription_status
467
+ table.add_row("Billing", f"[bold]{billing}[/] ({status})")
468
+ table.add_row("Total Queries", f"{settings.query_count:,}")
469
+ table.add_row("Pro Queries", f"{settings.query_count_copilot:,}")
467
470
 
468
471
  console.print(table)
469
472
 
@@ -732,7 +735,7 @@ def _cmd_council(args: list[str]) -> int:
732
735
  return 1
733
736
 
734
737
  query = args[0]
735
- models_str = "gpt54,claude_opus,gemini_pro"
738
+ models_str = COUNCIL_DEFAULT_MODELS_STR
736
739
  source: SourceFocusName = "web"
737
740
  synthesize = True
738
741
  json_output = False
@@ -6,8 +6,9 @@ to the appropriate location for each supported AI platform.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from dataclasses import dataclass
9
+ from dataclasses import dataclass, field
10
10
  from importlib import metadata
11
+ import os
11
12
  from pathlib import Path
12
13
  import re
13
14
  import shutil
@@ -17,6 +18,11 @@ import sys
17
18
  SKILL_DIR_NAME = "perplexity-web-mcp"
18
19
 
19
20
 
21
+ def _hermes_home() -> Path:
22
+ """Resolve the Hermes root directory, respecting $HERMES_HOME."""
23
+ return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
24
+
25
+
20
26
  @dataclass(frozen=True)
21
27
  class SkillTarget:
22
28
  """A platform that supports Agent Skills."""
@@ -25,72 +31,95 @@ class SkillTarget:
25
31
  description: str
26
32
  user_dir: Path
27
33
  project_dir: str
34
+ binary: str | None = None
35
+ root_dirs: list[Path] = field(default_factory=list)
28
36
  frontmatter_extras: dict[str, str] | None = None
29
37
 
30
38
 
31
- def _home() -> Path:
32
- return Path.home()
33
-
34
-
35
39
  def _get_targets() -> list[SkillTarget]:
36
40
  """Return the list of platforms that support skills."""
37
- home = _home()
41
+ home = Path.home()
42
+ hm_root = _hermes_home()
38
43
  return [
39
44
  SkillTarget(
40
45
  name="claude-code",
41
46
  description="Claude Code CLI and Desktop",
42
47
  user_dir=home / ".claude" / "skills",
43
48
  project_dir=".claude/skills",
49
+ binary="claude",
50
+ root_dirs=[home / ".claude"],
44
51
  ),
45
52
  SkillTarget(
46
53
  name="cursor",
47
54
  description="Cursor AI editor",
48
55
  user_dir=home / ".cursor" / "skills",
49
56
  project_dir=".cursor/skills",
57
+ binary="cursor",
58
+ root_dirs=[home / ".cursor"],
50
59
  ),
51
60
  SkillTarget(
52
61
  name="codex",
53
62
  description="OpenAI Codex CLI",
54
63
  user_dir=home / ".agents" / "skills",
55
64
  project_dir=".agents/skills",
65
+ binary="codex",
66
+ root_dirs=[home / ".codex", home / ".agents"],
56
67
  ),
57
68
  SkillTarget(
58
69
  name="opencode",
59
70
  description="OpenCode AI assistant",
60
71
  user_dir=home / ".config" / "opencode" / "skills",
61
72
  project_dir=".opencode/skills",
73
+ binary="opencode",
74
+ root_dirs=[home / ".config" / "opencode"],
62
75
  ),
63
76
  SkillTarget(
64
77
  name="gemini-cli",
65
78
  description="Google Gemini CLI",
66
79
  user_dir=home / ".agents" / "skills",
67
80
  project_dir=".agents/skills",
81
+ binary="gemini",
82
+ root_dirs=[home / ".agents", home / ".gemini"],
68
83
  ),
69
84
  SkillTarget(
70
85
  name="antigravity",
71
86
  description="Google Antigravity IDE",
72
87
  user_dir=home / ".gemini" / "antigravity" / "skills",
73
88
  project_dir=".agent/skills",
89
+ root_dirs=[home / ".gemini" / "antigravity"],
74
90
  ),
75
91
  SkillTarget(
76
92
  name="cline",
77
93
  description="Cline CLI terminal agent",
78
94
  user_dir=home / ".cline" / "skills",
79
95
  project_dir=".cline/skills",
96
+ binary="cline",
97
+ root_dirs=[home / ".cline"],
80
98
  ),
81
99
  SkillTarget(
82
100
  name="openclaw",
83
101
  description="OpenClaw AI agent framework",
84
102
  user_dir=home / ".openclaw" / "workspace" / "skills",
85
103
  project_dir=".openclaw/workspace/skills",
104
+ binary="openclaw",
105
+ root_dirs=[home / ".openclaw"],
86
106
  ),
87
107
  SkillTarget(
88
108
  name="alef-agent",
89
109
  description="Alef Agent AI framework",
90
110
  user_dir=home / ".alef-agent" / "workspace" / "skills",
91
111
  project_dir=".alef-agent/workspace/skills",
112
+ root_dirs=[home / ".alef-agent"],
92
113
  frontmatter_extras={"type": "tool", "status": "approved"},
93
114
  ),
115
+ SkillTarget(
116
+ name="hermes",
117
+ description="Hermes Agent (NousResearch)",
118
+ user_dir=hm_root / "skills",
119
+ project_dir=".hermes/skills",
120
+ binary="hermes",
121
+ root_dirs=[hm_root],
122
+ ),
94
123
  SkillTarget(
95
124
  name="other",
96
125
  description="Export all formats for manual install",
@@ -100,33 +129,17 @@ def _get_targets() -> list[SkillTarget]:
100
129
  ]
101
130
 
102
131
 
103
- def _is_tool_detected(target: SkillTarget) -> bool:
104
- """Check if a tool appears to be installed on this system.
105
-
106
- Looks for the tool's config directory (parent of its skills dir) and
107
- verifies the tool itself created content there -- not just our own
108
- ``skills/`` subdirectory from a previous install.
132
+ def _is_tool_installed(target: SkillTarget) -> bool:
133
+ """Detect whether a tool is actually installed on this system.
109
134
 
110
- Special case: Codex and Gemini CLI both use ``~/.agents/`` which is a
111
- shared cross-tool directory, so we check for their respective binaries
112
- (``codex`` / ``gemini``) in PATH instead.
135
+ Checks two signals (either is sufficient):
136
+ 1. Binary on PATH (e.g. ``claude``, ``cursor``, ``opencode``, ``hermes``)
137
+ 2. Tool's root config directory exists (e.g. ``~/.claude``, ``~/.cursor``)
113
138
  """
114
- # Codex and Gemini CLI: check for binary since ~/.agents/ is a shared directory
115
- if target.name == "codex":
116
- return shutil.which("codex") is not None
117
- if target.name == "gemini-cli":
118
- return shutil.which("gemini") is not None
119
-
120
- config_root = target.user_dir.parent
121
- if not config_root.is_dir():
122
- return False
123
- try:
124
- for child in config_root.iterdir():
125
- if child.name != "skills":
126
- return True
127
- except OSError:
128
- return False
129
- return False
139
+ if target.binary and shutil.which(target.binary):
140
+ return True
141
+
142
+ return any(root_dir.is_dir() for root_dir in target.root_dirs)
130
143
 
131
144
 
132
145
  def _find_skill_source() -> Path | None:
@@ -163,10 +176,16 @@ def _get_installed_version(target_dir: Path) -> str | None:
163
176
  return None
164
177
  try:
165
178
  text = skill_file.read_text(encoding="utf-8")
166
- for line in text.split("\n"):
167
- line = line.strip()
168
- if line.startswith("version:"):
169
- return line.split(":", 1)[1].strip().strip('"').strip("'")
179
+ in_frontmatter = False
180
+ for raw_line in text.split("\n"):
181
+ stripped = raw_line.strip()
182
+ if stripped == "---":
183
+ if not in_frontmatter:
184
+ in_frontmatter = True
185
+ continue
186
+ break # closing --- reached
187
+ if stripped.startswith("version:"):
188
+ return stripped.split(":", 1)[1].strip().strip('"').strip("'")
170
189
  except OSError:
171
190
  pass
172
191
  return None
@@ -304,6 +323,11 @@ cp -r {SKILL_DIR_NAME} ~/.openclaw/workspace/skills/
304
323
  cp -r {SKILL_DIR_NAME} ~/.alef-agent/workspace/skills/
305
324
  ```
306
325
 
326
+ ### Hermes Agent
327
+ ```bash
328
+ cp -r {SKILL_DIR_NAME} ~/.hermes/skills/
329
+ ```
330
+
307
331
  ## Automated Installation
308
332
 
309
333
  Instead of manual copying, you can use:
@@ -311,7 +335,7 @@ Instead of manual copying, you can use:
311
335
  pwm skill install <tool>
312
336
  ```
313
337
 
314
- Where `<tool>` is: claude-code, cursor, codex, opencode, gemini-cli, antigravity, cline, openclaw, alef-agent.
338
+ Where `<tool>` is: claude-code, cursor, codex, opencode, gemini-cli, antigravity, cline, openclaw, alef-agent, hermes.
315
339
  """
316
340
 
317
341
  (export_dir / "README.md").write_text(readme_content)
@@ -336,14 +360,14 @@ def _install_all(targets: list[SkillTarget], current_version: str) -> int:
336
360
  for t in targets:
337
361
  if t.name == "other":
338
362
  continue
339
- if _is_tool_detected(t):
363
+ if _is_tool_installed(t):
340
364
  detected.append(t)
341
365
  else:
342
366
  not_detected.append(t.name)
343
367
 
344
368
  if not detected:
345
369
  print(" No supported tools detected on this system.")
346
- print(f" Looked for: {', '.join(t.name for t in targets)}")
370
+ print(" (No binary on PATH and no config directory found for any tool)")
347
371
  return 0
348
372
 
349
373
  installed: list[str] = []
@@ -405,7 +429,7 @@ def cmd_skill(args: list[str]) -> int:
405
429
  " pwm skill show Display the skill content\n"
406
430
  " pwm skill update Update all outdated skills\n"
407
431
  "\n"
408
- "Tools: claude-code, cursor, codex, opencode, gemini-cli, antigravity, cline, openclaw, alef-agent, other, all\n"
432
+ "Tools: claude-code, cursor, codex, opencode, gemini-cli, antigravity, cline, openclaw, alef-agent, hermes, other, all\n"
409
433
  "\n"
410
434
  "Examples:\n"
411
435
  " pwm skill list\n"
@@ -16,6 +16,7 @@ from .config import ConversationConfig
16
16
  from .enums import CitationMode, SearchFocus, SourceFocus
17
17
  from .logging import get_logger
18
18
  from .models import Model, Models
19
+ from .shared import COUNCIL_DEFAULT_MODEL_NAMES, build_council_model_list
19
20
 
20
21
 
21
22
  if TYPE_CHECKING:
@@ -29,19 +30,14 @@ logger = get_logger(__name__)
29
30
  # Default council composition
30
31
  # ---------------------------------------------------------------------------
31
32
 
32
- COUNCIL_DEFAULT_MODELS: list[tuple[str, Model]] = [
33
- ("GPT-5.4", Models.GPT_54),
34
- ("Claude Opus 4.7", Models.CLAUDE_47_OPUS),
35
- ("Gemini 3.1 Pro", Models.GEMINI_31_PRO_THINKING),
36
- ]
37
- """Default models for the council (3 diverse providers)."""
33
+ COUNCIL_DEFAULT_MODELS: list[tuple[str, Model]] = build_council_model_list(COUNCIL_DEFAULT_MODEL_NAMES)
34
+ """Default Pro-compatible models for the council (3 diverse providers)."""
38
35
 
39
- COUNCIL_DEFAULT_MODELS_THINKING: list[tuple[str, Model]] = [
40
- ("GPT-5.4 Thinking", Models.GPT_54_THINKING),
41
- ("Claude Opus 4.7 Thinking", Models.CLAUDE_47_OPUS_THINKING),
42
- ("Gemini 3.1 Pro", Models.GEMINI_31_PRO_THINKING),
43
- ]
44
- """Default models for the council with extended thinking enabled."""
36
+ COUNCIL_DEFAULT_MODELS_THINKING: list[tuple[str, Model]] = build_council_model_list(
37
+ COUNCIL_DEFAULT_MODEL_NAMES,
38
+ thinking=True,
39
+ )
40
+ """Default Pro-compatible models for the council with extended thinking enabled."""
45
41
 
46
42
 
47
43
  # ---------------------------------------------------------------------------
@@ -220,7 +216,7 @@ def council_ask(
220
216
  Args:
221
217
  query: The question to ask all models.
222
218
  models: List of (display_name, Model) tuples. Defaults to
223
- COUNCIL_DEFAULT_MODELS (GPT-5.4, Claude Opus, Gemini Pro).
219
+ COUNCIL_DEFAULT_MODELS (GPT-5.4, Claude Sonnet, Gemini Pro).
224
220
  source_focus: Source focus for all queries (none/web/academic/social/finance/all).
225
221
  synthesize: Whether to produce a synthesized consensus (adds 1 Sonar 2 synthesis query by default).
226
222
  thinking: Use thinking model variants for default council members.
@@ -46,8 +46,9 @@ the weekly pool fast, leaving nothing for questions that actually need it.
46
46
  ### Before Every Session
47
47
 
48
48
  1. **Check quota first**: Call `pplx_usage()` (MCP) or `pwm usage` (CLI) before your first query.
49
- 2. Review the remaining Pro and Research counts.
50
- 3. If Pro < 20% remaining, restrict yourself to quick/Sonar 2 for everything except user-requested Pro queries.
49
+ 2. Review the remaining Pro and Research counts and the `Subscription` line.
50
+ 3. If Subscription is Pro, exclude Max-only models (`gpt55`, `claude_opus`) from model selection and councils.
51
+ 4. If Pro < 20% remaining, restrict yourself to quick/Sonar 2 for everything except user-requested Pro queries.
51
52
 
52
53
  ### Before Every Query: Choose the Lowest Sufficient Tier
53
54
 
@@ -83,8 +84,9 @@ Ask yourself: **"Can Sonar 2 answer this?"** If yes, use `quick`. Only escalate
83
84
  - The user needs high-confidence answers validated across multiple AI providers
84
85
  - Important decisions, fact-checking, or complex analysis
85
86
  - BEFORE calling: ASK the user which models and how many (each = 1 Pro Search)
86
- - Available models: gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26
87
- - Default: 3 models (GPT-5.4, Claude Opus, Gemini Pro) + synthesis = 4 Pro Searches
87
+ - Available models: sonar, gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26
88
+ - Max-only models: gpt55, claude_opus. Do not use these for Pro subscriptions.
89
+ - Default: 3 Pro-compatible models (GPT-5.4, Claude Sonnet, Gemini Pro) + synthesis = 4 Pro Searches
88
90
 
89
91
  ### Decision Flowchart
90
92
 
@@ -237,7 +239,8 @@ pwm ask "protein folding advances" -m gemini_pro -s academic --json
237
239
  ### Model Council
238
240
 
239
241
  Query multiple models in parallel and get a synthesized consensus.
240
- Each model in the council costs 1 Pro Search, plus 1 for Sonar 2 synthesis. Default: 3 models + synthesis = 4 Pro Searches.
242
+ Each model in the council costs 1 Pro Search, plus 1 for Sonar 2 synthesis. Default: 3 Pro-compatible models + synthesis = 4 Pro Searches.
243
+ Before selecting models, check `pplx_usage()` or `pwm usage`. If the subscription is Pro, exclude Max-only models (`gpt55`, `claude_opus`).
241
244
 
242
245
  ```bash
243
246
  pwm council "What are the best practices for microservices?" # default 3 models
@@ -283,7 +286,7 @@ pwm usage --refresh # Force-refresh from server
283
286
  | `pplx_sonar` | 1 Pro Search | Perplexity Sonar 2 |
284
287
  | `pplx_query` | 1 Pro | Explicit model selection with thinking toggle |
285
288
  | `pplx_ask` | 1 Pro | Quick Q&A (auto model) |
286
- | `pplx_council` | **N+1 Pro** (1 per model + 1 synthesis) | Model Council — **ASK USER which models first!** Supports `thinking=True` and `chairman` for synthesis model. |
289
+ | `pplx_council` | **N+1 Pro** (1 per model + 1 synthesis) | Model Council — **ASK USER which models first!** Check subscription first; exclude Max-only `gpt55`/`claude_opus` on Pro. Supports `thinking=True` and `chairman` for synthesis model. |
287
290
  | `pplx_gpt54` / `_thinking` | 1 Pro | OpenAI GPT-5.4 (versatile) |
288
291
  | `pplx_gpt55` / `_thinking` | 1 Pro | OpenAI GPT-5.5 (latest, Max tier) |
289
292
  | `pplx_claude_sonnet` / `_think` | 1 Pro | Anthropic Claude 4.6 Sonnet |
@@ -120,7 +120,7 @@ Returns a summary including:
120
120
  - Deep Research remaining (monthly)
121
121
  - Create Files & Apps remaining (monthly)
122
122
  - Browser Agent remaining (monthly)
123
- - Subscription tier and account info
123
+ - Subscription tier, billing detail, and account info
124
124
 
125
125
  ## Authentication Tools
126
126
 
@@ -15,11 +15,11 @@ from fastmcp import FastMCP
15
15
 
16
16
  from perplexity_web_mcp.models import Models
17
17
  from perplexity_web_mcp.shared import (
18
- COUNCIL_DISPLAY_NAMES,
19
- THINKING_TOGGLEABLE,
18
+ COUNCIL_DEFAULT_MODELS_STR,
20
19
  ModelName,
21
20
  SourceFocusName,
22
21
  ask,
22
+ build_council_model_list,
23
23
  council_ask,
24
24
  get_limit_cache,
25
25
  resolve_model,
@@ -39,6 +39,7 @@ mcp = FastMCP(
39
39
  "- pplx_deep_research: 1 DEEP RESEARCH each (small monthly pool, ~5-10 total)\n\n"
40
40
  "MANDATORY PROTOCOL:\n"
41
41
  "1. On your FIRST query of the session, call pplx_usage() to check remaining quotas.\n"
42
+ " Read the Subscription line: Pro users must avoid Max-only models.\n"
42
43
  "2. DEFAULT to pplx_smart_query(intent='quick') for most lookups — it prefers Sonar 2 "
43
44
  "before premium models when that fits the question.\n"
44
45
  "3. Only use 'standard' or 'detailed' intent when the question requires synthesis, "
@@ -288,7 +289,7 @@ def pplx_smart_query(
288
289
  def pplx_council(
289
290
  query: str,
290
291
  source_focus: SourceFocusName = "web",
291
- models: str = "gpt54,claude_opus,gemini_pro",
292
+ models: str = COUNCIL_DEFAULT_MODELS_STR,
292
293
  synthesize: bool = True,
293
294
  thinking: bool = False,
294
295
  chairman: ModelName = "sonar",
@@ -296,20 +297,22 @@ def pplx_council(
296
297
  """Model Council — query multiple models in parallel, get synthesized consensus.
297
298
 
298
299
  IMPORTANT — BEFORE calling this tool, you MUST:
299
- 1. Tell the user the available models: gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26
300
- 2. Ask the user WHICH models they want in their council and HOW MANY
301
- 3. Inform them of the cost: each council model = 1 Pro Search query, plus synthesis
300
+ 1. Tell the user the available models: sonar, gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26
301
+ 2. Check pplx_usage() first. If Subscription is Pro, do not include Max-only models: gpt55, claude_opus
302
+ 3. Ask the user WHICH models they want in their council and HOW MANY
303
+ 4. Inform them of the cost: each council model = 1 Pro Search query, plus synthesis
302
304
  (default chairman sonar = Sonar 2 pass — still counts as a normal query toward limits)
303
- 4. Get explicit confirmation before executing
305
+ 5. Get explicit confirmation before executing
304
306
 
305
- Default council: GPT-5.4, Claude Opus 4.7, Gemini 3.1 Pro (3 diverse providers).
307
+ Default council: GPT-5.4, Claude Sonnet 4.6, Gemini 3.1 Pro (Pro-compatible, 3 diverse providers).
306
308
 
307
309
  Args:
308
310
  query: The question to ask all council models
309
311
  source_focus: Source type for all models (none/web/academic/social/finance/all)
310
312
  models: Comma-separated model names to use as council members.
311
- Available: gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26.
312
- Default: "gpt54,claude_opus,gemini_pro" (3 models + synthesis = 4 Pro Searches)
313
+ Available: sonar, gpt54, gpt55, claude_sonnet, claude_opus, gemini_pro, nemotron, kimi_k26.
314
+ Default: "gpt54,claude_sonnet,gemini_pro" (3 models + synthesis = 4 Pro Searches)
315
+ Max-only: gpt55, claude_opus. Exclude these when pplx_usage shows a Pro subscription.
313
316
  synthesize: Whether to synthesize a consensus from all responses.
314
317
  Set false to get only individual responses (saves 1 Sonar 2 call).
315
318
  thinking: Enable extended thinking for council models (gpt54, gpt55, claude_sonnet,
@@ -319,15 +322,9 @@ def pplx_council(
319
322
  """
320
323
  # Parse custom model list if provided
321
324
  model_list = None
322
- if models != "gpt54,claude_opus,gemini_pro":
323
- model_list = []
324
- for name in models.split(","):
325
- name = name.strip()
326
- resolved = resolve_model(name, thinking=thinking)
327
- display = COUNCIL_DISPLAY_NAMES.get(name, name)
328
- if thinking and name in THINKING_TOGGLEABLE:
329
- display += " Thinking"
330
- model_list.append((display, resolved))
325
+ if models != COUNCIL_DEFAULT_MODELS_STR:
326
+ model_names = [name.strip() for name in models.split(",") if name.strip()]
327
+ model_list = build_council_model_list(model_names, thinking=thinking)
331
328
 
332
329
  synthesis_model = resolve_model(chairman) if chairman != "sonar" else None
333
330
 
@@ -376,12 +373,26 @@ def pplx_usage(refresh: bool = False) -> str:
376
373
  else:
377
374
  parts.append("WARNING: Could not fetch rate limits (network error or token issue).")
378
375
 
376
+ from perplexity_web_mcp.cli.auth import get_user_info
377
+
378
+ user_info = get_user_info(token)
379
379
  settings = cache.get_user_settings(force_refresh=refresh)
380
- if settings:
380
+ if settings or user_info:
381
381
  parts.append("")
382
382
  parts.append("ACCOUNT INFO")
383
383
  parts.append("=" * 40)
384
- parts.append(settings.format_summary())
384
+ if user_info:
385
+ parts.append(f"Subscription: {user_info.tier_display}")
386
+ if settings:
387
+ parts.append(f"Billing: {settings.subscription_tier} ({settings.subscription_status})")
388
+ parts.append(f"Total queries: {settings.query_count:,}")
389
+ parts.append(f"Pro queries: {settings.query_count_copilot:,}")
390
+ parts.append(f"Upload limit: {settings.upload_limit} files")
391
+ parts.append(f"Create limit: {settings.create_limit}")
392
+ parts.append(f"Pages limit: {settings.pages_limit}")
393
+ parts.append(f"Max files/user: {settings.max_files_per_user:,}")
394
+ parts.append(f"Max file size: {settings.connector_limits.max_file_size_mb} MB")
395
+ parts.append(f"Daily attachments: {settings.connector_limits.daily_attachment_limit}")
385
396
 
386
397
  credits = cache.get_credits(force_refresh=refresh)
387
398
  if credits:
@@ -7,6 +7,7 @@ Both the MCP server (mcp/server.py) and CLI (cli/main.py) import from here.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from dataclasses import dataclass
10
11
  from threading import Lock
11
12
  from typing import TYPE_CHECKING, Literal
12
13
  from uuid import uuid4
@@ -22,6 +23,7 @@ from .token_store import get_token_or_raise, load_token
22
23
 
23
24
 
24
25
  if TYPE_CHECKING:
26
+ from .council import CouncilResponse
25
27
  from .types import SearchResultItem
26
28
 
27
29
 
@@ -29,6 +31,20 @@ if TYPE_CHECKING:
29
31
  # Model and source focus mappings (single source of truth)
30
32
  # ---------------------------------------------------------------------------
31
33
 
34
+ SubscriptionMinimumTier = Literal["free", "pro", "max"]
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ModelDefinition:
39
+ """Metadata and model instances for one user-facing model key."""
40
+
41
+ base_model: Model
42
+ thinking_model: Model | None
43
+ display_name: str
44
+ provider: str
45
+ minimum_tier: SubscriptionMinimumTier = "pro"
46
+ council_eligible: bool = True
47
+
32
48
  SOURCE_FOCUS_MAP: dict[str, list[SourceFocus]] = {
33
49
  "none": [],
34
50
  "web": [SourceFocus.WEB],
@@ -38,18 +54,49 @@ SOURCE_FOCUS_MAP: dict[str, list[SourceFocus]] = {
38
54
  "all": [SourceFocus.WEB, SourceFocus.ACADEMIC, SourceFocus.SOCIAL],
39
55
  }
40
56
 
57
+ MODEL_METADATA: dict[str, ModelDefinition] = {
58
+ "auto": ModelDefinition(Models.BEST, None, "Auto (Best)", "Perplexity", council_eligible=False),
59
+ "sonar": ModelDefinition(Models.SONAR, None, "Sonar 2", "Perplexity"),
60
+ "deep_research": ModelDefinition(
61
+ Models.DEEP_RESEARCH,
62
+ None,
63
+ "Deep Research",
64
+ "Perplexity",
65
+ council_eligible=False,
66
+ ),
67
+ "gpt54": ModelDefinition(Models.GPT_54, Models.GPT_54_THINKING, "GPT-5.4", "OpenAI"),
68
+ "gpt55": ModelDefinition(Models.GPT_55, Models.GPT_55_THINKING, "GPT-5.5", "OpenAI", minimum_tier="max"),
69
+ "claude_sonnet": ModelDefinition(
70
+ Models.CLAUDE_46_SONNET,
71
+ Models.CLAUDE_46_SONNET_THINKING,
72
+ "Claude Sonnet 4.6",
73
+ "Anthropic",
74
+ ),
75
+ "claude_opus": ModelDefinition(
76
+ Models.CLAUDE_47_OPUS,
77
+ Models.CLAUDE_47_OPUS_THINKING,
78
+ "Claude Opus 4.7",
79
+ "Anthropic",
80
+ minimum_tier="max",
81
+ ),
82
+ "gemini_pro": ModelDefinition(
83
+ Models.GEMINI_31_PRO_THINKING,
84
+ Models.GEMINI_31_PRO_THINKING,
85
+ "Gemini 3.1 Pro",
86
+ "Google",
87
+ ),
88
+ "nemotron": ModelDefinition(
89
+ Models.NEMOTRON_3_SUPER,
90
+ Models.NEMOTRON_3_SUPER,
91
+ "Nemotron 3 Super",
92
+ "NVIDIA",
93
+ ),
94
+ "kimi_k26": ModelDefinition(Models.KIMI_K2_6, Models.KIMI_K2_6_THINKING, "Kimi K2.6", "Moonshot"),
95
+ }
96
+ """User-facing model metadata. Update this table when model names or tier availability changes."""
97
+
41
98
  MODEL_MAP: dict[str, tuple[Model, Model | None]] = {
42
- # (base_model, thinking_model) - None if no thinking variant
43
- "auto": (Models.BEST, None),
44
- "sonar": (Models.SONAR, None),
45
- "deep_research": (Models.DEEP_RESEARCH, None),
46
- "gpt54": (Models.GPT_54, Models.GPT_54_THINKING),
47
- "gpt55": (Models.GPT_55, Models.GPT_55_THINKING),
48
- "claude_sonnet": (Models.CLAUDE_46_SONNET, Models.CLAUDE_46_SONNET_THINKING),
49
- "claude_opus": (Models.CLAUDE_47_OPUS, Models.CLAUDE_47_OPUS_THINKING),
50
- "gemini_pro": (Models.GEMINI_31_PRO_THINKING, Models.GEMINI_31_PRO_THINKING),
51
- "nemotron": (Models.NEMOTRON_3_SUPER, Models.NEMOTRON_3_SUPER),
52
- "kimi_k26": (Models.KIMI_K2_6, Models.KIMI_K2_6_THINKING),
99
+ name: (definition.base_model, definition.thinking_model) for name, definition in MODEL_METADATA.items()
53
100
  }
54
101
 
55
102
  SourceFocusName = Literal["none", "web", "academic", "social", "finance", "all"]
@@ -70,21 +117,39 @@ MODEL_NAMES: list[str] = list(MODEL_MAP.keys())
70
117
  SOURCE_FOCUS_NAMES: list[str] = list(SOURCE_FOCUS_MAP.keys())
71
118
 
72
119
  COUNCIL_DISPLAY_NAMES: dict[str, str] = {
73
- "auto": "Auto (Best)",
74
- "sonar": "Sonar 2",
75
- "gpt54": "GPT-5.4",
76
- "gpt55": "GPT-5.5",
77
- "claude_sonnet": "Claude Sonnet 4.6",
78
- "claude_opus": "Claude Opus 4.7",
79
- "gemini_pro": "Gemini 3.1 Pro",
80
- "nemotron": "Nemotron 3 Super",
81
- "kimi_k26": "Kimi K2.6",
120
+ name: definition.display_name for name, definition in MODEL_METADATA.items()
82
121
  }
83
122
 
84
123
  THINKING_TOGGLEABLE: frozenset[str] = frozenset(
85
124
  name for name, (base, thinking) in MODEL_MAP.items() if thinking is not None and thinking is not base
86
125
  )
87
126
 
127
+ MAX_ONLY_MODEL_NAMES: frozenset[str] = frozenset(
128
+ name for name, definition in MODEL_METADATA.items() if definition.minimum_tier == "max"
129
+ )
130
+
131
+ COUNCIL_ELIGIBLE_MODEL_NAMES: tuple[str, ...] = tuple(
132
+ name for name, definition in MODEL_METADATA.items() if definition.council_eligible
133
+ )
134
+
135
+ COUNCIL_DEFAULT_MODEL_NAMES: tuple[str, ...] = ("gpt54", "claude_sonnet", "gemini_pro")
136
+ COUNCIL_DEFAULT_MODELS_STR = ",".join(COUNCIL_DEFAULT_MODEL_NAMES)
137
+
138
+
139
+ def build_council_model_list(
140
+ model_names: tuple[str, ...] | list[str],
141
+ thinking: bool = False,
142
+ ) -> list[tuple[str, Model]]:
143
+ """Build display/model pairs for council execution from model metadata."""
144
+ model_list: list[tuple[str, Model]] = []
145
+ for name in model_names:
146
+ resolved = resolve_model(name, thinking=thinking)
147
+ display = COUNCIL_DISPLAY_NAMES.get(name, name)
148
+ if thinking and name in THINKING_TOGGLEABLE:
149
+ display += " Thinking"
150
+ model_list.append((display, resolved))
151
+ return model_list
152
+
88
153
 
89
154
  def resolve_model(name: str, thinking: bool = False) -> Model:
90
155
  """Resolve a model name string to a Model instance.