exemplar-cli 0.1.1__tar.gz → 0.1.3__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.
- exemplar_cli-0.1.3/PKG-INFO +123 -0
- exemplar_cli-0.1.3/README.md +107 -0
- exemplar_cli-0.1.3/exemplar_cli/_scopes.py +49 -0
- exemplar_cli-0.1.3/exemplar_cli/_skill_targets.py +205 -0
- exemplar_cli-0.1.3/exemplar_cli/doctor.py +166 -0
- exemplar_cli-0.1.3/exemplar_cli/main.py +71 -0
- exemplar_cli-0.1.3/exemplar_cli/memory.py +264 -0
- exemplar_cli-0.1.3/exemplar_cli/prompts.py +234 -0
- exemplar_cli-0.1.3/exemplar_cli/skills.py +334 -0
- {exemplar_cli-0.1.1 → exemplar_cli-0.1.3}/pyproject.toml +3 -3
- exemplar_cli-0.1.1/PKG-INFO +0 -48
- exemplar_cli-0.1.1/README.md +0 -32
- exemplar_cli-0.1.1/exemplar_cli/_scopes.py +0 -23
- exemplar_cli-0.1.1/exemplar_cli/main.py +0 -27
- exemplar_cli-0.1.1/exemplar_cli/memory.py +0 -196
- exemplar_cli-0.1.1/exemplar_cli/skills.py +0 -201
- {exemplar_cli-0.1.1 → exemplar_cli-0.1.3}/exemplar_cli/__init__.py +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: exemplar-cli
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Exemplar terminal CLI for skills, prompts, and memory
|
|
5
|
+
License: LicenseRef-Proprietary
|
|
6
|
+
Author: Exemplar Dev LLC
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: exemplar-core (>=0.1.2,<0.2)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# exemplar-cli
|
|
17
|
+
|
|
18
|
+
Terminal CLI for Exemplar **skills** and **memory**.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
**Recommended** — isolated install with automatic PATH setup:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install exemplar-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Other options:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# System / python.org install (macOS, Linux)
|
|
32
|
+
pip install exemplar-cli
|
|
33
|
+
|
|
34
|
+
# User install (no sudo)
|
|
35
|
+
pip install --user exemplar-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
After install, verify everything:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
exemplar doctor
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If you see `command not found: exemplar`, the package is installed but its `bin/`
|
|
45
|
+
directory is not on your `PATH`. `exemplar doctor` prints the exact fix for your
|
|
46
|
+
machine. Common cases:
|
|
47
|
+
|
|
48
|
+
| Install method | Scripts directory |
|
|
49
|
+
|----------------|-------------------|
|
|
50
|
+
| `pipx install` | `~/.local/bin` (pipx adds this automatically) |
|
|
51
|
+
| python.org macOS | `/Library/Frameworks/Python.framework/Versions/3.x/bin` |
|
|
52
|
+
| `pip install --user` | `~/Library/Python/3.x/bin` (macOS) or `~/.local/bin` (Linux) |
|
|
53
|
+
|
|
54
|
+
Open a **new terminal** after changing shell profile files.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export EXEMPLAR_API_KEY=eis_...
|
|
60
|
+
|
|
61
|
+
exemplar doctor
|
|
62
|
+
exemplar skills list
|
|
63
|
+
exemplar memory add "User prefers bullet points" --user-id u1 --type preference
|
|
64
|
+
exemplar memory search "formatting" --user-id u1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Environment
|
|
68
|
+
|
|
69
|
+
| Variable | Purpose |
|
|
70
|
+
|----------|---------|
|
|
71
|
+
| `EXEMPLAR_API_KEY` | Org API key (required) |
|
|
72
|
+
| `EXEMPLAR_BASE_URL` | API host override (optional) |
|
|
73
|
+
| `EXEMPLAR_ORGANIZATION_ID` | Org override for JWT auth (optional) |
|
|
74
|
+
| `EXEMPLAR_SKILLS_TARGET` | Default skill install preset: `cursor`, `cursor-global`, `claude`, `claude-global` |
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
### Doctor
|
|
79
|
+
|
|
80
|
+
`exemplar doctor` — check package install, `PATH`, and required environment variables.
|
|
81
|
+
|
|
82
|
+
### Skills
|
|
83
|
+
|
|
84
|
+
`list`, `get`, `search`, `pull`, `push`, `init`, `export-zip`
|
|
85
|
+
|
|
86
|
+
**Skill install destinations** (`pull`, `init`):
|
|
87
|
+
|
|
88
|
+
| Target | Path | Confirmation |
|
|
89
|
+
|--------|------|----------------|
|
|
90
|
+
| `cursor` (default) | `.cursor/skills` | No |
|
|
91
|
+
| `cursor-global` | `~/.cursor/skills` | No |
|
|
92
|
+
| `claude` | `.claude/skills` | Yes |
|
|
93
|
+
| `claude-global` | `~/.claude/skills` | Yes |
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Default — Cursor project skills (no prompt)
|
|
97
|
+
exemplar skills pull refund-policy
|
|
98
|
+
|
|
99
|
+
# Global Cursor skills
|
|
100
|
+
exemplar skills pull refund-policy --global
|
|
101
|
+
|
|
102
|
+
# Claude Code — asks for confirmation (or use -y)
|
|
103
|
+
exemplar skills pull refund-policy --target claude-global
|
|
104
|
+
|
|
105
|
+
# Pick interactively when multiple agents are installed
|
|
106
|
+
exemplar skills pull --all -i
|
|
107
|
+
|
|
108
|
+
# Override with a custom directory
|
|
109
|
+
exemplar skills pull refund-policy --dest ./my-skills
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Set a default via `EXEMPLAR_SKILLS_TARGET=cursor-global`.
|
|
113
|
+
|
|
114
|
+
### Prompts
|
|
115
|
+
|
|
116
|
+
`list`, `get`, `search`, `create`, `run`, `publish`, `delete`
|
|
117
|
+
|
|
118
|
+
### Memory
|
|
119
|
+
|
|
120
|
+
`add`, `list`, `get`, `search`, `recall`, `update`, `delete`, `delete-bulk`
|
|
121
|
+
|
|
122
|
+
Memory commands accept scope flags: `--user-id`, `--agent-id`, `--session-id`, `--app-id`.
|
|
123
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# exemplar-cli
|
|
2
|
+
|
|
3
|
+
Terminal CLI for Exemplar **skills** and **memory**.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
**Recommended** — isolated install with automatic PATH setup:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install exemplar-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Other options:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# System / python.org install (macOS, Linux)
|
|
17
|
+
pip install exemplar-cli
|
|
18
|
+
|
|
19
|
+
# User install (no sudo)
|
|
20
|
+
pip install --user exemplar-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
After install, verify everything:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
exemplar doctor
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If you see `command not found: exemplar`, the package is installed but its `bin/`
|
|
30
|
+
directory is not on your `PATH`. `exemplar doctor` prints the exact fix for your
|
|
31
|
+
machine. Common cases:
|
|
32
|
+
|
|
33
|
+
| Install method | Scripts directory |
|
|
34
|
+
|----------------|-------------------|
|
|
35
|
+
| `pipx install` | `~/.local/bin` (pipx adds this automatically) |
|
|
36
|
+
| python.org macOS | `/Library/Frameworks/Python.framework/Versions/3.x/bin` |
|
|
37
|
+
| `pip install --user` | `~/Library/Python/3.x/bin` (macOS) or `~/.local/bin` (Linux) |
|
|
38
|
+
|
|
39
|
+
Open a **new terminal** after changing shell profile files.
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export EXEMPLAR_API_KEY=eis_...
|
|
45
|
+
|
|
46
|
+
exemplar doctor
|
|
47
|
+
exemplar skills list
|
|
48
|
+
exemplar memory add "User prefers bullet points" --user-id u1 --type preference
|
|
49
|
+
exemplar memory search "formatting" --user-id u1
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Environment
|
|
53
|
+
|
|
54
|
+
| Variable | Purpose |
|
|
55
|
+
|----------|---------|
|
|
56
|
+
| `EXEMPLAR_API_KEY` | Org API key (required) |
|
|
57
|
+
| `EXEMPLAR_BASE_URL` | API host override (optional) |
|
|
58
|
+
| `EXEMPLAR_ORGANIZATION_ID` | Org override for JWT auth (optional) |
|
|
59
|
+
| `EXEMPLAR_SKILLS_TARGET` | Default skill install preset: `cursor`, `cursor-global`, `claude`, `claude-global` |
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
### Doctor
|
|
64
|
+
|
|
65
|
+
`exemplar doctor` — check package install, `PATH`, and required environment variables.
|
|
66
|
+
|
|
67
|
+
### Skills
|
|
68
|
+
|
|
69
|
+
`list`, `get`, `search`, `pull`, `push`, `init`, `export-zip`
|
|
70
|
+
|
|
71
|
+
**Skill install destinations** (`pull`, `init`):
|
|
72
|
+
|
|
73
|
+
| Target | Path | Confirmation |
|
|
74
|
+
|--------|------|----------------|
|
|
75
|
+
| `cursor` (default) | `.cursor/skills` | No |
|
|
76
|
+
| `cursor-global` | `~/.cursor/skills` | No |
|
|
77
|
+
| `claude` | `.claude/skills` | Yes |
|
|
78
|
+
| `claude-global` | `~/.claude/skills` | Yes |
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Default — Cursor project skills (no prompt)
|
|
82
|
+
exemplar skills pull refund-policy
|
|
83
|
+
|
|
84
|
+
# Global Cursor skills
|
|
85
|
+
exemplar skills pull refund-policy --global
|
|
86
|
+
|
|
87
|
+
# Claude Code — asks for confirmation (or use -y)
|
|
88
|
+
exemplar skills pull refund-policy --target claude-global
|
|
89
|
+
|
|
90
|
+
# Pick interactively when multiple agents are installed
|
|
91
|
+
exemplar skills pull --all -i
|
|
92
|
+
|
|
93
|
+
# Override with a custom directory
|
|
94
|
+
exemplar skills pull refund-policy --dest ./my-skills
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Set a default via `EXEMPLAR_SKILLS_TARGET=cursor-global`.
|
|
98
|
+
|
|
99
|
+
### Prompts
|
|
100
|
+
|
|
101
|
+
`list`, `get`, `search`, `create`, `run`, `publish`, `delete`
|
|
102
|
+
|
|
103
|
+
### Memory
|
|
104
|
+
|
|
105
|
+
`add`, `list`, `get`, `search`, `recall`, `update`, `delete`, `delete-bulk`
|
|
106
|
+
|
|
107
|
+
Memory commands accept scope flags: `--user-id`, `--agent-id`, `--session-id`, `--app-id`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shared CLI helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from exemplar_core.memory.models import MemoryScopes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_scope_arguments(parser: argparse.ArgumentParser) -> None:
|
|
11
|
+
"""Add memory scope flags (at least one required for most memory commands)."""
|
|
12
|
+
scope = parser.add_argument_group(
|
|
13
|
+
"memory scope",
|
|
14
|
+
"Scope memories to a user, agent, session, or app. At least one scope flag is "
|
|
15
|
+
"required for add, list, search, recall, update, delete, and delete-bulk.",
|
|
16
|
+
)
|
|
17
|
+
scope.add_argument(
|
|
18
|
+
"--user-id",
|
|
19
|
+
dest="user_id",
|
|
20
|
+
metavar="ID",
|
|
21
|
+
help="End-user identifier (e.g. u123)",
|
|
22
|
+
)
|
|
23
|
+
scope.add_argument(
|
|
24
|
+
"--agent-id",
|
|
25
|
+
dest="agent_id",
|
|
26
|
+
metavar="ID",
|
|
27
|
+
help="Agent identifier within your org",
|
|
28
|
+
)
|
|
29
|
+
scope.add_argument(
|
|
30
|
+
"--session-id",
|
|
31
|
+
dest="session_id",
|
|
32
|
+
metavar="ID",
|
|
33
|
+
help="Conversation or eval session identifier",
|
|
34
|
+
)
|
|
35
|
+
scope.add_argument(
|
|
36
|
+
"--app-id",
|
|
37
|
+
dest="app_id",
|
|
38
|
+
metavar="ID",
|
|
39
|
+
help="Source application identifier",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scopes_from_args(args: argparse.Namespace) -> MemoryScopes:
|
|
44
|
+
return MemoryScopes(
|
|
45
|
+
user_id=args.user_id,
|
|
46
|
+
agent_id=args.agent_id,
|
|
47
|
+
session_id=args.session_id,
|
|
48
|
+
app_id=args.app_id,
|
|
49
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Resolve local skill install destinations for Cursor, Claude, and custom paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
SkillTargetId = Literal["cursor", "cursor-global", "claude", "claude-global"]
|
|
12
|
+
|
|
13
|
+
DEFAULT_TARGET_ID: SkillTargetId = "cursor"
|
|
14
|
+
CONFIRM_TARGETS = frozenset({"claude", "claude-global"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SkillTarget:
|
|
19
|
+
target_id: SkillTargetId
|
|
20
|
+
agent: str
|
|
21
|
+
scope: str
|
|
22
|
+
relative: bool
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def label(self) -> str:
|
|
26
|
+
return f"{self.agent} ({self.scope})"
|
|
27
|
+
|
|
28
|
+
def resolve_path(self) -> Path:
|
|
29
|
+
if self.target_id == "cursor":
|
|
30
|
+
return Path(".cursor") / "skills"
|
|
31
|
+
if self.target_id == "cursor-global":
|
|
32
|
+
return Path.home() / ".cursor" / "skills"
|
|
33
|
+
if self.target_id == "claude":
|
|
34
|
+
return Path(".claude") / "skills"
|
|
35
|
+
if self.target_id == "claude-global":
|
|
36
|
+
return Path.home() / ".claude" / "skills"
|
|
37
|
+
raise ValueError(f"unknown target: {self.target_id}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
TARGETS: dict[SkillTargetId, SkillTarget] = {
|
|
41
|
+
"cursor": SkillTarget("cursor", "Cursor", "project", relative=True),
|
|
42
|
+
"cursor-global": SkillTarget("cursor-global", "Cursor", "global", relative=False),
|
|
43
|
+
"claude": SkillTarget("claude", "Claude Code", "project", relative=True),
|
|
44
|
+
"claude-global": SkillTarget("claude-global", "Claude Code", "global", relative=False),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _env_target() -> SkillTargetId | None:
|
|
49
|
+
raw = (os.environ.get("EXEMPLAR_SKILLS_TARGET") or "").strip().lower()
|
|
50
|
+
if not raw:
|
|
51
|
+
return None
|
|
52
|
+
if raw in ("cursor", "cursor-global", "claude", "claude-global"):
|
|
53
|
+
return raw # type: ignore[return-value]
|
|
54
|
+
if raw == "global":
|
|
55
|
+
return "cursor-global"
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"invalid EXEMPLAR_SKILLS_TARGET={raw!r}; "
|
|
58
|
+
"use cursor, cursor-global, claude, or claude-global"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _agent_hint_present(target_id: SkillTargetId) -> bool:
|
|
63
|
+
if target_id.startswith("cursor"):
|
|
64
|
+
home = Path.home() / ".cursor"
|
|
65
|
+
return home.is_dir() or (Path.cwd() / ".cursor").is_dir()
|
|
66
|
+
if target_id.startswith("claude"):
|
|
67
|
+
home = Path.home() / ".claude"
|
|
68
|
+
return home.is_dir() or (Path.cwd() / ".claude").is_dir()
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_available_targets() -> list[SkillTargetId]:
|
|
73
|
+
"""Targets whose agent directories appear to exist on this machine."""
|
|
74
|
+
available: list[SkillTargetId] = []
|
|
75
|
+
for target_id in TARGETS:
|
|
76
|
+
if _agent_hint_present(target_id):
|
|
77
|
+
available.append(target_id)
|
|
78
|
+
return available or [DEFAULT_TARGET_ID]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_interactive() -> bool:
|
|
82
|
+
if os.environ.get("EXEMPLAR_SKILLS_INTERACTIVE", "").lower() in ("1", "true", "yes"):
|
|
83
|
+
return True
|
|
84
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _confirm(message: str) -> bool:
|
|
88
|
+
try:
|
|
89
|
+
answer = input(f"{message} [y/N]: ").strip().lower()
|
|
90
|
+
except EOFError:
|
|
91
|
+
return False
|
|
92
|
+
return answer in ("y", "yes")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _pick_target_interactively() -> SkillTargetId:
|
|
96
|
+
options = detect_available_targets()
|
|
97
|
+
if len(options) == 1:
|
|
98
|
+
return options[0]
|
|
99
|
+
|
|
100
|
+
print("Where should skills be installed?\n")
|
|
101
|
+
for index, target_id in enumerate(options, start=1):
|
|
102
|
+
target = TARGETS[target_id]
|
|
103
|
+
path = target.resolve_path()
|
|
104
|
+
print(f" [{index}] {target.label:<22} {path}")
|
|
105
|
+
print(f" [0] {TARGETS[DEFAULT_TARGET_ID].label:<22} {TARGETS[DEFAULT_TARGET_ID].resolve_path()} (default)")
|
|
106
|
+
|
|
107
|
+
default_choice = "0"
|
|
108
|
+
if DEFAULT_TARGET_ID in options:
|
|
109
|
+
default_choice = str(options.index(DEFAULT_TARGET_ID) + 1)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
raw = input(f"\nChoose destination [{default_choice}]: ").strip()
|
|
113
|
+
except EOFError:
|
|
114
|
+
return DEFAULT_TARGET_ID
|
|
115
|
+
|
|
116
|
+
if not raw:
|
|
117
|
+
if default_choice == "0":
|
|
118
|
+
return DEFAULT_TARGET_ID
|
|
119
|
+
return options[int(default_choice) - 1]
|
|
120
|
+
|
|
121
|
+
if raw == "0":
|
|
122
|
+
return DEFAULT_TARGET_ID
|
|
123
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
124
|
+
return options[int(raw) - 1]
|
|
125
|
+
|
|
126
|
+
lowered = raw.lower()
|
|
127
|
+
if lowered in TARGETS:
|
|
128
|
+
return lowered # type: ignore[return-value]
|
|
129
|
+
return DEFAULT_TARGET_ID
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _normalize_legacy_global(
|
|
133
|
+
*,
|
|
134
|
+
target: SkillTargetId | None,
|
|
135
|
+
global_flag: bool,
|
|
136
|
+
) -> SkillTargetId | None:
|
|
137
|
+
if target is not None:
|
|
138
|
+
return target
|
|
139
|
+
if global_flag:
|
|
140
|
+
return "cursor-global"
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_skill_target(
|
|
145
|
+
*,
|
|
146
|
+
dest: str | None = None,
|
|
147
|
+
target: str | None = None,
|
|
148
|
+
global_flag: bool = False,
|
|
149
|
+
yes: bool = False,
|
|
150
|
+
no_input: bool = False,
|
|
151
|
+
interactive: bool = False,
|
|
152
|
+
) -> tuple[Path, SkillTarget | None]:
|
|
153
|
+
"""
|
|
154
|
+
Resolve pull/init destination.
|
|
155
|
+
|
|
156
|
+
Returns (path, target_meta). target_meta is None when `dest` is explicit.
|
|
157
|
+
"""
|
|
158
|
+
if dest:
|
|
159
|
+
return Path(dest), None
|
|
160
|
+
|
|
161
|
+
target_id: SkillTargetId | None
|
|
162
|
+
try:
|
|
163
|
+
target_id = _env_target()
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
raise SystemExit(str(exc)) from exc
|
|
166
|
+
|
|
167
|
+
if target:
|
|
168
|
+
lowered = target.strip().lower()
|
|
169
|
+
if lowered == "global":
|
|
170
|
+
lowered = "cursor-global"
|
|
171
|
+
if lowered not in TARGETS:
|
|
172
|
+
raise SystemExit(
|
|
173
|
+
f"unknown --target {target!r}; choose from: cursor, cursor-global, claude, claude-global"
|
|
174
|
+
)
|
|
175
|
+
target_id = lowered # type: ignore[assignment]
|
|
176
|
+
|
|
177
|
+
target_id = _normalize_legacy_global(target=target_id, global_flag=global_flag)
|
|
178
|
+
|
|
179
|
+
if target_id is None and interactive and _is_interactive() and not no_input:
|
|
180
|
+
target_id = _pick_target_interactively()
|
|
181
|
+
elif target_id is None:
|
|
182
|
+
target_id = DEFAULT_TARGET_ID
|
|
183
|
+
|
|
184
|
+
meta = TARGETS[target_id]
|
|
185
|
+
path = meta.resolve_path()
|
|
186
|
+
|
|
187
|
+
if target_id in CONFIRM_TARGETS and not yes:
|
|
188
|
+
if no_input:
|
|
189
|
+
raise SystemExit(
|
|
190
|
+
f"{meta.label} requires confirmation; re-run with --yes or use --target cursor"
|
|
191
|
+
)
|
|
192
|
+
if not _is_interactive():
|
|
193
|
+
raise SystemExit(
|
|
194
|
+
f"{meta.label} ({path}) requires confirmation; re-run with --yes in non-interactive mode"
|
|
195
|
+
)
|
|
196
|
+
if not _confirm(f"Install skills to {path} for {meta.agent}?"):
|
|
197
|
+
raise SystemExit("cancelled")
|
|
198
|
+
|
|
199
|
+
return path, meta
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def format_destination_banner(path: Path, meta: SkillTarget | None) -> str:
|
|
203
|
+
if meta is None:
|
|
204
|
+
return f"Destination: {path}"
|
|
205
|
+
return f"Destination: {meta.label} → {path}"
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""exemplar doctor — diagnose CLI install, PATH, and environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import site
|
|
9
|
+
import sys
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _path_entries() -> list[Path]:
|
|
15
|
+
entries: list[Path] = []
|
|
16
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
|
17
|
+
if not entry:
|
|
18
|
+
continue
|
|
19
|
+
try:
|
|
20
|
+
entries.append(Path(entry).resolve())
|
|
21
|
+
except OSError:
|
|
22
|
+
continue
|
|
23
|
+
return entries
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _locate_installed_script() -> Path | None:
|
|
27
|
+
"""Find the exemplar console script when it is not on PATH."""
|
|
28
|
+
candidates: list[Path] = [
|
|
29
|
+
Path(sys.prefix) / "bin" / "exemplar",
|
|
30
|
+
Path(sys.executable).resolve().parent / "exemplar",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
user_base = site.USER_BASE
|
|
34
|
+
if user_base:
|
|
35
|
+
candidates.append(Path(user_base) / "bin" / "exemplar")
|
|
36
|
+
|
|
37
|
+
if sys.platform == "darwin":
|
|
38
|
+
version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
39
|
+
candidates.append(
|
|
40
|
+
Path(f"/Library/Frameworks/Python.framework/Versions/{version}/bin/exemplar")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
dist = distribution("exemplar-cli")
|
|
45
|
+
for path in dist.files or ():
|
|
46
|
+
normalized = str(path).replace("\\", "/")
|
|
47
|
+
if normalized.endswith("/bin/exemplar") or normalized == "../../../bin/exemplar":
|
|
48
|
+
root = Path(dist.locate_file(""))
|
|
49
|
+
candidates.append((root / path).resolve())
|
|
50
|
+
except PackageNotFoundError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
seen: set[Path] = set()
|
|
54
|
+
for candidate in candidates:
|
|
55
|
+
try:
|
|
56
|
+
resolved = candidate.resolve()
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
if resolved in seen:
|
|
60
|
+
continue
|
|
61
|
+
seen.add(resolved)
|
|
62
|
+
if resolved.is_file():
|
|
63
|
+
return resolved
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _script_dir_on_path(script_dir: Path) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
resolved = script_dir.resolve()
|
|
70
|
+
except OSError:
|
|
71
|
+
return False
|
|
72
|
+
return resolved in _path_entries()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _path_fix_lines(script_dir: Path) -> list[str]:
|
|
76
|
+
export_line = f'export PATH="{script_dir}:$PATH"'
|
|
77
|
+
return [
|
|
78
|
+
"Add one of the following to ~/.zprofile (macOS login shells) or ~/.zshrc:",
|
|
79
|
+
f" {export_line}",
|
|
80
|
+
"",
|
|
81
|
+
"Or symlink into a directory already on PATH:",
|
|
82
|
+
f" ln -sf {script_dir / 'exemplar'} ~/.local/bin/exemplar",
|
|
83
|
+
"",
|
|
84
|
+
"Recommended for CLI tools:",
|
|
85
|
+
" pipx install exemplar-cli",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_api_key() -> tuple[bool, str]:
|
|
90
|
+
key = os.environ.get("EXEMPLAR_API_KEY", "").strip()
|
|
91
|
+
if key:
|
|
92
|
+
return True, "EXEMPLAR_API_KEY is set"
|
|
93
|
+
return False, "EXEMPLAR_API_KEY is not set (required for skills/memory commands)"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
del argv # doctor accepts no subcommands yet
|
|
98
|
+
issues = 0
|
|
99
|
+
|
|
100
|
+
print("Exemplar doctor")
|
|
101
|
+
print("===============")
|
|
102
|
+
|
|
103
|
+
print(
|
|
104
|
+
f"Python: {platform.python_version()} "
|
|
105
|
+
f"({platform.machine()}, {sys.executable})"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
version = distribution("exemplar-cli").version
|
|
110
|
+
print(f"exemplar-cli package: installed ({version})")
|
|
111
|
+
except PackageNotFoundError:
|
|
112
|
+
issues += 1
|
|
113
|
+
print("exemplar-cli package: NOT INSTALLED")
|
|
114
|
+
print(" Install with: pipx install exemplar-cli")
|
|
115
|
+
print(" or: pip install exemplar-cli")
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
command = shutil.which("exemplar")
|
|
119
|
+
if command:
|
|
120
|
+
print(f"exemplar command: OK ({command})")
|
|
121
|
+
else:
|
|
122
|
+
issues += 1
|
|
123
|
+
script = _locate_installed_script()
|
|
124
|
+
print("exemplar command: NOT ON PATH")
|
|
125
|
+
if script:
|
|
126
|
+
print(f" Script installed at: {script}")
|
|
127
|
+
if not _script_dir_on_path(script.parent):
|
|
128
|
+
print(" Fix:")
|
|
129
|
+
for line in _path_fix_lines(script.parent):
|
|
130
|
+
print(f" {line}" if line else "")
|
|
131
|
+
else:
|
|
132
|
+
print(" Script directory is on PATH; open a new terminal or run:")
|
|
133
|
+
print(" source ~/.zprofile")
|
|
134
|
+
else:
|
|
135
|
+
print(" Could not locate the exemplar script.")
|
|
136
|
+
print(" Reinstall with: pipx install exemplar-cli")
|
|
137
|
+
|
|
138
|
+
api_ok, api_msg = _check_api_key()
|
|
139
|
+
if api_ok:
|
|
140
|
+
print(f"API key: OK — {api_msg}")
|
|
141
|
+
else:
|
|
142
|
+
issues += 1
|
|
143
|
+
print(f"API key: MISSING — {api_msg}")
|
|
144
|
+
print(" Set with: export EXEMPLAR_API_KEY=eis_...")
|
|
145
|
+
|
|
146
|
+
base_url = os.environ.get("EXEMPLAR_BASE_URL", "").strip()
|
|
147
|
+
if base_url:
|
|
148
|
+
print(f"API base URL: {base_url}")
|
|
149
|
+
else:
|
|
150
|
+
print("API base URL: default (https://api.exemplar.dev)")
|
|
151
|
+
|
|
152
|
+
org_id = os.environ.get("EXEMPLAR_ORGANIZATION_ID", "").strip()
|
|
153
|
+
if org_id:
|
|
154
|
+
print(f"Organization override: {org_id}")
|
|
155
|
+
|
|
156
|
+
print()
|
|
157
|
+
if issues:
|
|
158
|
+
print(f"{issues} issue(s) found.")
|
|
159
|
+
return 1
|
|
160
|
+
|
|
161
|
+
print("All checks passed.")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
raise SystemExit(main())
|