exemplar-cli 0.1.2__tar.gz → 0.1.4__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: exemplar-cli
3
- Version: 0.1.2
4
- Summary: Exemplar terminal CLI for skills, prompts, and memory
3
+ Version: 0.1.4
4
+ Summary: Exemplar terminal CLI for skills, prompts, gateway catalog, and memory
5
5
  License: LicenseRef-Proprietary
6
6
  Author: Exemplar Dev LLC
7
7
  Requires-Python: >=3.10,<4.0
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.10
11
11
  Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
- Requires-Dist: exemplar-core (>=0.1.2,<0.2)
13
+ Requires-Dist: exemplar-core (>=0.1.3,<0.2)
14
14
  Description-Content-Type: text/markdown
15
15
 
16
16
  # exemplar-cli
@@ -71,6 +71,7 @@ exemplar memory search "formatting" --user-id u1
71
71
  | `EXEMPLAR_API_KEY` | Org API key (required) |
72
72
  | `EXEMPLAR_BASE_URL` | API host override (optional) |
73
73
  | `EXEMPLAR_ORGANIZATION_ID` | Org override for JWT auth (optional) |
74
+ | `EXEMPLAR_SKILLS_TARGET` | Default skill install preset: `cursor`, `cursor-global`, `claude`, `claude-global` |
74
75
 
75
76
  ## Commands
76
77
 
@@ -82,6 +83,34 @@ exemplar memory search "formatting" --user-id u1
82
83
 
83
84
  `list`, `get`, `search`, `pull`, `push`, `init`, `export-zip`
84
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
+
85
114
  ### Prompts
86
115
 
87
116
  `list`, `get`, `search`, `create`, `run`, `publish`, `delete`
@@ -56,6 +56,7 @@ exemplar memory search "formatting" --user-id u1
56
56
  | `EXEMPLAR_API_KEY` | Org API key (required) |
57
57
  | `EXEMPLAR_BASE_URL` | API host override (optional) |
58
58
  | `EXEMPLAR_ORGANIZATION_ID` | Org override for JWT auth (optional) |
59
+ | `EXEMPLAR_SKILLS_TARGET` | Default skill install preset: `cursor`, `cursor-global`, `claude`, `claude-global` |
59
60
 
60
61
  ## Commands
61
62
 
@@ -67,6 +68,34 @@ exemplar memory search "formatting" --user-id u1
67
68
 
68
69
  `list`, `get`, `search`, `pull`, `push`, `init`, `export-zip`
69
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
+
70
99
  ### Prompts
71
100
 
72
101
  `list`, `get`, `search`, `create`, `run`, `publish`, `delete`
