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.
Files changed (87) hide show
  1. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/PKG-INFO +7 -1
  2. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/README.md +6 -0
  3. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/pyproject.toml +1 -1
  4. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_skill_bootstrap.py +45 -15
  5. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_skill_platforms.py +30 -4
  6. wealthbox_cli-1.1.2/src/wealthbox_tools/cli/skills.py +455 -0
  7. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/SKILL.md +59 -4
  8. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/bootstrap.md +10 -15
  9. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/notes.md +1 -1
  10. wealthbox_cli-1.1.0/src/wealthbox_tools/cli/skills.py +0 -270
  11. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.env.example +0 -0
  12. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.github/workflows/ci.yml +0 -0
  13. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.github/workflows/docs.yml +0 -0
  14. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/.gitignore +0 -0
  15. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/CONTRIBUTING.md +0 -0
  16. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/LICENSE +0 -0
  17. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/SECURITY.md +0 -0
  18. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/changelog.md +0 -0
  19. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/cli-reference.md +0 -0
  20. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/getting-started.md +0 -0
  21. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/index.md +0 -0
  22. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/overrides/main.html +0 -0
  23. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/docs/promotion.md +0 -0
  24. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/mkdocs.yml +0 -0
  25. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/__init__.py +0 -0
  26. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/__init__.py +0 -0
  27. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_config.py +0 -0
  28. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/_util.py +0 -0
  29. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/activity.py +0 -0
  30. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/categories.py +0 -0
  31. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/config.py +0 -0
  32. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/contacts.py +0 -0
  33. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/events.py +0 -0
  34. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/households.py +0 -0
  35. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/main.py +0 -0
  36. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/me.py +0 -0
  37. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/notes.py +0 -0
  38. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/opportunities.py +0 -0
  39. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/projects.py +0 -0
  40. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/tasks.py +0 -0
  41. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/users.py +0 -0
  42. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/cli/workflows.py +0 -0
  43. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/__init__.py +0 -0
  44. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/activity.py +0 -0
  45. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/base.py +0 -0
  46. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/categories.py +0 -0
  47. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/comments.py +0 -0
  48. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/contacts.py +0 -0
  49. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/events.py +0 -0
  50. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/households.py +0 -0
  51. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/me.py +0 -0
  52. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/notes.py +0 -0
  53. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/opportunities.py +0 -0
  54. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/projects.py +0 -0
  55. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/tasks.py +0 -0
  56. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/users.py +0 -0
  57. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/client/workflows.py +0 -0
  58. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/__init__.py +0 -0
  59. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/activity.py +0 -0
  60. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/comments.py +0 -0
  61. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/common.py +0 -0
  62. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/contacts.py +0 -0
  63. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/custom_fields.py +0 -0
  64. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/enums.py +0 -0
  65. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/events.py +0 -0
  66. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/households.py +0 -0
  67. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/notes.py +0 -0
  68. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/opportunities.py +0 -0
  69. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/projects.py +0 -0
  70. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/tasks.py +0 -0
  71. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/models/workflows.py +0 -0
  72. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/py.typed +0 -0
  73. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/contacts.md +0 -0
  74. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/events.md +0 -0
  75. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/notes.md +0 -0
  76. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/opportunities.md +0 -0
  77. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/projects.md +0 -0
  78. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/tasks.md +0 -0
  79. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/firm-examples/workflows.md +0 -0
  80. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/contacts.md +0 -0
  81. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/events.md +0 -0
  82. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/households.md +0 -0
  83. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/lookups.md +0 -0
  84. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/opportunities.md +0 -0
  85. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/projects.md +0 -0
  86. {wealthbox_cli-1.1.0 → wealthbox_cli-1.1.2}/src/wealthbox_tools/skills/wealthbox-crm/references/tasks.md +0 -0
  87. {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.0
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wealthbox-cli"
7
- version = "1.1.0"
7
+ version = "1.1.2"
8
8
  description = "CLI and client library for the Wealthbox CRM API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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 write_meta_json(
143
- firm_dir: Path,
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
- generated_files: list[str],
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
- "firm": firm_identity,
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
- (firm_dir / "_meta.json").write_text(
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
- write_meta_json(
234
- firm_dir,
235
- generated_files=generated,
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
- if platform.skill_filename != "SKILL.md":
73
- skill_md = dest / "SKILL.md"
74
- if skill_md.exists():
75
- skill_md.rename(dest / platform.skill_filename)
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