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.
@@ -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())