agent-tool-close-crm-cli 0.1.0__py3-none-any.whl
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.
- agent_tool_close_crm_cli-0.1.0.dist-info/METADATA +210 -0
- agent_tool_close_crm_cli-0.1.0.dist-info/RECORD +22 -0
- agent_tool_close_crm_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_tool_close_crm_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_tool_close_crm_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_tool_close_crm_cli-0.1.0.dist-info/top_level.txt +1 -0
- closecli/__init__.py +3 -0
- closecli/__main__.py +5 -0
- closecli/appctx.py +173 -0
- closecli/cli.py +219 -0
- closecli/client.py +363 -0
- closecli/commands/__init__.py +1 -0
- closecli/commands/_shared.py +39 -0
- closecli/commands/auth.py +220 -0
- closecli/commands/context.py +102 -0
- closecli/commands/guide.py +307 -0
- closecli/commands/install.py +227 -0
- closecli/commands/raw.py +136 -0
- closecli/commands/settings.py +72 -0
- closecli/config.py +167 -0
- closecli/errors.py +32 -0
- closecli/spec.py +45 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-tool-close-crm-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-ready command-line interface for Close CRM — set up leads, keep contacts and notes current, track tasks and opportunities, and search across all of it. Installs the `close` command.
|
|
5
|
+
Author: Zierhut IT, Alexander Zierhut
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/alexander-zierhut/agent-tool-close-crm-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/alexander-zierhut/agent-tool-close-crm-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/alexander-zierhut/agent-tool-close-crm-cli/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/alexander-zierhut/agent-tool-close-crm-cli/releases
|
|
11
|
+
Keywords: close,close-crm,crm,sales,leads,contacts,opportunities,pipeline,sales-pipeline,cli,command-line,close-api,ai-agent,llm,automation
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Customer Service
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Office/Business
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: agent-tool-shared-cli<0.2,>=0.1.3
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: keyring>=25
|
|
27
|
+
Requires-Dist: rich>=13
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
30
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "test"
|
|
31
|
+
Provides-Extra: build
|
|
32
|
+
Requires-Dist: pyinstaller>=6; extra == "build"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# close — the agent-ready Close CRM CLI
|
|
36
|
+
|
|
37
|
+
> Set up leads, keep contacts and notes current, track tasks and opportunities,
|
|
38
|
+
> and search across all of it — a [Close](https://www.close.com) CRM client an
|
|
39
|
+
> LLM can drive with no prior knowledge of it.
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/agent-tool-close-crm-cli/)
|
|
42
|
+
[](https://github.com/alexander-zierhut/agent-tool-close-crm-cli/actions/workflows/ci.yml)
|
|
43
|
+

|
|
44
|
+

|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
**Install:** `pipx install agent-tool-close-crm-cli` — then run `close guide`.
|
|
48
|
+
|
|
49
|
+
`close` wraps the [Close CRM REST API](https://developer.close.com/) in a command
|
|
50
|
+
line built for **AI agents**: JSON on stdout by default, errors as JSON on stderr
|
|
51
|
+
with a meaningful exit code, a built-in `guide` that needs no network, and
|
|
52
|
+
`--dry-run` on every write. Point an agent at a website and let it stand a lead
|
|
53
|
+
up end to end — company, contacts, notes, a first task and an opportunity — then
|
|
54
|
+
keep it current, search across everything, and see what happened last.
|
|
55
|
+
|
|
56
|
+
## The command surface
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
Usage: close [OPTIONS] COMMAND [ARGS]...
|
|
60
|
+
|
|
61
|
+
Agent-friendly CLI for Close CRM: set up leads, keep contacts current, log notes
|
|
62
|
+
and calls, create and reschedule tasks and opportunities, and search across all of
|
|
63
|
+
it.
|
|
64
|
+
|
|
65
|
+
Output is JSON on stdout by default (errors are JSON on stderr with a non-zero
|
|
66
|
+
exit code); add `-o table` or trim with `--fields id,name`.
|
|
67
|
+
|
|
68
|
+
New here / no context? Run `close guide` for the full playbook.
|
|
69
|
+
|
|
70
|
+
╭─ Options ────────────────────────────────────────────────────────────────────────╮
|
|
71
|
+
│ --output -o <json|table|markdown|csv> Output format: json │
|
|
72
|
+
│ (default), table, │
|
|
73
|
+
│ markdown, csv. Also │
|
|
74
|
+
│ --format/-f, anywhere on │
|
|
75
|
+
│ the line. │
|
|
76
|
+
│ --fields,--columns <str> Comma-separated fields to │
|
|
77
|
+
│ return, e.g. │
|
|
78
|
+
│ 'id,display_name'. Works │
|
|
79
|
+
│ anywhere on the line. │
|
|
80
|
+
│ --profile -p <str> Configuration profile. │
|
|
81
|
+
│ One profile per Close API │
|
|
82
|
+
│ key (e.g. a second org). │
|
|
83
|
+
│ --dry-run Mutating commands: print │
|
|
84
|
+
│ the request that would be │
|
|
85
|
+
│ sent and exit. │
|
|
86
|
+
│ --stream Stream results as NDJSON. │
|
|
87
|
+
│ --no-context Ignore the saved session │
|
|
88
|
+
│ context for this command. │
|
|
89
|
+
│ --no-color Disable coloured output. │
|
|
90
|
+
│ --version -V Show version and exit. │
|
|
91
|
+
│ --help Show this message and │
|
|
92
|
+
│ exit. │
|
|
93
|
+
╰──────────────────────────────────────────────────────────────────────────────────╯
|
|
94
|
+
╭─ Commands ───────────────────────────────────────────────────────────────────────╮
|
|
95
|
+
│ guide Built-in operating guide — how to use this CLI without external docs. │
|
|
96
|
+
│ auth Log in with an API key, log out, inspect credentials. │
|
|
97
|
+
│ raw Escape hatch: call any Close API endpoint directly. │
|
|
98
|
+
│ settings View & change CLI settings. │
|
|
99
|
+
│ context Sticky session defaults (e.g. a lead), applied to later commands. │
|
|
100
|
+
│ install Integrate with other tools (e.g. `install claude`). │
|
|
101
|
+
╰──────────────────────────────────────────────────────────────────────────────────╯
|
|
102
|
+
|
|
103
|
+
Learn more: `close guide` · `close guide <topic>` · `close <group> --help`
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
> **Status — v0.1.0.** This first release ships the full chassis, the built-in
|
|
107
|
+
> `guide`, authentication, and the `raw` escape hatch — so an agent can already
|
|
108
|
+
> drive **every** Close endpoint (leads, contacts, notes, calls, tasks,
|
|
109
|
+
> opportunities, statuses, search) through `close raw`, with the family's JSON
|
|
110
|
+
> contract and `--dry-run` safety. Typed, name-resolving domain commands
|
|
111
|
+
> (`close lead`, `close contact`, `close task`, `close opportunity`,
|
|
112
|
+
> `close search`, …) are landing release by release on top of it. Run
|
|
113
|
+
> `close guide` for the current map.
|
|
114
|
+
|
|
115
|
+
## Quickstart
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pipx install agent-tool-close-crm-cli
|
|
119
|
+
close auth login # paste an API key from app.close.com/settings/api/
|
|
120
|
+
# ...or run headless, no config file:
|
|
121
|
+
export CLOSE_API_KEY=api_xxxxxxxx
|
|
122
|
+
|
|
123
|
+
close guide # the built-in manual — no network needed
|
|
124
|
+
close raw get me/ # who am I, which org
|
|
125
|
+
close raw post lead/ --data '{"name": "Acme Inc", "url": "https://acme.com"}'
|
|
126
|
+
close raw get lead/ -P query="Acme" # find it again
|
|
127
|
+
close --dry-run raw put lead/<id>/ --data '{"status_id": "<status>"}' # preview a write
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Why "agent-ready"
|
|
131
|
+
|
|
132
|
+
An agent-ready CLI is one an LLM with **no prior knowledge** of the tool can
|
|
133
|
+
discover, drive correctly, and know whether it worked — without a human in the
|
|
134
|
+
loop. Concretely:
|
|
135
|
+
|
|
136
|
+
- **stdout is JSON, always.** `close … | jq` never breaks; human status goes to stderr.
|
|
137
|
+
- **Errors are a machine channel too.** `{"error": "...", "status": 404}` on stderr,
|
|
138
|
+
and a 422 carries `fieldErrors` — the exact fields Close rejected, as dotted
|
|
139
|
+
paths (`contacts.0.emails.0.email`) — so a rejected write is fixable on the next call.
|
|
140
|
+
- **Exit codes are the error taxonomy** (see below): branch on the class without parsing prose.
|
|
141
|
+
- **It teaches itself.** `close guide` is a zero-config, zero-network, offline
|
|
142
|
+
manual compiled into the binary; `close install claude` registers it as a Claude Code skill.
|
|
143
|
+
- **Preview before you write.** `--dry-run` prints the exact request and sends nothing.
|
|
144
|
+
|
|
145
|
+
## The Close data model
|
|
146
|
+
|
|
147
|
+
| Object | Id prefix | What |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| **Lead** | `lead_` | A company/account — the central object; everything hangs off it. Has a status. |
|
|
150
|
+
| **Contact** | `cont_` | A person at a lead — names, emails, phones. |
|
|
151
|
+
| **Activity** | `acti_` | The lead's timeline: notes, calls, emails, SMS, status changes. "What happened last." |
|
|
152
|
+
| **Task** | `task_` | A to-do with a due date. "Open tasks" = incomplete ones due. |
|
|
153
|
+
| **Opportunity** | `oppo_` | A potential deal: value, confidence, a pipeline status (active/won/lost), a close date. |
|
|
154
|
+
| **Status** | `stat_` | Lead & opportunity statuses — **per-org and custom**; resolve them, never hard-code. |
|
|
155
|
+
|
|
156
|
+
Lists return `{"data": [...], "has_more": ..., "total_results": N}`; page with
|
|
157
|
+
`_skip`/`_limit` (max 100). Updates are **PUT** (partial — send only what changed).
|
|
158
|
+
|
|
159
|
+
## Output contract & exit codes
|
|
160
|
+
|
|
161
|
+
Default output is JSON on stdout; add `-o table` for humans, `-o csv` to export,
|
|
162
|
+
`--fields id,display_name` to trim, `--stream` for NDJSON. Errors are JSON on
|
|
163
|
+
stderr with a non-zero exit code:
|
|
164
|
+
|
|
165
|
+
| Code | Meaning | | Code | Meaning |
|
|
166
|
+
|---|---|---|---|---|
|
|
167
|
+
| 0 | success (incl. a `--dry-run`) | | 5 | not found |
|
|
168
|
+
| 1 | generic error | | 6 | conflict |
|
|
169
|
+
| 3 | config (no API key) | | 7 | validation — see `fieldErrors` |
|
|
170
|
+
| 4 | auth (bad/insufficient key) | | 130 | interrupted |
|
|
171
|
+
|
|
172
|
+
Codes 0–7 mean the same thing across every tool in the family.
|
|
173
|
+
|
|
174
|
+
## Authentication
|
|
175
|
+
|
|
176
|
+
A Close **API key** (starts with `api_`), minted at
|
|
177
|
+
[app.close.com/settings/api/](https://app.close.com/settings/api/). The key *is*
|
|
178
|
+
the org selector — there is no server URL to configure.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
close auth login # prompts, verifies against /me/, stores in your OS keyring
|
|
182
|
+
export CLOSE_API_KEY=api_xxxxxxxx # or headless, no config file
|
|
183
|
+
close auth status # names WHICH key/backend is actually in use
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Precedence is **env > OS keyring > 0600 file**; a second Close org is a second
|
|
187
|
+
profile (`close auth login --profile client-x`).
|
|
188
|
+
|
|
189
|
+
## Part of the family
|
|
190
|
+
|
|
191
|
+
`close` is built on **[agent-tool-shared-cli](https://github.com/alexander-zierhut/agent-tool-shared-cli)**,
|
|
192
|
+
the shared chassis that fixes the output format, the exit-code taxonomy and the
|
|
193
|
+
credential handling — so an agent that has learned one tool in the family already
|
|
194
|
+
knows the next.
|
|
195
|
+
|
|
196
|
+
| Tool | Install | For |
|
|
197
|
+
| --- | --- | --- |
|
|
198
|
+
| [**close**](https://github.com/alexander-zierhut/agent-tool-close-crm-cli) | `pipx install agent-tool-close-crm-cli` | Close CRM — leads, contacts, notes, tasks, opportunities, search |
|
|
199
|
+
| [**drone-cli**](https://github.com/alexander-zierhut/agent-tool-drone-cli) | `pipx install agent-tool-drone-cli` | Drone CI — builds, failing-step logs, promotions |
|
|
200
|
+
| [**grafana**](https://github.com/alexander-zierhut/agent-tool-grafana-cli) | `pipx install agent-tool-grafana-cli` | Grafana — log discovery, health scan, alert routing |
|
|
201
|
+
| [**openproject**](https://github.com/alexander-zierhut/agent-tool-openproject-cli) | `pipx install agent-tool-openproject-cli` | OpenProject — work packages, time, invoicing |
|
|
202
|
+
| [**lexware-office**](https://github.com/alexander-zierhut/agent-tool-lexware-office-cli) | `pipx install agent-tool-lexware-office-cli` | Lexware Office — invoices, contacts, AR-aging |
|
|
203
|
+
|
|
204
|
+
**Keywords:** Close CRM CLI, Close.com API, sales CRM command line, leads,
|
|
205
|
+
contacts, sales opportunities, sales pipeline, tasks, CRM automation, AI agent
|
|
206
|
+
tool, LLM tooling, Claude.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
agent_tool_close_crm_cli-0.1.0.dist-info/licenses/LICENSE,sha256=pnlo9A2Num7hCAJ7Aqhnezlvnc9pdRwpH_VUnjb5UTU,1087
|
|
2
|
+
closecli/__init__.py,sha256=mRTu7AVNy1-8OrsVW7gy0V7CZKEwxgqq2bTBJAKHaaQ,98
|
|
3
|
+
closecli/__main__.py,sha256=H6haqA5Rr9qO83UHmEyNgd-idQ3kMs5qOZi22197-0E,104
|
|
4
|
+
closecli/appctx.py,sha256=OKFhXaIRWbonWVNLfkEUcB-s8VQOwpuZ_UrxmGImbjA,6429
|
|
5
|
+
closecli/cli.py,sha256=YwW170OYUzUZp7Np4wK1EmDbTZvBRvQRP7rbN2m4krk,8352
|
|
6
|
+
closecli/client.py,sha256=BA594ailm4gWGzWDahXN09lvmXyIZFX66aV36oAwL3U,14048
|
|
7
|
+
closecli/config.py,sha256=x5MuRm6vfaaJ5Bq2ifmTyAc6AMN3QKUIAWNUw8Fzi2g,6262
|
|
8
|
+
closecli/errors.py,sha256=XyZY8Pzz3WtQfhX01wr1d8B9-KtbPffnF2uGaQ5Tv9U,1317
|
|
9
|
+
closecli/spec.py,sha256=n3zSHkQZ6HzGqZxmXyBwl4Ai-kZ3zEzOFLsgT76uAkI,1781
|
|
10
|
+
closecli/commands/__init__.py,sha256=3pzHUjsFWgA2_qXed1CujvWc-7tUL2V8yU6cLkfjmLY,42
|
|
11
|
+
closecli/commands/_shared.py,sha256=dr8LR7uDYT5q9U_vCTr8oBn0Ev6JPxZqla-bD2MSsIw,1127
|
|
12
|
+
closecli/commands/auth.py,sha256=ZujuQ0WejfpDQzH3X_3R5gAQESC5PEcfmjvYjWPLn8w,7366
|
|
13
|
+
closecli/commands/context.py,sha256=nirhPedJwZIEuQeR-G51IhscpoLpT5v-S6RkgPWo45I,3439
|
|
14
|
+
closecli/commands/guide.py,sha256=_iHTo90ghkpBThdqrj9k-ZsW_Wxq_H4rVCToHqDX-e0,13856
|
|
15
|
+
closecli/commands/install.py,sha256=7uB3GQMMBUhPM80gTN8crZ6FYb90Z02z3PUidSmlRpw,8693
|
|
16
|
+
closecli/commands/raw.py,sha256=-Ab3kR971RVfdb0YjboKB5Z_G3rgjzGULB44-VE_yGw,5143
|
|
17
|
+
closecli/commands/settings.py,sha256=nGh3PRa8o34JRyzHWEFcnWDi-Q50iOC-9I72lgWBH88,2445
|
|
18
|
+
agent_tool_close_crm_cli-0.1.0.dist-info/METADATA,sha256=u90iq9gTA5Tm0pjws_E09U4v5Tn0nl6ZiCJrBIHlR_0,12529
|
|
19
|
+
agent_tool_close_crm_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
20
|
+
agent_tool_close_crm_cli-0.1.0.dist-info/entry_points.txt,sha256=Edib-gP5Q_bPHWhb2jCdnWqjKL7gM7mDzEdMyFzjXLM,44
|
|
21
|
+
agent_tool_close_crm_cli-0.1.0.dist-info/top_level.txt,sha256=qOsyDwp65EESvmkZQxQrM7k0Ak7gXqBNSoh2DmqfUck,9
|
|
22
|
+
agent_tool_close_crm_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zierhut IT / Alexander Zierhut
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
closecli
|
closecli/__init__.py
ADDED
closecli/__main__.py
ADDED
closecli/appctx.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The per-invocation DI container.
|
|
2
|
+
|
|
3
|
+
Named `appctx`, not `context`, deliberately: `commands/context.py` is the
|
|
4
|
+
user-facing sticky-defaults feature. Keeping the two apart at scaffold time
|
|
5
|
+
avoids the three-colliding-meanings-of-"context" tangle a sibling tool carries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from agentcli import Emitter, OutputFormat
|
|
14
|
+
from agentcli.errors import ConfigError
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .client import Client
|
|
18
|
+
from .config import Config, base_url
|
|
19
|
+
from .spec import SPEC, credentials, token_url
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppContext:
|
|
23
|
+
"""Lazily-built config, credentials and client for one command run."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
output: OutputFormat | None = None,
|
|
29
|
+
color: bool = True,
|
|
30
|
+
interactive: bool = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.config = Config.load()
|
|
33
|
+
self.color = color
|
|
34
|
+
self.interactive = interactive
|
|
35
|
+
self.output = self._resolve_format(output)
|
|
36
|
+
self.emitter = Emitter(
|
|
37
|
+
self.output,
|
|
38
|
+
color=color,
|
|
39
|
+
fields=self._resolve_fields(),
|
|
40
|
+
stream=os.environ.get("CLOSECLI_STREAM") == "1",
|
|
41
|
+
)
|
|
42
|
+
self._client: Client | None = None
|
|
43
|
+
if self.interactive:
|
|
44
|
+
self._maybe_offer_claude_skill()
|
|
45
|
+
|
|
46
|
+
# ---- format ------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _resolve_format(self, explicit: OutputFormat | None) -> OutputFormat:
|
|
49
|
+
"""Precedence: --format/-o > $CLOSECLI_CLI_FORMAT > $CLOSECLI_FORMAT
|
|
50
|
+
> saved default > ask once > json.
|
|
51
|
+
|
|
52
|
+
The explicit rungs do NOT swallow their own errors (a bad --format is a
|
|
53
|
+
usage error, say so); the stored rungs do fall through (a stale saved
|
|
54
|
+
value must not brick every command).
|
|
55
|
+
"""
|
|
56
|
+
if explicit is not None:
|
|
57
|
+
return explicit
|
|
58
|
+
cli_fmt = os.environ.get("CLOSECLI_CLI_FORMAT")
|
|
59
|
+
if cli_fmt:
|
|
60
|
+
return OutputFormat.coerce(cli_fmt) # raises -> caught by main(), exit 1
|
|
61
|
+
env = SPEC.getenv("FORMAT")
|
|
62
|
+
if env:
|
|
63
|
+
try:
|
|
64
|
+
return OutputFormat.coerce(env)
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
saved = self.config.default_format
|
|
68
|
+
if saved:
|
|
69
|
+
try:
|
|
70
|
+
return OutputFormat.coerce(saved)
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
73
|
+
if self.interactive:
|
|
74
|
+
chosen = self._ask_default_format()
|
|
75
|
+
if chosen:
|
|
76
|
+
return chosen
|
|
77
|
+
return OutputFormat.json
|
|
78
|
+
|
|
79
|
+
def _ask_default_format(self) -> OutputFormat | None:
|
|
80
|
+
"""First run, once, on a real TTY. Prompt on stderr; stdout stays clean."""
|
|
81
|
+
try:
|
|
82
|
+
sys.stderr.write(
|
|
83
|
+
"\nFirst run — how should output be formatted by default?\n"
|
|
84
|
+
" json machine-readable (default; best for agents & scripts)\n"
|
|
85
|
+
" table human-readable\n"
|
|
86
|
+
" markdown for pasting into docs/PRs\n"
|
|
87
|
+
"Choice [json]: "
|
|
88
|
+
)
|
|
89
|
+
sys.stderr.flush()
|
|
90
|
+
ans = (sys.stdin.readline() or "").strip().lower()
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
fmt = OutputFormat.json
|
|
94
|
+
if ans:
|
|
95
|
+
try:
|
|
96
|
+
fmt = OutputFormat.coerce(ans)
|
|
97
|
+
except ValueError:
|
|
98
|
+
fmt = OutputFormat.json
|
|
99
|
+
try:
|
|
100
|
+
self.config.default_format = fmt.value
|
|
101
|
+
self.config.save()
|
|
102
|
+
sys.stderr.write("Saved. Change it any time: `close settings set-format <fmt>`\n\n")
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # a first-run nicety must never fail a real command
|
|
105
|
+
return fmt
|
|
106
|
+
|
|
107
|
+
# ---- first-run Claude offer --------------------------------------
|
|
108
|
+
|
|
109
|
+
def _maybe_offer_claude_skill(self) -> None:
|
|
110
|
+
"""Offer to register the Claude Code skill. Once, ever, on a real TTY.
|
|
111
|
+
|
|
112
|
+
`claude_prompted` is set and SAVED *before* the prompt, so a decline, a
|
|
113
|
+
Ctrl-C or a failed install all count as "asked" — a nagging CLI gets
|
|
114
|
+
`2>/dev/null`'d and then its errors are invisible too. Everything is
|
|
115
|
+
swallowed: a nicety must never fail a real command, and this runs before
|
|
116
|
+
every one.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
from .commands import install
|
|
120
|
+
|
|
121
|
+
if self.config.claude_prompted:
|
|
122
|
+
return
|
|
123
|
+
if not install.claude_available() or install.skill_installed():
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
self.config.claude_prompted = True
|
|
127
|
+
self.config.save()
|
|
128
|
+
|
|
129
|
+
sys.stderr.write(
|
|
130
|
+
"\nClaude Code is installed here. Register `close` as a skill, so Claude\n"
|
|
131
|
+
"uses it automatically when you mention Close, a lead, a CRM contact or a\n"
|
|
132
|
+
"sales opportunity?\n"
|
|
133
|
+
" writes ~/.claude/skills/close/SKILL.md — undo with "
|
|
134
|
+
"`close install claude --uninstall`\n"
|
|
135
|
+
"Install it? [y/N]: "
|
|
136
|
+
)
|
|
137
|
+
sys.stderr.flush()
|
|
138
|
+
ans = (sys.stdin.readline() or "").strip().lower()
|
|
139
|
+
if ans not in ("y", "yes"):
|
|
140
|
+
sys.stderr.write(
|
|
141
|
+
"Skipped — you will not be asked again. "
|
|
142
|
+
"Change your mind any time: `close install claude`\n\n"
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
path = install.write_skill()
|
|
146
|
+
sys.stderr.write(f"Installed {path}\nStart a new Claude session to pick it up.\n\n")
|
|
147
|
+
except Exception:
|
|
148
|
+
pass # a first-run nicety must never fail a real command
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _resolve_fields() -> list[str] | None:
|
|
152
|
+
raw = os.environ.get("CLOSECLI_CLI_FIELDS")
|
|
153
|
+
if not raw:
|
|
154
|
+
return None
|
|
155
|
+
return [f.strip() for f in raw.split(",") if f.strip()]
|
|
156
|
+
|
|
157
|
+
# ---- client ------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def client(self) -> Client:
|
|
160
|
+
if self._client is None:
|
|
161
|
+
key = credentials.get_token(self.config.active_profile_name())
|
|
162
|
+
if not key:
|
|
163
|
+
raise ConfigError(
|
|
164
|
+
"no API key. Run `close auth login`, or set CLOSE_API_KEY "
|
|
165
|
+
f"(create a key at {token_url()})."
|
|
166
|
+
)
|
|
167
|
+
self._client = Client(
|
|
168
|
+
base_url(),
|
|
169
|
+
key,
|
|
170
|
+
dry_run=os.environ.get("CLOSECLI_DRY_RUN") == "1",
|
|
171
|
+
user_agent=f"agent-tool-close-crm-cli/{__version__}",
|
|
172
|
+
)
|
|
173
|
+
return self._client
|
closecli/cli.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Top-level Typer application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from agentcli import OutputFormat, print_error
|
|
10
|
+
from agentcli.errors import DryRun, OpError
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .appctx import AppContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _version_callback(value: bool) -> None:
|
|
17
|
+
if value:
|
|
18
|
+
typer.echo(__version__)
|
|
19
|
+
raise typer.Exit()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="close",
|
|
24
|
+
help=(
|
|
25
|
+
"Agent-friendly CLI for Close CRM: set up leads, keep contacts current, log "
|
|
26
|
+
"notes and calls, create and reschedule tasks and opportunities, and search "
|
|
27
|
+
"across all of it.\n\n"
|
|
28
|
+
"Output is JSON on stdout by default (errors are JSON on stderr with a "
|
|
29
|
+
"non-zero exit code); add `-o table` or trim with `--fields id,name`.\n\n"
|
|
30
|
+
"New here / no context? Run `close guide` for the full playbook."
|
|
31
|
+
),
|
|
32
|
+
epilog="Learn more: `close guide` · `close guide <topic>` · `close <group> --help`",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
add_completion=False,
|
|
35
|
+
# A security decision, not cosmetics: locals hold the API key, and a pretty
|
|
36
|
+
# traceback would print it to the terminal (and into CI logs).
|
|
37
|
+
pretty_exceptions_show_locals=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Remembered so the central handler can render errors in the format the user
|
|
41
|
+
# asked for, even when the failure predates the command.
|
|
42
|
+
_ERROR_FORMAT = OutputFormat.json
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def _root(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
output: OutputFormat = typer.Option(
|
|
49
|
+
None, "--output", "-o",
|
|
50
|
+
help="Output format: json (default), table, markdown, csv. Also --format/-f, anywhere on the line.",
|
|
51
|
+
),
|
|
52
|
+
fields: str = typer.Option(
|
|
53
|
+
None, "--fields", "--columns",
|
|
54
|
+
help="Comma-separated fields to return, e.g. 'id,display_name'. Works anywhere on the line.",
|
|
55
|
+
),
|
|
56
|
+
profile: str = typer.Option(
|
|
57
|
+
None, "--profile", "-p",
|
|
58
|
+
help="Configuration profile. One profile per Close API key (e.g. a second org).",
|
|
59
|
+
),
|
|
60
|
+
dry_run: bool = typer.Option(
|
|
61
|
+
False, "--dry-run", help="Mutating commands: print the request that would be sent and exit."
|
|
62
|
+
),
|
|
63
|
+
stream: bool = typer.Option(False, "--stream", help="Stream results as NDJSON."),
|
|
64
|
+
no_context: bool = typer.Option(
|
|
65
|
+
False, "--no-context", help="Ignore the saved session context for this command."
|
|
66
|
+
),
|
|
67
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
|
|
68
|
+
version: bool = typer.Option(
|
|
69
|
+
None, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
70
|
+
),
|
|
71
|
+
) -> None:
|
|
72
|
+
if profile:
|
|
73
|
+
os.environ["CLOSECLI_PROFILE"] = profile
|
|
74
|
+
|
|
75
|
+
# First-run prompt gate — every clause load-bearing: meta subcommands must
|
|
76
|
+
# never prompt; stdin AND stdout must be TTYs (stdin alone fires the prompt
|
|
77
|
+
# into `| jq` and hangs it); CI=true is the universal signal.
|
|
78
|
+
meta = ctx.invoked_subcommand in ("settings", "guide", "install", "context", "auth")
|
|
79
|
+
interactive = (
|
|
80
|
+
not meta
|
|
81
|
+
and sys.stdin.isatty()
|
|
82
|
+
and sys.stdout.isatty()
|
|
83
|
+
and os.environ.get("CI") != "true"
|
|
84
|
+
)
|
|
85
|
+
ctx.obj = AppContext(output=output, color=not no_color, interactive=interactive)
|
|
86
|
+
global _ERROR_FORMAT
|
|
87
|
+
_ERROR_FORMAT = ctx.obj.emitter.fmt
|
|
88
|
+
|
|
89
|
+
if os.environ.get("CLOSECLI_NO_CONTEXT") != "1":
|
|
90
|
+
active = ctx.obj.config.context
|
|
91
|
+
if active:
|
|
92
|
+
# Meta groups are excluded: they are what you run to diagnose things,
|
|
93
|
+
# and a sticky default silently rescoping a diagnostic is the last
|
|
94
|
+
# thing you want when lost.
|
|
95
|
+
dm = _context_default_map(
|
|
96
|
+
ctx.command,
|
|
97
|
+
active,
|
|
98
|
+
skip={"context", "settings", "guide", "install", "auth"},
|
|
99
|
+
)
|
|
100
|
+
ctx.default_map = {**(ctx.default_map or {}), **dm}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _context_default_map(group, values: dict, skip: set) -> dict:
|
|
104
|
+
"""Build a Click default_map from the active context: for every command with
|
|
105
|
+
an OPTION whose name is a context key, use that value as the default.
|
|
106
|
+
|
|
107
|
+
Options only — Click's default_map also satisfies required positionals, so
|
|
108
|
+
including arguments would let sticky state silently fill an id a user forgot.
|
|
109
|
+
"""
|
|
110
|
+
dmap: dict = {}
|
|
111
|
+
for name, cmd in getattr(group, "commands", {}).items():
|
|
112
|
+
if name in skip:
|
|
113
|
+
continue
|
|
114
|
+
if hasattr(cmd, "commands"):
|
|
115
|
+
sub = _context_default_map(cmd, values, skip)
|
|
116
|
+
if sub:
|
|
117
|
+
dmap[name] = sub
|
|
118
|
+
else:
|
|
119
|
+
opt_names = {p.name for p in cmd.params if getattr(p, "param_type_name", "") == "option"}
|
|
120
|
+
matched = {k: v for k, v in values.items() if k in opt_names}
|
|
121
|
+
if matched:
|
|
122
|
+
dmap[name] = matched
|
|
123
|
+
return dmap
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# The reserved namespace. Anything here is stripped from argv before Click parses
|
|
127
|
+
# it, so NO command may declare an option with these names -- it could never
|
|
128
|
+
# receive one. tests/test_globals_unit.py enforces that across the whole tree.
|
|
129
|
+
# (Which is why every file destination in this tool is spelled `--out`.)
|
|
130
|
+
_FORMAT_FLAGS = ("--format", "-f", "--output", "-o")
|
|
131
|
+
_FIELDS_FLAGS = ("--fields", "--columns")
|
|
132
|
+
_BOOL_FLAGS = ("--dry-run", "--stream", "--no-context")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _pop_globals(argv: list[str]) -> tuple[str | None, str | None, set[str], list[str]]:
|
|
136
|
+
"""Extract global flags from anywhere on the line, so they work after a
|
|
137
|
+
subcommand too. Honours ``--`` to stop parsing."""
|
|
138
|
+
out: list[str] = []
|
|
139
|
+
fmt: str | None = None
|
|
140
|
+
fields: str | None = None
|
|
141
|
+
bools: set[str] = set()
|
|
142
|
+
i, stop = 0, False
|
|
143
|
+
|
|
144
|
+
def take_value(idx: int) -> tuple[str | None, int]:
|
|
145
|
+
return (argv[idx + 1], idx + 1) if idx + 1 < len(argv) else (None, idx)
|
|
146
|
+
|
|
147
|
+
while i < len(argv):
|
|
148
|
+
a = argv[i]
|
|
149
|
+
if not stop and a == "--":
|
|
150
|
+
stop = True
|
|
151
|
+
out.append(a)
|
|
152
|
+
elif not stop and a in _FORMAT_FLAGS:
|
|
153
|
+
fmt, i = take_value(i)
|
|
154
|
+
elif not stop and a in _FIELDS_FLAGS:
|
|
155
|
+
fields, i = take_value(i)
|
|
156
|
+
elif not stop and a in _BOOL_FLAGS:
|
|
157
|
+
bools.add(a.lstrip("-"))
|
|
158
|
+
elif not stop and any(a.startswith(p + "=") for p in _FORMAT_FLAGS):
|
|
159
|
+
fmt = a.split("=", 1)[1]
|
|
160
|
+
elif not stop and any(a.startswith(p + "=") for p in _FIELDS_FLAGS):
|
|
161
|
+
fields = a.split("=", 1)[1]
|
|
162
|
+
else:
|
|
163
|
+
out.append(a)
|
|
164
|
+
i += 1
|
|
165
|
+
return fmt, fields, bools, out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---- command groups (imported here to avoid circular imports) ----
|
|
169
|
+
from .commands import ( # noqa: E402
|
|
170
|
+
auth,
|
|
171
|
+
context as context_cmd,
|
|
172
|
+
guide,
|
|
173
|
+
install,
|
|
174
|
+
raw,
|
|
175
|
+
settings,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
app.command("guide", help="Built-in operating guide — how to use this CLI without external docs.")(guide.guide)
|
|
179
|
+
|
|
180
|
+
app.add_typer(auth.app, name="auth", help="Log in with an API key, log out, inspect credentials.")
|
|
181
|
+
app.add_typer(raw.app, name="raw", help="Escape hatch: call any Close API endpoint directly.")
|
|
182
|
+
app.add_typer(settings.app, name="settings", help="View & change CLI settings.")
|
|
183
|
+
app.add_typer(context_cmd.app, name="context", help="Sticky session defaults (e.g. a lead), applied to later commands.")
|
|
184
|
+
app.add_typer(install.app, name="install", help="Integrate with other tools (e.g. `install claude`).")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main() -> None:
|
|
188
|
+
import json as _json
|
|
189
|
+
|
|
190
|
+
fmt, fields, bools, argv = _pop_globals(sys.argv[1:])
|
|
191
|
+
if fmt is not None:
|
|
192
|
+
os.environ["CLOSECLI_CLI_FORMAT"] = fmt
|
|
193
|
+
if fields is not None:
|
|
194
|
+
os.environ["CLOSECLI_CLI_FIELDS"] = fields
|
|
195
|
+
if "dry-run" in bools:
|
|
196
|
+
os.environ["CLOSECLI_DRY_RUN"] = "1"
|
|
197
|
+
if "stream" in bools:
|
|
198
|
+
os.environ["CLOSECLI_STREAM"] = "1"
|
|
199
|
+
if "no-context" in bools:
|
|
200
|
+
os.environ["CLOSECLI_NO_CONTEXT"] = "1"
|
|
201
|
+
try:
|
|
202
|
+
app(args=argv)
|
|
203
|
+
except DryRun as dr:
|
|
204
|
+
sys.stdout.write(_json.dumps({"dryRun": True, "request": dr.request}, indent=2, default=str) + "\n")
|
|
205
|
+
sys.exit(0)
|
|
206
|
+
except OpError as exc:
|
|
207
|
+
print_error(exc, _ERROR_FORMAT)
|
|
208
|
+
sys.exit(exc.exit_code)
|
|
209
|
+
except ValueError as exc:
|
|
210
|
+
# e.g. OutputFormat.coerce on a bad --format. A usage error, not a crash.
|
|
211
|
+
print_error(OpError(str(exc)), _ERROR_FORMAT)
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
214
|
+
print_error(OpError("interrupted"), _ERROR_FORMAT)
|
|
215
|
+
sys.exit(130)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__": # pragma: no cover
|
|
219
|
+
main()
|