wealthbox-cli 1.1.0__tar.gz → 1.1.2__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.
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/PKG-INFO +7 -1
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/README.md +6 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/pyproject.toml +1 -1
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_skill_bootstrap.py +45 -15
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_skill_platforms.py +30 -4
- wealthbox_cli-1.1.2/src/wealthbox_tools/cli/skills.py +455 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/SKILL.md +59 -4
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/bootstrap.md +10 -15
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/notes.md +1 -1
- wealthbox_cli-1.1.0/src/wealthbox_tools/cli/skills.py +0 -270
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.env.example +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.github/workflows/ci.yml +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.github/workflows/docs.yml +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.gitignore +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/CONTRIBUTING.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/LICENSE +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/SECURITY.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/changelog.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/cli-reference.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/getting-started.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/index.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/overrides/main.html +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/promotion.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/mkdocs.yml +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/__init__.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/__init__.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_config.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_util.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/activity.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/categories.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/config.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/contacts.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/events.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/households.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/main.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/me.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/notes.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/opportunities.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/projects.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/tasks.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/users.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/workflows.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/__init__.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/activity.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/base.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/categories.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/comments.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/contacts.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/events.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/households.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/me.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/notes.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/opportunities.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/projects.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/tasks.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/users.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/workflows.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/__init__.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/activity.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/comments.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/common.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/contacts.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/custom_fields.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/enums.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/events.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/households.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/notes.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/opportunities.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/projects.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/tasks.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/workflows.py +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/py.typed +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/contacts.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/events.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/notes.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/opportunities.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/projects.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/tasks.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/workflows.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/contacts.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/events.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/households.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/lookups.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/opportunities.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/projects.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/tasks.md +0 -0
- {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/workflows.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wealthbox-cli
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: CLI and client library for the Wealthbox CRM API
|
|
5
5
|
Project-URL: Homepage, https://github.com/massive-value/wealthbox-cli
|
|
6
6
|
Project-URL: Documentation, https://massive-value.github.io/wealthbox-cli/
|
|
@@ -165,9 +165,15 @@ This updates the generated half of the skill's firm files; hand-edited policy is
|
|
|
165
165
|
```bash
|
|
166
166
|
wbox skills list # show where the skill is installed + last bootstrap time
|
|
167
167
|
wbox skills doctor # diagnose install state + token
|
|
168
|
+
wbox skills sync # copy firm/ from one install to others (Claude Code <-> Codex)
|
|
169
|
+
wbox skills upgrade # refresh template files (SKILL.md, references/, ...) preserving firm/
|
|
168
170
|
wbox skills uninstall # remove the skill
|
|
169
171
|
```
|
|
170
172
|
|
|
173
|
+
When the CLI ships a newer template (improved SKILL.md, new reference docs, etc.), `wbox skills upgrade` re-copies the bundled template into every installed platform without touching your firm-specific data in `firm/`. Each install records the template's CLI version in `_meta.json`, so the agent can detect drift and prompt you to upgrade when needed.
|
|
174
|
+
|
|
175
|
+
`wbox skills sync` is useful if you maintain firm conventions in one platform's install (e.g., your Claude Code user scope) and want the same `firm/` files mirrored to Codex or to a project-scoped `.claude/skills/` folder. The command runs as a wizard by default; pass `--source`, `--target`, and `--yes` for non-interactive use.
|
|
176
|
+
|
|
171
177
|
### Works with other agents too
|
|
172
178
|
|
|
173
179
|
The `wbox` CLI is a standard command-line tool. Any AI coding agent that can execute shell commands — [Claude Code](https://claude.ai/download), [GitHub Copilot CLI](https://githubnext.com/projects/copilot-cli), [Cursor](https://cursor.sh/), or custom agent frameworks — can use it to read and write Wealthbox data. The bundled skill just makes Claude Code and Codex aware of the full command surface.
|
|
@@ -124,9 +124,15 @@ This updates the generated half of the skill's firm files; hand-edited policy is
|
|
|
124
124
|
```bash
|
|
125
125
|
wbox skills list # show where the skill is installed + last bootstrap time
|
|
126
126
|
wbox skills doctor # diagnose install state + token
|
|
127
|
+
wbox skills sync # copy firm/ from one install to others (Claude Code <-> Codex)
|
|
128
|
+
wbox skills upgrade # refresh template files (SKILL.md, references/, ...) preserving firm/
|
|
127
129
|
wbox skills uninstall # remove the skill
|
|
128
130
|
```
|
|
129
131
|
|
|
132
|
+
When the CLI ships a newer template (improved SKILL.md, new reference docs, etc.), `wbox skills upgrade` re-copies the bundled template into every installed platform without touching your firm-specific data in `firm/`. Each install records the template's CLI version in `_meta.json`, so the agent can detect drift and prompt you to upgrade when needed.
|
|
133
|
+
|
|
134
|
+
`wbox skills sync` is useful if you maintain firm conventions in one platform's install (e.g., your Claude Code user scope) and want the same `firm/` files mirrored to Codex or to a project-scoped `.claude/skills/` folder. The command runs as a wizard by default; pass `--source`, `--target`, and `--yes` for non-interactive use.
|
|
135
|
+
|
|
130
136
|
### Works with other agents too
|
|
131
137
|
|
|
132
138
|
The `wbox` CLI is a standard command-line tool. Any AI coding agent that can execute shell commands — [Claude Code](https://claude.ai/download), [GitHub Copilot CLI](https://githubnext.com/projects/copilot-cli), [Cursor](https://cursor.sh/), or custom agent frameworks — can use it to read and write Wealthbox data. The bundled skill just makes Claude Code and Codex aware of the full command surface.
|
|
@@ -86,11 +86,11 @@ def render_users_md(users: list[dict[str, Any]]) -> str:
|
|
|
86
86
|
FIRM_README = """# firm/ — Firm Customizations
|
|
87
87
|
|
|
88
88
|
This folder holds everything the agent needs to know about your firm's
|
|
89
|
-
Wealthbox conventions.
|
|
89
|
+
Wealthbox conventions. Install metadata lives in `../_meta.json` (skill
|
|
90
|
+
root), not here.
|
|
90
91
|
|
|
91
92
|
## Generated files (overwritten by `wbox skills refresh`)
|
|
92
93
|
|
|
93
|
-
- `_meta.json` — refresh timestamps, firm identity, CLI version
|
|
94
94
|
- `categories.md` — every category type + valid values
|
|
95
95
|
- `custom-fields.md` — custom fields per document type + valid values
|
|
96
96
|
- `users.md` — user id → name → email
|
|
@@ -139,22 +139,52 @@ def write_stubs(firm_dir: Path) -> list[str]:
|
|
|
139
139
|
return written
|
|
140
140
|
|
|
141
141
|
|
|
142
|
-
def
|
|
143
|
-
|
|
142
|
+
def meta_path(skill_dir: Path) -> Path:
|
|
143
|
+
"""Location of _meta.json relative to a skill install root."""
|
|
144
|
+
return skill_dir / "_meta.json"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_meta(skill_dir: Path) -> dict[str, Any]:
|
|
148
|
+
"""Read _meta.json. Returns {} if missing or unreadable."""
|
|
149
|
+
p = meta_path(skill_dir)
|
|
150
|
+
if not p.exists():
|
|
151
|
+
return {}
|
|
152
|
+
try:
|
|
153
|
+
loaded = json.loads(p.read_text(encoding="utf-8"))
|
|
154
|
+
except (json.JSONDecodeError, OSError):
|
|
155
|
+
return {}
|
|
156
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def write_meta(skill_dir: Path, data: dict[str, Any]) -> None:
|
|
160
|
+
meta_path(skill_dir).write_text(
|
|
161
|
+
json.dumps(data, indent=2) + "\n", encoding="utf-8"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def update_template_meta(skill_dir: Path, *, cli_version: str) -> None:
|
|
166
|
+
"""Set the template section of _meta.json. Preserves any existing firm section."""
|
|
167
|
+
meta = read_meta(skill_dir)
|
|
168
|
+
meta["template"] = {"cli_version": cli_version}
|
|
169
|
+
write_meta(skill_dir, meta)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def update_firm_meta(
|
|
173
|
+
skill_dir: Path,
|
|
144
174
|
*,
|
|
145
|
-
|
|
146
|
-
firm_identity: dict[str, Any],
|
|
175
|
+
identity: dict[str, Any],
|
|
147
176
|
cli_version: str,
|
|
177
|
+
generated_files: list[str],
|
|
148
178
|
) -> None:
|
|
179
|
+
"""Set the firm section of _meta.json. Preserves any existing template section."""
|
|
149
180
|
now = datetime.now(timezone.utc).isoformat()
|
|
150
|
-
meta =
|
|
151
|
-
|
|
181
|
+
meta = read_meta(skill_dir)
|
|
182
|
+
meta["firm"] = {
|
|
183
|
+
"identity": identity,
|
|
152
184
|
"cli_version": cli_version,
|
|
153
185
|
"files": {name: now for name in generated_files},
|
|
154
186
|
}
|
|
155
|
-
(
|
|
156
|
-
json.dumps(meta, indent=2) + "\n", encoding="utf-8"
|
|
157
|
-
)
|
|
187
|
+
write_meta(skill_dir, meta)
|
|
158
188
|
|
|
159
189
|
|
|
160
190
|
# --------------------------------------------------------------------------- #
|
|
@@ -230,11 +260,11 @@ def bootstrap_skill_dir(
|
|
|
230
260
|
(firm_dir / "users.md").write_text(render_users_md(users), encoding="utf-8")
|
|
231
261
|
|
|
232
262
|
generated = ["categories.md", "custom-fields.md", "users.md"]
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
firm_identity=firm_identity,
|
|
263
|
+
update_firm_meta(
|
|
264
|
+
skill_dir,
|
|
265
|
+
identity=firm_identity,
|
|
237
266
|
cli_version=_pkg_version("wealthbox-cli"),
|
|
267
|
+
generated_files=generated,
|
|
238
268
|
)
|
|
239
269
|
|
|
240
270
|
wrote_stubs = 0
|
|
@@ -69,10 +69,36 @@ def install_skill(platform: Platform, src: Path, *, force: bool) -> None:
|
|
|
69
69
|
_safe_rmtree(platform, dest)
|
|
70
70
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
71
71
|
shutil.copytree(src, dest)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
_rename_skill_md(platform, dest)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def upgrade_skill(platform: Platform, src: Path) -> None:
|
|
76
|
+
"""Re-copy the bundled template over an existing install.
|
|
77
|
+
|
|
78
|
+
Preserves the user's `firm/` directory and root `_meta.json` (the
|
|
79
|
+
bundled template ships neither). Refreshes SKILL.md, references/,
|
|
80
|
+
firm-examples/, and bootstrap.md.
|
|
81
|
+
"""
|
|
82
|
+
dest = skill_dir(platform)
|
|
83
|
+
if not dest.is_dir() or not (dest / platform.skill_filename).is_file():
|
|
84
|
+
raise SkillInstallError(
|
|
85
|
+
f"Cannot upgrade: nothing installed at {dest}"
|
|
86
|
+
)
|
|
87
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
88
|
+
_rename_skill_md(platform, dest)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _rename_skill_md(platform: Platform, dest: Path) -> None:
|
|
92
|
+
"""Rename SKILL.md → platform.skill_filename if needed (e.g. AGENTS.md for codex)."""
|
|
93
|
+
if platform.skill_filename == "SKILL.md":
|
|
94
|
+
return
|
|
95
|
+
skill_md = dest / "SKILL.md"
|
|
96
|
+
target = dest / platform.skill_filename
|
|
97
|
+
if not skill_md.exists():
|
|
98
|
+
return
|
|
99
|
+
if target.exists():
|
|
100
|
+
target.unlink()
|
|
101
|
+
skill_md.rename(target)
|
|
76
102
|
|
|
77
103
|
|
|
78
104
|
def uninstall_skill(platform: Platform) -> None:
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from importlib.metadata import version as _pkg_version
|
|
6
|
+
from importlib.resources import as_file, files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ._skill_bootstrap import read_meta, update_template_meta
|
|
12
|
+
from ._skill_platforms import (
|
|
13
|
+
Platform,
|
|
14
|
+
SkillInstallError,
|
|
15
|
+
detect_platforms,
|
|
16
|
+
install_skill,
|
|
17
|
+
is_installed,
|
|
18
|
+
skill_dir,
|
|
19
|
+
uninstall_skill,
|
|
20
|
+
upgrade_skill,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
25
|
+
help="Install and manage the wealthbox-crm agent skill.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("list", help="Show where the skill is installed per platform.")
|
|
31
|
+
def list_platforms() -> None:
|
|
32
|
+
header = ("platform", "path", "status", "template-version", "last-bootstrap")
|
|
33
|
+
rows: list[tuple[str, str, str, str, str]] = []
|
|
34
|
+
for p in detect_platforms():
|
|
35
|
+
dest = skill_dir(p)
|
|
36
|
+
installed = is_installed(p)
|
|
37
|
+
template_version = "-"
|
|
38
|
+
last_bootstrap = "-"
|
|
39
|
+
if installed:
|
|
40
|
+
meta = read_meta(dest)
|
|
41
|
+
template_version = meta.get("template", {}).get("cli_version", "-")
|
|
42
|
+
firm_section = meta.get("firm") or {}
|
|
43
|
+
firm_files = firm_section.get("files") or {}
|
|
44
|
+
if firm_files:
|
|
45
|
+
last_bootstrap = max(firm_files.values())
|
|
46
|
+
rows.append((
|
|
47
|
+
p.id,
|
|
48
|
+
str(dest),
|
|
49
|
+
"installed" if installed else "not installed",
|
|
50
|
+
template_version,
|
|
51
|
+
last_bootstrap,
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
widths = [
|
|
55
|
+
max(len(r[i]) for r in ([header] + rows))
|
|
56
|
+
for i in range(len(header))
|
|
57
|
+
]
|
|
58
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
59
|
+
typer.echo(fmt.format(*header))
|
|
60
|
+
typer.echo(fmt.format(*("-" * w for w in widths)))
|
|
61
|
+
for r in rows:
|
|
62
|
+
typer.echo(fmt.format(*r))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_platforms(ids: list[str]) -> list[Platform]:
|
|
66
|
+
"""Translate --platform flags to Platform objects; validate they exist."""
|
|
67
|
+
known = {p.id: p for p in detect_platforms()}
|
|
68
|
+
selected: list[Platform] = []
|
|
69
|
+
for pid in ids:
|
|
70
|
+
if pid not in known:
|
|
71
|
+
raise typer.BadParameter(
|
|
72
|
+
f"unknown platform {pid!r}; choose from {sorted(known)}"
|
|
73
|
+
)
|
|
74
|
+
selected.append(known[pid])
|
|
75
|
+
return selected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _project_scope_allowed() -> bool:
|
|
79
|
+
cwd = Path.cwd()
|
|
80
|
+
return (cwd / ".git").exists() or (cwd / ".claude").exists()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _prompt_platforms() -> list[Platform]:
|
|
84
|
+
available = detect_platforms()
|
|
85
|
+
typer.echo("Select platforms (comma-separated ids):")
|
|
86
|
+
for p in available:
|
|
87
|
+
typer.echo(f" {p.id:<22} {p.label:<32} {p.root_dir}")
|
|
88
|
+
raw = typer.prompt("platforms", default="claude-code-user")
|
|
89
|
+
ids = [x.strip() for x in raw.split(",") if x.strip()]
|
|
90
|
+
return _resolve_platforms(ids)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("install", help="Install the wealthbox-crm skill to a platform.")
|
|
94
|
+
def install_cmd(
|
|
95
|
+
platforms_flag: list[str] = typer.Option(
|
|
96
|
+
[], "--platform", "-p",
|
|
97
|
+
help="Platform id: claude-code-user | claude-code-project | codex. Repeat to install to multiple.",
|
|
98
|
+
),
|
|
99
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing install."),
|
|
100
|
+
no_bootstrap: bool = typer.Option(False, "--no-bootstrap", help="Skip the post-install bootstrap prompt."),
|
|
101
|
+
) -> None:
|
|
102
|
+
targets = _resolve_platforms(platforms_flag) if platforms_flag else _prompt_platforms()
|
|
103
|
+
|
|
104
|
+
if any(t.requires_project_cwd for t in targets) and not _project_scope_allowed():
|
|
105
|
+
typer.echo(
|
|
106
|
+
"Error: claude-code-project requires the current directory to contain .git or .claude",
|
|
107
|
+
err=True,
|
|
108
|
+
)
|
|
109
|
+
raise typer.Exit(code=2)
|
|
110
|
+
|
|
111
|
+
with as_file(files("wealthbox_tools").joinpath("skills/wealthbox-crm")) as src:
|
|
112
|
+
for target in targets:
|
|
113
|
+
try:
|
|
114
|
+
install_skill(target, Path(src), force=force)
|
|
115
|
+
except SkillInstallError as e:
|
|
116
|
+
typer.echo(f"Error installing to {target.id}: {e}", err=True)
|
|
117
|
+
raise typer.Exit(code=1) from e
|
|
118
|
+
update_template_meta(skill_dir(target), cli_version=_pkg_version("wealthbox-cli"))
|
|
119
|
+
typer.echo(f"OK installed to {skill_dir(target)}")
|
|
120
|
+
|
|
121
|
+
if not no_bootstrap:
|
|
122
|
+
if typer.confirm("Run 'wbox skills bootstrap' now?", default=True):
|
|
123
|
+
from ._skill_bootstrap import bootstrap_skill_dir
|
|
124
|
+
for target in targets:
|
|
125
|
+
bootstrap_skill_dir(skill_dir(target), token=None, generated_only=False)
|
|
126
|
+
typer.echo(f"OK bootstrapped {target.id}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command("bootstrap", help="Populate firm/ files from the Wealthbox API.")
|
|
130
|
+
def bootstrap_cmd(
|
|
131
|
+
platforms_flag: list[str] = typer.Option(
|
|
132
|
+
[], "--platform", "-p",
|
|
133
|
+
help="Platform id. Default: every installed platform.",
|
|
134
|
+
),
|
|
135
|
+
generated_only: bool = typer.Option(
|
|
136
|
+
False, "--generated-only",
|
|
137
|
+
help="Only update generated files; never create stubs.",
|
|
138
|
+
),
|
|
139
|
+
dry_run: bool = typer.Option(
|
|
140
|
+
False, "--dry-run", help="Print planned writes; make no disk changes."
|
|
141
|
+
),
|
|
142
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
143
|
+
) -> None:
|
|
144
|
+
from ._skill_bootstrap import bootstrap_skill_dir
|
|
145
|
+
|
|
146
|
+
if platforms_flag:
|
|
147
|
+
targets = _resolve_platforms(platforms_flag)
|
|
148
|
+
else:
|
|
149
|
+
targets = [p for p in detect_platforms() if is_installed(p)]
|
|
150
|
+
|
|
151
|
+
if not targets:
|
|
152
|
+
typer.echo(
|
|
153
|
+
"No installed platforms found. Run 'wbox skills install' first.",
|
|
154
|
+
err=True,
|
|
155
|
+
)
|
|
156
|
+
raise typer.Exit(code=2)
|
|
157
|
+
|
|
158
|
+
if dry_run:
|
|
159
|
+
for t in targets:
|
|
160
|
+
typer.echo(
|
|
161
|
+
f"[dry-run] would bootstrap {skill_dir(t)} (generated_only={generated_only})"
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
for t in targets:
|
|
166
|
+
result = bootstrap_skill_dir(
|
|
167
|
+
skill_dir(t), token=token, generated_only=generated_only
|
|
168
|
+
)
|
|
169
|
+
typer.echo(
|
|
170
|
+
f"OK bootstrapped {t.id}: wrote {len(result.wrote_generated)} generated files, "
|
|
171
|
+
f"{result.wrote_stubs} stubs (firm: {result.firm_identity.get('name')})"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command("refresh", help="Re-fetch generated firm files. Hand-edited files are preserved.")
|
|
176
|
+
def refresh_cmd(
|
|
177
|
+
platforms_flag: list[str] = typer.Option([], "--platform", "-p"),
|
|
178
|
+
staleness_days: int = typer.Option(
|
|
179
|
+
30, "--staleness-days", help="Warn if _meta.json older than N days."
|
|
180
|
+
),
|
|
181
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
182
|
+
) -> None:
|
|
183
|
+
from ._skill_bootstrap import bootstrap_skill_dir
|
|
184
|
+
|
|
185
|
+
if platforms_flag:
|
|
186
|
+
targets = _resolve_platforms(platforms_flag)
|
|
187
|
+
else:
|
|
188
|
+
targets = [p for p in detect_platforms() if is_installed(p)]
|
|
189
|
+
|
|
190
|
+
if not targets:
|
|
191
|
+
typer.echo(
|
|
192
|
+
"No installed platforms found. Run 'wbox skills install' first.",
|
|
193
|
+
err=True,
|
|
194
|
+
)
|
|
195
|
+
raise typer.Exit(code=2)
|
|
196
|
+
|
|
197
|
+
for t in targets:
|
|
198
|
+
meta = read_meta(skill_dir(t))
|
|
199
|
+
firm_files = (meta.get("firm") or {}).get("files") or {}
|
|
200
|
+
if firm_files:
|
|
201
|
+
try:
|
|
202
|
+
oldest = min(datetime.fromisoformat(ts) for ts in firm_files.values())
|
|
203
|
+
age_days = (datetime.now(timezone.utc) - oldest).days
|
|
204
|
+
if age_days > staleness_days:
|
|
205
|
+
typer.echo(
|
|
206
|
+
f"! {t.id}: generated files were {age_days} days stale - refreshing now",
|
|
207
|
+
err=True,
|
|
208
|
+
)
|
|
209
|
+
except ValueError:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
bootstrap_skill_dir(skill_dir(t), token=token, generated_only=True)
|
|
213
|
+
typer.echo(f"OK refreshed {t.id}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command(
|
|
217
|
+
"upgrade",
|
|
218
|
+
help="Refresh template files (SKILL.md, references/, firm-examples/, bootstrap.md) "
|
|
219
|
+
"in every installed platform. Preserves firm/ and _meta.json firm section.",
|
|
220
|
+
)
|
|
221
|
+
def upgrade_cmd(
|
|
222
|
+
platforms_flag: list[str] = typer.Option(
|
|
223
|
+
[], "--platform", "-p",
|
|
224
|
+
help="Platform id. Default: every installed platform.",
|
|
225
|
+
),
|
|
226
|
+
) -> None:
|
|
227
|
+
if platforms_flag:
|
|
228
|
+
targets = _resolve_platforms(platforms_flag)
|
|
229
|
+
for t in targets:
|
|
230
|
+
if not is_installed(t):
|
|
231
|
+
typer.echo(f"Error: {t.id!r} is not installed.", err=True)
|
|
232
|
+
raise typer.Exit(code=2)
|
|
233
|
+
else:
|
|
234
|
+
targets = [p for p in detect_platforms() if is_installed(p)]
|
|
235
|
+
|
|
236
|
+
if not targets:
|
|
237
|
+
typer.echo(
|
|
238
|
+
"No installed platforms found. Run 'wbox skills install' first.",
|
|
239
|
+
err=True,
|
|
240
|
+
)
|
|
241
|
+
raise typer.Exit(code=2)
|
|
242
|
+
|
|
243
|
+
current = _pkg_version("wealthbox-cli")
|
|
244
|
+
|
|
245
|
+
with as_file(files("wealthbox_tools").joinpath("skills/wealthbox-crm")) as src:
|
|
246
|
+
for t in targets:
|
|
247
|
+
existing = (read_meta(skill_dir(t)).get("template") or {}).get("cli_version", "-")
|
|
248
|
+
try:
|
|
249
|
+
upgrade_skill(t, Path(src))
|
|
250
|
+
except SkillInstallError as e:
|
|
251
|
+
typer.echo(f"Error upgrading {t.id}: {e}", err=True)
|
|
252
|
+
raise typer.Exit(code=1) from e
|
|
253
|
+
update_template_meta(skill_dir(t), cli_version=current)
|
|
254
|
+
typer.echo(f"OK upgraded {t.id}: {existing} -> {current}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.command("doctor", help="Diagnose install state and token.")
|
|
258
|
+
def doctor_cmd(
|
|
259
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
260
|
+
) -> None:
|
|
261
|
+
from wealthbox_tools.client import WealthboxAPIError
|
|
262
|
+
|
|
263
|
+
from ._util import run_client
|
|
264
|
+
|
|
265
|
+
current = _pkg_version("wealthbox-cli")
|
|
266
|
+
typer.echo("# Skill install state")
|
|
267
|
+
for p in detect_platforms():
|
|
268
|
+
status = "installed" if is_installed(p) else "not installed"
|
|
269
|
+
meta = read_meta(skill_dir(p)) if is_installed(p) else {}
|
|
270
|
+
bootstrapped = "bootstrapped" if meta.get("firm") else "not bootstrapped"
|
|
271
|
+
template_v = (meta.get("template") or {}).get("cli_version", "-")
|
|
272
|
+
upgrade_hint = ""
|
|
273
|
+
if template_v not in ("-", current):
|
|
274
|
+
upgrade_hint = f" [upgrade available: {template_v} -> {current}]"
|
|
275
|
+
typer.echo(
|
|
276
|
+
f" {p.id:<22} {status:<16} {bootstrapped:<18} "
|
|
277
|
+
f"template={template_v:<10} {skill_dir(p)}{upgrade_hint}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
typer.echo("\n# Token")
|
|
281
|
+
try:
|
|
282
|
+
me = run_client(token, lambda c: c.get_me())
|
|
283
|
+
accounts = me.get("accounts") or []
|
|
284
|
+
firm_name = accounts[0].get("name") if accounts else "(no firm)"
|
|
285
|
+
typer.echo(
|
|
286
|
+
f" token ok - authenticated as {me.get('name')} "
|
|
287
|
+
f"(firm: {firm_name}, user id={me.get('id')})"
|
|
288
|
+
)
|
|
289
|
+
except WealthboxAPIError as e:
|
|
290
|
+
typer.echo(f" token failed: {e}")
|
|
291
|
+
except Exception as e: # network, config, etc.
|
|
292
|
+
typer.echo(f" token check failed: {e}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _prompt_sync_source(installed: list[Platform]) -> Platform:
|
|
296
|
+
typer.echo("Installed platforms:")
|
|
297
|
+
for i, p in enumerate(installed, 1):
|
|
298
|
+
typer.echo(f" {i}. {p.id:<22} {p.label}")
|
|
299
|
+
while True:
|
|
300
|
+
raw = typer.prompt("Source platform (id or number)").strip()
|
|
301
|
+
for i, p in enumerate(installed, 1):
|
|
302
|
+
if raw == str(i) or raw == p.id:
|
|
303
|
+
return p
|
|
304
|
+
typer.echo(f" ! unknown selection {raw!r}; try again", err=True)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _prompt_sync_targets(candidates: list[Platform]) -> list[Platform]:
|
|
308
|
+
typer.echo("Available targets:")
|
|
309
|
+
for i, p in enumerate(candidates, 1):
|
|
310
|
+
typer.echo(f" {i}. {p.id:<22} {p.label}")
|
|
311
|
+
typer.echo(" a. all of the above")
|
|
312
|
+
raw = typer.prompt("Targets (comma-separated ids/numbers, or 'a')").strip()
|
|
313
|
+
if raw.lower() in {"a", "all"}:
|
|
314
|
+
return list(candidates)
|
|
315
|
+
selected: list[Platform] = []
|
|
316
|
+
for token in (t.strip() for t in raw.split(",") if t.strip()):
|
|
317
|
+
match: Platform | None = None
|
|
318
|
+
for i, p in enumerate(candidates, 1):
|
|
319
|
+
if token == str(i) or token == p.id:
|
|
320
|
+
match = p
|
|
321
|
+
break
|
|
322
|
+
if match is None:
|
|
323
|
+
raise typer.BadParameter(f"unknown target {token!r}")
|
|
324
|
+
if match not in selected:
|
|
325
|
+
selected.append(match)
|
|
326
|
+
return selected
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@app.command(
|
|
330
|
+
"sync",
|
|
331
|
+
help="Copy firm/ files from one installed platform to one or more others.",
|
|
332
|
+
)
|
|
333
|
+
def sync_cmd(
|
|
334
|
+
source_id: str | None = typer.Option(
|
|
335
|
+
None, "--source", "-s",
|
|
336
|
+
help="Platform id to copy firm/ from. Prompts if omitted.",
|
|
337
|
+
),
|
|
338
|
+
target_ids: list[str] = typer.Option(
|
|
339
|
+
[], "--target", "-t",
|
|
340
|
+
help="Platform id to copy firm/ to. Repeat for multiple. Prompts if omitted.",
|
|
341
|
+
),
|
|
342
|
+
all_targets: bool = typer.Option(
|
|
343
|
+
False, "--all-targets",
|
|
344
|
+
help="Sync to every installed platform except the source.",
|
|
345
|
+
),
|
|
346
|
+
dry_run: bool = typer.Option(
|
|
347
|
+
False, "--dry-run", help="Show planned writes without copying.",
|
|
348
|
+
),
|
|
349
|
+
yes: bool = typer.Option(
|
|
350
|
+
False, "--yes", "-y", help="Skip confirmation prompt.",
|
|
351
|
+
),
|
|
352
|
+
) -> None:
|
|
353
|
+
installed = [p for p in detect_platforms() if is_installed(p)]
|
|
354
|
+
if len(installed) < 2:
|
|
355
|
+
ids = [p.id for p in installed] or ["none"]
|
|
356
|
+
typer.echo(
|
|
357
|
+
f"Need at least two installed platforms to sync. Installed: {ids}",
|
|
358
|
+
err=True,
|
|
359
|
+
)
|
|
360
|
+
raise typer.Exit(code=2)
|
|
361
|
+
|
|
362
|
+
if source_id:
|
|
363
|
+
source = _resolve_platforms([source_id])[0]
|
|
364
|
+
if not is_installed(source):
|
|
365
|
+
typer.echo(f"Error: source {source.id!r} is not installed.", err=True)
|
|
366
|
+
raise typer.Exit(code=2)
|
|
367
|
+
else:
|
|
368
|
+
source = _prompt_sync_source(installed)
|
|
369
|
+
|
|
370
|
+
src_firm = skill_dir(source) / "firm"
|
|
371
|
+
if not src_firm.is_dir():
|
|
372
|
+
typer.echo(
|
|
373
|
+
f"Error: {source.id} has no firm/ directory at {src_firm}. "
|
|
374
|
+
f"Run 'wbox skills bootstrap --platform {source.id}' first.",
|
|
375
|
+
err=True,
|
|
376
|
+
)
|
|
377
|
+
raise typer.Exit(code=2)
|
|
378
|
+
|
|
379
|
+
others = [p for p in installed if p.id != source.id]
|
|
380
|
+
|
|
381
|
+
if all_targets and target_ids:
|
|
382
|
+
typer.echo("Error: --all-targets and --target are mutually exclusive.", err=True)
|
|
383
|
+
raise typer.Exit(code=2)
|
|
384
|
+
|
|
385
|
+
if all_targets:
|
|
386
|
+
targets = others
|
|
387
|
+
elif target_ids:
|
|
388
|
+
targets = _resolve_platforms(target_ids)
|
|
389
|
+
for t in targets:
|
|
390
|
+
if t.id == source.id:
|
|
391
|
+
typer.echo(f"Error: target {t.id!r} is the same as the source.", err=True)
|
|
392
|
+
raise typer.Exit(code=2)
|
|
393
|
+
if not is_installed(t):
|
|
394
|
+
typer.echo(f"Error: target {t.id!r} is not installed.", err=True)
|
|
395
|
+
raise typer.Exit(code=2)
|
|
396
|
+
else:
|
|
397
|
+
targets = _prompt_sync_targets(others)
|
|
398
|
+
|
|
399
|
+
if not targets:
|
|
400
|
+
typer.echo("No targets selected.", err=True)
|
|
401
|
+
raise typer.Exit(code=2)
|
|
402
|
+
|
|
403
|
+
typer.echo(f"Source: {source.id} ({src_firm})")
|
|
404
|
+
typer.echo("Targets:")
|
|
405
|
+
for t in targets:
|
|
406
|
+
typer.echo(f" {t.id} ({skill_dir(t) / 'firm'})")
|
|
407
|
+
|
|
408
|
+
src_files = sorted(p for p in src_firm.rglob("*") if p.is_file())
|
|
409
|
+
|
|
410
|
+
if dry_run:
|
|
411
|
+
for t in targets:
|
|
412
|
+
tgt_firm = skill_dir(t) / "firm"
|
|
413
|
+
for src_file in src_files:
|
|
414
|
+
rel = src_file.relative_to(src_firm)
|
|
415
|
+
dest = tgt_firm / rel
|
|
416
|
+
action = "overwrite" if dest.exists() else "create"
|
|
417
|
+
typer.echo(f" [dry-run][{t.id}] {action} firm/{rel.as_posix()}")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
if not yes and not typer.confirm("Proceed with sync?", default=False):
|
|
421
|
+
typer.echo("Cancelled.")
|
|
422
|
+
raise typer.Exit(code=1)
|
|
423
|
+
|
|
424
|
+
for t in targets:
|
|
425
|
+
tgt_firm = skill_dir(t) / "firm"
|
|
426
|
+
tgt_firm.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
shutil.copytree(src_firm, tgt_firm, dirs_exist_ok=True)
|
|
428
|
+
typer.echo(f"OK synced {len(src_files)} files: {source.id} -> {t.id}")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@app.command("uninstall", help="Remove the wealthbox-crm skill from a platform.")
|
|
432
|
+
def uninstall_cmd(
|
|
433
|
+
platforms_flag: list[str] = typer.Option([], "--platform", "-p"),
|
|
434
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
435
|
+
) -> None:
|
|
436
|
+
if platforms_flag:
|
|
437
|
+
targets = _resolve_platforms(platforms_flag)
|
|
438
|
+
else:
|
|
439
|
+
targets = [p for p in detect_platforms() if is_installed(p)]
|
|
440
|
+
if not targets:
|
|
441
|
+
typer.echo("Nothing to remove. No installed platforms detected.")
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
for t in targets:
|
|
445
|
+
if not is_installed(t):
|
|
446
|
+
typer.echo(f" {t.id}: not installed, skipping")
|
|
447
|
+
continue
|
|
448
|
+
if not yes and not typer.confirm(f"Remove {skill_dir(t)}?", default=False):
|
|
449
|
+
continue
|
|
450
|
+
try:
|
|
451
|
+
uninstall_skill(t)
|
|
452
|
+
typer.echo(f"OK removed {skill_dir(t)}")
|
|
453
|
+
except SkillInstallError as e:
|
|
454
|
+
typer.echo(f"Error: {e}", err=True)
|
|
455
|
+
raise typer.Exit(code=1) from e
|