@@ -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,111 @@
1
+ """CLI: exemplar gateway — list AI gateway providers and live models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from exemplar_core import gateway_from_env
10
+
11
+
12
+ def _cmd_providers(args: argparse.Namespace) -> int:
13
+ providers = gateway_from_env().list_providers(llm_only=not args.all_providers)
14
+ if args.json:
15
+ print(
16
+ json.dumps(
17
+ [
18
+ {
19
+ "provider_key": p.provider_key,
20
+ "display_name": p.display_name,
21
+ "categories": p.categories,
22
+ }
23
+ for p in providers
24
+ ],
25
+ indent=2,
26
+ )
27
+ )
28
+ return 0
29
+ for item in providers:
30
+ cats = ",".join(item.categories) or "-"
31
+ print(f"{item.provider_key}\t{item.display_name}\t{cats}")
32
+ return 0
33
+
34
+
35
+ def _cmd_models(args: argparse.Namespace) -> int:
36
+ result = gateway_from_env().list_models(args.provider)
37
+ if args.json:
38
+ print(
39
+ json.dumps(
40
+ {
41
+ "provider_key": result.provider_key,
42
+ "source": result.source,
43
+ "error": result.error,
44
+ "models": [
45
+ {
46
+ "gateway_model_id": m.gateway_model_id,
47
+ "name": m.name,
48
+ }
49
+ for m in result.models
50
+ ],
51
+ },
52
+ indent=2,
53
+ )
54
+ )
55
+ return 0
56
+ if result.error:
57
+ print(f"unavailable: {result.error}", file=sys.stderr)
58
+ return 1
59
+ for model in result.models:
60
+ print(f"{model.gateway_model_id}\t{model.name}")
61
+ return 0
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ parser = argparse.ArgumentParser(
66
+ prog="exemplar gateway",
67
+ description=(
68
+ "List AI gateway providers and live upstream models from the Exemplar "
69
+ "integration-service catalog (/api/gateway)."
70
+ ),
71
+ epilog=(
72
+ "Examples:\n"
73
+ " exemplar gateway providers\n"
74
+ " exemplar gateway models --provider gemini\n"
75
+ ),
76
+ formatter_class=argparse.RawDescriptionHelpFormatter,
77
+ )
78
+ sub = parser.add_subparsers(dest="command", required=True)
79
+
80
+ providers_p = sub.add_parser(
81
+ "providers",
82
+ help="List gateway LLM providers",
83
+ description="List providers from integrations/gateway_providers.yaml.",
84
+ )
85
+ providers_p.add_argument(
86
+ "--all-providers",
87
+ action="store_true",
88
+ help="Include non-LLM providers (default: LLM only)",
89
+ )
90
+ providers_p.add_argument("--json", action="store_true", help="Output JSON")
91
+ providers_p.set_defaults(func=_cmd_providers)
92
+
93
+ models_p = sub.add_parser(
94
+ "models",
95
+ help="List live models for a provider",
96
+ description="Proxy live model list from exemplar-ai-gateway for a connected provider.",
97
+ )
98
+ models_p.add_argument(
99
+ "provider",
100
+ metavar="PROVIDER",
101
+ help="Provider key, e.g. openai or gemini",
102
+ )
103
+ models_p.add_argument("--json", action="store_true", help="Output JSON")
104
+ models_p.set_defaults(func=_cmd_models)
105
+
106
+ args = parser.parse_args(argv)
107
+ return int(args.func(args))
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
@@ -0,0 +1,76 @@
1
+ """Exemplar CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from exemplar_cli.doctor import main as doctor_main
9
+ from exemplar_cli.gateway import main as gateway_main
10
+ from exemplar_cli.memory import main as memory_main
11
+ from exemplar_cli.prompts import main as prompts_main
12
+ from exemplar_cli.skills import main as skills_main
13
+
14
+ _EPILOG = """
15
+ Commands:
16
+ skills Manage org agent skills (pull to Cursor/Claude, push from disk)
17
+ prompts Manage versioned prompt templates (create, run, publish)
18
+ gateway List AI gateway providers and live models
19
+ memory Manage long-term agent memory (add, search, recall)
20
+ doctor Verify install, PATH, and EXEMPLAR_API_KEY
21
+
22
+ Examples:
23
+ exemplar doctor
24
+ exemplar skills pull refund-policy
25
+ exemplar prompts list
26
+ exemplar gateway providers
27
+ exemplar memory add "User prefers bullets" --user-id u1
28
+
29
+ Run `exemplar <command> --help` for full subcommand reference.
30
+ """
31
+
32
+
33
+ def _build_root_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="exemplar",
36
+ description=(
37
+ "Exemplar terminal CLI for skills, prompts, and memory. "
38
+ "Requires EXEMPLAR_API_KEY (run `exemplar doctor` to verify setup)."
39
+ ),
40
+ formatter_class=argparse.RawDescriptionHelpFormatter,
41
+ epilog=_EPILOG,
42
+ )
43
+ parser.add_argument(
44
+ "command",
45
+ nargs="?",
46
+ choices=["skills", "memory", "prompts", "gateway", "doctor"],
47
+ help="Command group to run",
48
+ )
49
+ parser.add_argument("args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
50
+ return parser
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ raw = list(argv if argv is not None else sys.argv[1:])
55
+ if raw and raw[0] in ("skills", "memory", "prompts", "gateway", "doctor"):
56
+ if raw[0] == "skills":
57
+ return skills_main(raw[1:])
58
+ if raw[0] == "memory":
59
+ return memory_main(raw[1:])
60
+ if raw[0] == "prompts":
61
+ return prompts_main(raw[1:])
62
+ if raw[0] == "gateway":
63
+ return gateway_main(raw[1:])
64
+ if raw[0] == "doctor":
65
+ return doctor_main(raw[1:])
66
+
67
+ parser = _build_root_parser()
68
+ args = parser.parse_args(raw)
69
+ if args.command is None:
70
+ parser.print_help()
71
+ return 2
72
+ return main([args.command, *args.args])
73
+
74
+
75
+ if __name__ == "__main__":
76
+ raise SystemExit(main())