nexla-cli 0.2.0__tar.gz → 0.2.1__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.
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/.github/workflows/publish-pypi.yml +7 -4
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/.github/workflows/release-binaries.yml +21 -3
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/PKG-INFO +13 -40
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/README.md +12 -39
- nexla_cli-0.2.1/RELEASING.md +43 -0
- nexla_cli-0.2.1/npm/README.md +8 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/npm/package.json +1 -1
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/pyproject.toml +1 -1
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/AGENTS.md +26 -3
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/SKILL.md +5 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/__init__.py +2 -0
- nexla_cli-0.2.1/src/nexla_cli/poll.py +71 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/nexsets.py +15 -8
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/sinks.py +15 -8
- nexla_cli-0.2.1/src/nexla_cli/resources/skill.py +56 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/sources.py +15 -8
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/triage.py +14 -7
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_contract.py +4 -2
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_nexsets.py +17 -0
- nexla_cli-0.2.1/tests/test_poll.py +42 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_sinks.py +18 -0
- nexla_cli-0.2.1/tests/test_skill.py +29 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_sources.py +18 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/uv.lock +1 -1
- nexla_cli-0.2.0/npm/README.md +0 -27
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/.gitignore +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/LICENSE +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/npm/bin/nexla.js +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/npm/scripts/install.js +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/scripts/pyinstaller_entry.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/client.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/dryrun.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/errors.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/login.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/mcp_client.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/openapi_client.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/output.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/py.typed +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/__init__.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/code_containers.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/connectors.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/context.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/credentials.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/flows.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/mcp_servers.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/metrics.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/notifications.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/orgs.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/probe.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/tools.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/toolsets.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/transforms.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/users.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/sanitize.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/schema.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/validate.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/__init__.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/conftest.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_app.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_client.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_connectors.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_context.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_credentials.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_dryrun.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_errors.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_flows.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_login.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_mcp_servers.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_openapi_client.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_orgs.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_output.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_packaging.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_probe.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_sanitize.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_schema.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_stub_resources.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_tools.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_toolsets.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_transforms.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_triage.py +0 -0
- {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_validate.py +0 -0
|
@@ -20,10 +20,13 @@ jobs:
|
|
|
20
20
|
- run: python -m build
|
|
21
21
|
|
|
22
22
|
# Preferred: PyPI Trusted Publishing. Requires a one-time setup on
|
|
23
|
-
# pypi.org
|
|
24
|
-
#
|
|
25
|
-
# publisher
|
|
26
|
-
#
|
|
23
|
+
# pypi.org, done by the repo owner (not automatable from a workflow
|
|
24
|
+
# run): project "nexla-cli" -> Settings -> Publishing -> add a
|
|
25
|
+
# trusted publisher with Owner = this repo's GitHub org/user, repo =
|
|
26
|
+
# this repo's name, workflow filename = "publish-pypi.yml",
|
|
27
|
+
# environment name = "pypi" (matches the `environment:` key above).
|
|
28
|
+
# No secret/token needed once that's configured -- id-token: write
|
|
29
|
+
# plus the matching environment satisfy PyPI's OIDC handshake.
|
|
27
30
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
28
31
|
|
|
29
32
|
# Optional fallback if Trusted Publishing isn't set up yet: uncomment
|
|
@@ -23,9 +23,25 @@ jobs:
|
|
|
23
23
|
with:
|
|
24
24
|
python-version: "3.12"
|
|
25
25
|
- run: pip install . pyinstaller
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
- name: Build (bundling SKILL.md/AGENTS.md so `nexla skill install` works from the binary too)
|
|
27
|
+
shell: python
|
|
28
|
+
run: |
|
|
29
|
+
import os
|
|
30
|
+
import PyInstaller.__main__
|
|
31
|
+
|
|
32
|
+
def add_data(src: str) -> str:
|
|
33
|
+
# PyInstaller's --add-data separator is OS-specific (":" on
|
|
34
|
+
# posix, ";" on Windows) -- os.pathsep gets this right without
|
|
35
|
+
# needing per-OS shell branches in this matrix job.
|
|
36
|
+
return f"src/nexla_cli/{src}{os.pathsep}nexla_cli"
|
|
37
|
+
|
|
38
|
+
PyInstaller.__main__.run([
|
|
39
|
+
"--onefile",
|
|
40
|
+
"--name", "${{ matrix.asset }}",
|
|
41
|
+
"--add-data", add_data("SKILL.md"),
|
|
42
|
+
"--add-data", add_data("AGENTS.md"),
|
|
43
|
+
"scripts/pyinstaller_entry.py",
|
|
44
|
+
])
|
|
29
45
|
- uses: actions/upload-artifact@v4
|
|
30
46
|
with:
|
|
31
47
|
name: ${{ matrix.asset }}
|
|
@@ -34,6 +50,8 @@ jobs:
|
|
|
34
50
|
release:
|
|
35
51
|
needs: build
|
|
36
52
|
runs-on: ubuntu-latest
|
|
53
|
+
permissions:
|
|
54
|
+
contents: write
|
|
37
55
|
steps:
|
|
38
56
|
- uses: actions/download-artifact@v4
|
|
39
57
|
with:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nexla-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Command-line client for the Nexla agent API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/abhishekkumar705/nexla-agent-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/abhishekkumar705/nexla-agent-cli
|
|
@@ -52,10 +52,8 @@ npm install -g nexla-cli
|
|
|
52
52
|
npx nexla-cli sources list
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
This installs a prebuilt native binary
|
|
56
|
-
|
|
57
|
-
Python interpreter needed on the target machine. See `npm/README.md` for how
|
|
58
|
-
releases are cut.
|
|
55
|
+
This installs a prebuilt native binary behind a thin `npm/` wrapper — no
|
|
56
|
+
Python interpreter needed on the target machine.
|
|
59
57
|
|
|
60
58
|
### From PyPI
|
|
61
59
|
|
|
@@ -66,30 +64,6 @@ uvx nexla-cli sources list
|
|
|
66
64
|
pipx install nexla-cli
|
|
67
65
|
```
|
|
68
66
|
|
|
69
|
-
Published by `.github/workflows/publish-pypi.yml` — see "Publishing to
|
|
70
|
-
PyPI" below.
|
|
71
|
-
|
|
72
|
-
## Publishing to PyPI
|
|
73
|
-
|
|
74
|
-
Tagging a release (`git tag vX.Y.Z && git push --tags` — same tag used to
|
|
75
|
-
trigger `.github/workflows/release-binaries.yml`, see `npm/README.md` for
|
|
76
|
-
the full release checklist) also triggers `publish-pypi.yml`, which builds
|
|
77
|
-
the sdist+wheel and publishes them to PyPI using
|
|
78
|
-
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) —
|
|
79
|
-
no long-lived API token stored in this repo.
|
|
80
|
-
|
|
81
|
-
One-time setup required on pypi.org before the first release (repo owner
|
|
82
|
-
only, not automatable from here):
|
|
83
|
-
|
|
84
|
-
1. Create the `nexla-cli` project on PyPI (or reserve the name).
|
|
85
|
-
2. Project → Settings → Publishing → add a trusted publisher:
|
|
86
|
-
- Owner: `abhishekkumar705`, repo: `nexla-agent-cli`
|
|
87
|
-
- Workflow filename: `publish-pypi.yml`
|
|
88
|
-
- Environment name: `pypi`
|
|
89
|
-
3. Nothing else to configure — no secrets to add. The workflow's
|
|
90
|
-
`id-token: write` permission plus the `pypi` GitHub Environment satisfy
|
|
91
|
-
PyPI's OIDC handshake automatically.
|
|
92
|
-
|
|
93
67
|
## Quick start
|
|
94
68
|
|
|
95
69
|
```bash
|
|
@@ -108,21 +82,15 @@ above); alternatively, set the following environment variables directly:
|
|
|
108
82
|
|
|
109
83
|
The package ships a [Claude Code skill](https://docs.claude.com/en/docs/claude-code/skills) (`AGENTS.md` + `SKILL.md`, installed alongside the `nexla_cli` package) encoding invariants an agent can't infer from `--help` alone — output modes, `--dry-run`, `--json`/`--params` precedence, exit codes, and treating API responses as untrusted data.
|
|
110
84
|
|
|
111
|
-
Claude Code only discovers skills placed at `~/.claude/skills/<name>/SKILL.md` (global) or `.claude/skills/<name>/SKILL.md` (project-local) — a file merely present inside an installed
|
|
85
|
+
Claude Code only discovers skills placed at `~/.claude/skills/<name>/SKILL.md` (global) or `.claude/skills/<name>/SKILL.md` (project-local) — a file merely present inside an installed package isn't picked up automatically. Install it once, after installing the CLI (works the same way regardless of whether you installed via `uv tool`, `pipx`, `pip`, or the npm-wrapped binary):
|
|
112
86
|
|
|
113
87
|
```bash
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
ln -sf "$(uv tool dir)/nexla-cli/lib/python3."*"/site-packages/nexla_cli/SKILL.md" \
|
|
118
|
-
~/.claude/skills/nexla-cli/SKILL.md
|
|
119
|
-
|
|
120
|
-
# if installed with `pipx install`:
|
|
121
|
-
ln -sf "$HOME/.local/pipx/venvs/nexla-cli/lib/python3."*"/site-packages/nexla_cli/SKILL.md" \
|
|
122
|
-
~/.claude/skills/nexla-cli/SKILL.md
|
|
88
|
+
nexla skill install
|
|
89
|
+
# or, for a project-local install instead of the global default:
|
|
90
|
+
nexla skill install --target .claude/skills/nexla-cli
|
|
123
91
|
```
|
|
124
92
|
|
|
125
|
-
|
|
93
|
+
Re-run this after upgrading `nexla-cli` to pick up any skill content changes. Restart Claude Code (or start a new session) afterward for it to be picked up.
|
|
126
94
|
|
|
127
95
|
## Global flags
|
|
128
96
|
|
|
@@ -175,6 +143,7 @@ nexla <resource> --help
|
|
|
175
143
|
| `toolsets` | `list`, `get`, `create`, `update`, `delete`, `add-nexsets` |
|
|
176
144
|
| `tools` | `list`, `get`, `set-runtime-config`, `clear-runtime-config`, `delete` |
|
|
177
145
|
| `mcp-servers` | `list`, `attach`, `sync`, `detach` (nested under a toolset) |
|
|
146
|
+
| `triage` | `errors`, `status`, `run`, `metrics`, `resource-status`, `org-metrics`, `user-metrics`, `notifications`, `search`, `logs`, `quarantine` — flow/log triage via the Nexla monitoring MCP server, not the main API |
|
|
178
147
|
| `context` | `get` |
|
|
179
148
|
| `orgs` | `list`, `get` |
|
|
180
149
|
| `code-containers` | `list` |
|
|
@@ -194,3 +163,7 @@ nexla <resource> --help
|
|
|
194
163
|
| 4 | 401/403 from the API |
|
|
195
164
|
| 5 | 404 |
|
|
196
165
|
| 6 | 5xx from the API |
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -23,10 +23,8 @@ npm install -g nexla-cli
|
|
|
23
23
|
npx nexla-cli sources list
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
This installs a prebuilt native binary
|
|
27
|
-
|
|
28
|
-
Python interpreter needed on the target machine. See `npm/README.md` for how
|
|
29
|
-
releases are cut.
|
|
26
|
+
This installs a prebuilt native binary behind a thin `npm/` wrapper — no
|
|
27
|
+
Python interpreter needed on the target machine.
|
|
30
28
|
|
|
31
29
|
### From PyPI
|
|
32
30
|
|
|
@@ -37,30 +35,6 @@ uvx nexla-cli sources list
|
|
|
37
35
|
pipx install nexla-cli
|
|
38
36
|
```
|
|
39
37
|
|
|
40
|
-
Published by `.github/workflows/publish-pypi.yml` — see "Publishing to
|
|
41
|
-
PyPI" below.
|
|
42
|
-
|
|
43
|
-
## Publishing to PyPI
|
|
44
|
-
|
|
45
|
-
Tagging a release (`git tag vX.Y.Z && git push --tags` — same tag used to
|
|
46
|
-
trigger `.github/workflows/release-binaries.yml`, see `npm/README.md` for
|
|
47
|
-
the full release checklist) also triggers `publish-pypi.yml`, which builds
|
|
48
|
-
the sdist+wheel and publishes them to PyPI using
|
|
49
|
-
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) —
|
|
50
|
-
no long-lived API token stored in this repo.
|
|
51
|
-
|
|
52
|
-
One-time setup required on pypi.org before the first release (repo owner
|
|
53
|
-
only, not automatable from here):
|
|
54
|
-
|
|
55
|
-
1. Create the `nexla-cli` project on PyPI (or reserve the name).
|
|
56
|
-
2. Project → Settings → Publishing → add a trusted publisher:
|
|
57
|
-
- Owner: `abhishekkumar705`, repo: `nexla-agent-cli`
|
|
58
|
-
- Workflow filename: `publish-pypi.yml`
|
|
59
|
-
- Environment name: `pypi`
|
|
60
|
-
3. Nothing else to configure — no secrets to add. The workflow's
|
|
61
|
-
`id-token: write` permission plus the `pypi` GitHub Environment satisfy
|
|
62
|
-
PyPI's OIDC handshake automatically.
|
|
63
|
-
|
|
64
38
|
## Quick start
|
|
65
39
|
|
|
66
40
|
```bash
|
|
@@ -79,21 +53,15 @@ above); alternatively, set the following environment variables directly:
|
|
|
79
53
|
|
|
80
54
|
The package ships a [Claude Code skill](https://docs.claude.com/en/docs/claude-code/skills) (`AGENTS.md` + `SKILL.md`, installed alongside the `nexla_cli` package) encoding invariants an agent can't infer from `--help` alone — output modes, `--dry-run`, `--json`/`--params` precedence, exit codes, and treating API responses as untrusted data.
|
|
81
55
|
|
|
82
|
-
Claude Code only discovers skills placed at `~/.claude/skills/<name>/SKILL.md` (global) or `.claude/skills/<name>/SKILL.md` (project-local) — a file merely present inside an installed
|
|
56
|
+
Claude Code only discovers skills placed at `~/.claude/skills/<name>/SKILL.md` (global) or `.claude/skills/<name>/SKILL.md` (project-local) — a file merely present inside an installed package isn't picked up automatically. Install it once, after installing the CLI (works the same way regardless of whether you installed via `uv tool`, `pipx`, `pip`, or the npm-wrapped binary):
|
|
83
57
|
|
|
84
58
|
```bash
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
ln -sf "$(uv tool dir)/nexla-cli/lib/python3."*"/site-packages/nexla_cli/SKILL.md" \
|
|
89
|
-
~/.claude/skills/nexla-cli/SKILL.md
|
|
90
|
-
|
|
91
|
-
# if installed with `pipx install`:
|
|
92
|
-
ln -sf "$HOME/.local/pipx/venvs/nexla-cli/lib/python3."*"/site-packages/nexla_cli/SKILL.md" \
|
|
93
|
-
~/.claude/skills/nexla-cli/SKILL.md
|
|
59
|
+
nexla skill install
|
|
60
|
+
# or, for a project-local install instead of the global default:
|
|
61
|
+
nexla skill install --target .claude/skills/nexla-cli
|
|
94
62
|
```
|
|
95
63
|
|
|
96
|
-
|
|
64
|
+
Re-run this after upgrading `nexla-cli` to pick up any skill content changes. Restart Claude Code (or start a new session) afterward for it to be picked up.
|
|
97
65
|
|
|
98
66
|
## Global flags
|
|
99
67
|
|
|
@@ -146,6 +114,7 @@ nexla <resource> --help
|
|
|
146
114
|
| `toolsets` | `list`, `get`, `create`, `update`, `delete`, `add-nexsets` |
|
|
147
115
|
| `tools` | `list`, `get`, `set-runtime-config`, `clear-runtime-config`, `delete` |
|
|
148
116
|
| `mcp-servers` | `list`, `attach`, `sync`, `detach` (nested under a toolset) |
|
|
117
|
+
| `triage` | `errors`, `status`, `run`, `metrics`, `resource-status`, `org-metrics`, `user-metrics`, `notifications`, `search`, `logs`, `quarantine` — flow/log triage via the Nexla monitoring MCP server, not the main API |
|
|
149
118
|
| `context` | `get` |
|
|
150
119
|
| `orgs` | `list`, `get` |
|
|
151
120
|
| `code-containers` | `list` |
|
|
@@ -165,3 +134,7 @@ nexla <resource> --help
|
|
|
165
134
|
| 4 | 401/403 from the API |
|
|
166
135
|
| 5 | 404 |
|
|
167
136
|
| 6 | 5xx from the API |
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
This repo ships to three places from one tag push: GitHub Releases (binaries),
|
|
4
|
+
PyPI (sdist/wheel), and npm (wrapper around the binaries). Maintainer-only —
|
|
5
|
+
none of this belongs in `README.md`/`npm/README.md`, since both render on
|
|
6
|
+
public package pages (PyPI's long description and the npm package page).
|
|
7
|
+
|
|
8
|
+
## Cutting a release
|
|
9
|
+
|
|
10
|
+
1. Bump the version in both `pyproject.toml` and `npm/package.json` — they
|
|
11
|
+
must match, since `npm/scripts/install.js` derives the GitHub release tag
|
|
12
|
+
it downloads from `npm/package.json`'s version.
|
|
13
|
+
2. `git tag vX.Y.Z && git push --tags`. This triggers two workflows:
|
|
14
|
+
- `.github/workflows/release-binaries.yml` — builds binaries for macOS
|
|
15
|
+
(arm64), Linux (x64), and Windows (x64) via PyInstaller and attaches
|
|
16
|
+
them to a new GitHub Release.
|
|
17
|
+
- `.github/workflows/publish-pypi.yml` — builds the sdist+wheel and
|
|
18
|
+
publishes to PyPI via Trusted Publishing (OIDC).
|
|
19
|
+
3. Once the GitHub Release assets are up, publish the npm wrapper (not yet
|
|
20
|
+
automated):
|
|
21
|
+
```bash
|
|
22
|
+
cd npm && npm publish
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## One-time PyPI Trusted Publishing setup (repo owner only)
|
|
26
|
+
|
|
27
|
+
Required once before the first PyPI release; see
|
|
28
|
+
`.github/workflows/publish-pypi.yml`'s own comments for the exact steps
|
|
29
|
+
(project name, trusted-publisher owner/repo/workflow filename/environment) —
|
|
30
|
+
kept there since it lives right next to the `id-token: write` permission it
|
|
31
|
+
configures.
|
|
32
|
+
|
|
33
|
+
## Binary release matrix
|
|
34
|
+
|
|
35
|
+
Supported platforms: `darwin-arm64`, `linux-x64`, `win32-x64` — see
|
|
36
|
+
`npm/scripts/install.js`'s `ASSETS` map. To support another platform, add an
|
|
37
|
+
entry there and a matching matrix row in `release-binaries.yml`.
|
|
38
|
+
|
|
39
|
+
Intel Mac (`macos-13`) was dropped from the matrix: GitHub's Intel-macOS
|
|
40
|
+
runner capacity is shrinking as it's phased out, and that job was sitting
|
|
41
|
+
queued 15+ minutes with zero progress while the other three platforms
|
|
42
|
+
finished in under 90s each. Re-add a `macos-13` row + a `darwin-x64` entry
|
|
43
|
+
in `ASSETS` if Intel Mac support is actually needed again.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# nexla-cli (npm wrapper)
|
|
2
|
+
|
|
3
|
+
Thin npm package that installs the prebuilt `nexla` binary — no Python
|
|
4
|
+
required. `postinstall` (`scripts/install.js`) downloads the binary matching
|
|
5
|
+
the current OS/arch from the GitHub release tagged `v<package version>`;
|
|
6
|
+
`bin/nexla.js` execs it and forwards argv/stdio/exit code.
|
|
7
|
+
|
|
8
|
+
Supported platforms: `darwin-arm64`, `linux-x64`, `win32-x64`.
|
|
@@ -50,6 +50,16 @@ response does not guarantee the resource is fully live yet. Poll
|
|
|
50
50
|
for sources, `source_nexset_id` once schema inference has run) rather than
|
|
51
51
|
assuming the `create` response reflects final state.
|
|
52
52
|
|
|
53
|
+
`sources get`/`nexsets get`/`sinks get` all take `--wait-until` to do this
|
|
54
|
+
polling for you instead of hand-writing a `for i in ...; sleep N; done`
|
|
55
|
+
shell loop — `--wait-until <field>` blocks until that dotted field is
|
|
56
|
+
truthy (e.g. `--wait-until source_nexset_id`, `--wait-until samples`), or
|
|
57
|
+
`--wait-until field=value` for an exact match (e.g.
|
|
58
|
+
`--wait-until runtime_status=ACTIVE`). `--wait-timeout` (default 300s) and
|
|
59
|
+
`--wait-interval` (default 5s) control the polling; timing out raises exit
|
|
60
|
+
code `1` with the last observed value in the message, rather than hanging
|
|
61
|
+
forever or silently returning stale state.
|
|
62
|
+
|
|
53
63
|
## Raw JSON body passthrough
|
|
54
64
|
|
|
55
65
|
`create`/`update`-style commands (`sources`, `sinks`, `credentials`,
|
|
@@ -141,9 +151,10 @@ sink activates successfully (`status: ACTIVE`) and then sits in
|
|
|
141
151
|
exist") only surfaces in the web UI's notification stream, not through
|
|
142
152
|
this CLI. Create the table with the exact column set your nexset's
|
|
143
153
|
`output_schema` implies before calling `sinks create`, or the run will
|
|
144
|
-
silently stall. (`nexla triage resource-status sinks
|
|
145
|
-
`nexla triage logs <flow_id>` will surface
|
|
146
|
-
see "Flow/log triage" below — instead of
|
|
154
|
+
silently stall. (`nexla triage resource-status --resource-type sinks
|
|
155
|
+
--resource-id <sink_id>` and `nexla triage logs <flow_id>` will surface
|
|
156
|
+
the JDBC error directly — see "Flow/log triage" below — instead of
|
|
157
|
+
needing the web UI.)
|
|
147
158
|
|
|
148
159
|
`sinks create` now runs a pre-flight check for this itself when
|
|
149
160
|
`--connector` is a known DB/JDBC connector and `--config` includes a
|
|
@@ -168,6 +179,18 @@ connector where the tree probe is known to be unreliable). `--dry-run`
|
|
|
168
179
|
is unaffected either way — it always exits before any network call, this
|
|
169
180
|
check included.
|
|
170
181
|
|
|
182
|
+
**Supabase specifically always needs `--skip-table-check`.** Its tree
|
|
183
|
+
probe (`nexla probe run --action tree --credential-id <id>`, drilling
|
|
184
|
+
into `postgres` then `public`) bottoms out at the schema level and never
|
|
185
|
+
returns table names at all — confirmed live, `--params '{"path":
|
|
186
|
+
"public"}'` comes back `"nodes": []` regardless of what tables actually
|
|
187
|
+
exist. That lands in the *second* outcome above (probe succeeds, table
|
|
188
|
+
"not found" among zero returned names) — a hard failure, exit 2, not the
|
|
189
|
+
softer warn-and-proceed branch — even when the table is genuinely there.
|
|
190
|
+
Always pass `--skip-table-check` for Supabase sinks and verify the table
|
|
191
|
+
exists yourself first (`psql`/the Supabase dashboard), rather than relying
|
|
192
|
+
on this pre-flight check.
|
|
193
|
+
|
|
171
194
|
## `probe run`'s `--params` shape depends on the connector's `kind`
|
|
172
195
|
|
|
173
196
|
`nexla probe run --action sample|tree --params '<json>'` takes a
|
|
@@ -98,6 +98,11 @@ asynchronously. Poll `nexla sources get <id>` (check `status`, and
|
|
|
98
98
|
`source_nexset_id` once schema inference completes) instead of assuming
|
|
99
99
|
the `create` response is the final state.
|
|
100
100
|
|
|
101
|
+
`sources get`/`nexsets get`/`sinks get` support `--wait-until <field>`
|
|
102
|
+
(polls until truthy) or `--wait-until field=value` (exact match), plus
|
|
103
|
+
`--wait-timeout`/`--wait-interval` — use these instead of a hand-rolled
|
|
104
|
+
polling loop, e.g. `nexla sources get <id> --wait-until source_nexset_id`.
|
|
105
|
+
|
|
101
106
|
## Inspecting the live API schema
|
|
102
107
|
|
|
103
108
|
```bash
|
|
@@ -39,6 +39,7 @@ from .resources import (
|
|
|
39
39
|
orgs,
|
|
40
40
|
probe,
|
|
41
41
|
sinks,
|
|
42
|
+
skill,
|
|
42
43
|
sources,
|
|
43
44
|
tools,
|
|
44
45
|
toolsets,
|
|
@@ -169,6 +170,7 @@ for _mod in (
|
|
|
169
170
|
users,
|
|
170
171
|
notifications,
|
|
171
172
|
triage,
|
|
173
|
+
skill,
|
|
172
174
|
):
|
|
173
175
|
_wrap_app_commands(_mod.app)
|
|
174
176
|
app.add_typer(_mod.app)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared ``--wait-until`` polling for ``get`` commands on resources that
|
|
2
|
+
finish provisioning asynchronously.
|
|
3
|
+
|
|
4
|
+
``sources create``/``sinks create`` auto-activate but don't block on
|
|
5
|
+
activation (see AGENTS.md), and a transform's derived nexset takes a
|
|
6
|
+
moment to get real samples -- every one of this session's own live runs
|
|
7
|
+
hand-wrote a ``for i in ...; do ...; sleep N; done`` bash loop around
|
|
8
|
+
``nexla <resource> get <id>`` to wait for readiness. This gives that loop
|
|
9
|
+
a name instead of asking every caller to reinvent it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from .errors import EXIT, CliError
|
|
21
|
+
|
|
22
|
+
WAIT_TIMEOUT_OPT = typer.Option(300, "--wait-timeout", help="Max seconds to poll with --wait-until")
|
|
23
|
+
WAIT_INTERVAL_OPT = typer.Option(5, "--wait-interval", help="Seconds between polls with --wait-until")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def wait_until_option(example: str) -> Any:
|
|
27
|
+
"""Build a ``--wait-until`` option with a resource-specific example.
|
|
28
|
+
|
|
29
|
+
A single shared example across sources/nexsets/sinks would show
|
|
30
|
+
``source_nexset_id`` on `nexsets get`, where that field doesn't even
|
|
31
|
+
exist -- each caller passes the field that's actually relevant to it.
|
|
32
|
+
"""
|
|
33
|
+
return typer.Option(
|
|
34
|
+
None,
|
|
35
|
+
"--wait-until",
|
|
36
|
+
help=f"Poll until this dotted field is truthy, or 'field=value' for an exact "
|
|
37
|
+
f"match (e.g. {example})",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dotted_get(body: Any, field: str) -> Any:
|
|
42
|
+
cur = body
|
|
43
|
+
for part in field.split("."):
|
|
44
|
+
if not isinstance(cur, dict):
|
|
45
|
+
return None
|
|
46
|
+
cur = cur.get(part)
|
|
47
|
+
return cur
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def poll_until(fetch: Callable[[], Any], condition: str, timeout: int, interval: int) -> Any:
|
|
51
|
+
"""Call ``fetch()`` repeatedly until ``condition`` is met or ``timeout`` elapses.
|
|
52
|
+
|
|
53
|
+
``condition`` is a dotted field name (waits for a truthy/non-empty
|
|
54
|
+
value) or ``field=value`` for an exact string match. Raises
|
|
55
|
+
:class:`CliError` (exit ``EXIT.ERROR``) on timeout, including the last
|
|
56
|
+
observed value so the caller knows how far it got.
|
|
57
|
+
"""
|
|
58
|
+
field, sep, expected = condition.partition("=")
|
|
59
|
+
deadline = time.monotonic() + timeout
|
|
60
|
+
while True:
|
|
61
|
+
body = fetch()
|
|
62
|
+
value = _dotted_get(body, field)
|
|
63
|
+
met = (str(value) == expected) if sep else bool(value)
|
|
64
|
+
if met:
|
|
65
|
+
return body
|
|
66
|
+
if time.monotonic() >= deadline:
|
|
67
|
+
raise CliError(
|
|
68
|
+
EXIT.ERROR,
|
|
69
|
+
f"timed out after {timeout}s waiting for {condition!r} (last value: {value!r})",
|
|
70
|
+
)
|
|
71
|
+
time.sleep(interval)
|
|
@@ -10,7 +10,7 @@ import json as jsonlib
|
|
|
10
10
|
|
|
11
11
|
import typer
|
|
12
12
|
|
|
13
|
-
from .. import client, dryrun, output, validate
|
|
13
|
+
from .. import client, dryrun, output, poll, validate
|
|
14
14
|
|
|
15
15
|
app = typer.Typer(name="nexsets", help="Manage Nexla nexsets.", no_args_is_help=True)
|
|
16
16
|
|
|
@@ -56,13 +56,20 @@ def list_(
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
@app.command("get")
|
|
59
|
-
def get(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
def get(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
nexset_id: int,
|
|
62
|
+
wait_until: str | None = poll.wait_until_option("samples, or output_schema"),
|
|
63
|
+
wait_timeout: int = poll.WAIT_TIMEOUT_OPT,
|
|
64
|
+
wait_interval: int = poll.WAIT_INTERVAL_OPT,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Get one nexset, including samples and schema. `--wait-until samples` polls until non-empty."""
|
|
67
|
+
|
|
68
|
+
def fetch() -> object:
|
|
69
|
+
return client.request("GET", f"/nexla/nexsets/{nexset_id}")
|
|
70
|
+
|
|
71
|
+
body = poll.poll_until(fetch, wait_until, wait_timeout, wait_interval) if wait_until else fetch()
|
|
72
|
+
output.emit(body, mode=output.ctx_mode(ctx), fields=output.ctx_fields(ctx))
|
|
66
73
|
|
|
67
74
|
|
|
68
75
|
@app.command("transform")
|
|
@@ -11,7 +11,7 @@ import json as jsonlib
|
|
|
11
11
|
|
|
12
12
|
import typer
|
|
13
13
|
|
|
14
|
-
from .. import client, dryrun, output, validate
|
|
14
|
+
from .. import client, dryrun, output, poll, validate
|
|
15
15
|
from ..errors import EXIT, CliError
|
|
16
16
|
|
|
17
17
|
app = typer.Typer(name="sinks", help="Manage Nexla data sinks.", no_args_is_help=True)
|
|
@@ -160,13 +160,20 @@ def list_(
|
|
|
160
160
|
|
|
161
161
|
|
|
162
162
|
@app.command("get")
|
|
163
|
-
def get(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
163
|
+
def get(
|
|
164
|
+
ctx: typer.Context,
|
|
165
|
+
sink_id: int,
|
|
166
|
+
wait_until: str | None = poll.wait_until_option("runtime_status=ACTIVE"),
|
|
167
|
+
wait_timeout: int = poll.WAIT_TIMEOUT_OPT,
|
|
168
|
+
wait_interval: int = poll.WAIT_INTERVAL_OPT,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Get one sink by id. `--wait-until runtime_status=ACTIVE` polls for a status change."""
|
|
171
|
+
|
|
172
|
+
def fetch() -> object:
|
|
173
|
+
return client.request("GET", f"/nexla/sinks/{sink_id}")
|
|
174
|
+
|
|
175
|
+
body = poll.poll_until(fetch, wait_until, wait_timeout, wait_interval) if wait_until else fetch()
|
|
176
|
+
output.emit(body, mode=output.ctx_mode(ctx), fields=output.ctx_fields(ctx))
|
|
170
177
|
|
|
171
178
|
|
|
172
179
|
@app.command("create")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""`nexla skill install` — self-locating Claude Code skill installer.
|
|
2
|
+
|
|
3
|
+
Claude Code only discovers skills at ``~/.claude/skills/<name>/SKILL.md``
|
|
4
|
+
(global) or ``.claude/skills/<name>/SKILL.md`` (project-local) -- a file
|
|
5
|
+
merely present inside an installed package isn't picked up automatically.
|
|
6
|
+
Previously this required the user to figure out which install method
|
|
7
|
+
(``uv tool``/``pipx``/npm-wrapped binary) put ``SKILL.md`` where, and
|
|
8
|
+
hand-construct a glob path per method. ``importlib.resources`` already
|
|
9
|
+
knows exactly where this package's own files live regardless of install
|
|
10
|
+
method, so there's nothing to guess.
|
|
11
|
+
|
|
12
|
+
Copies rather than symlinks: a symlink would be great for `uv tool`/`pipx`
|
|
13
|
+
installs (auto-picks up future upgrades), but is useless for the
|
|
14
|
+
PyInstaller-compiled binary (npm distribution), where the source path is
|
|
15
|
+
an ephemeral extraction directory that's gone once the process exits. One
|
|
16
|
+
behavior for every install method is simpler than branching on which one
|
|
17
|
+
is in play; the tradeoff is re-running this command after an upgrade to
|
|
18
|
+
pick up skill content changes.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib.resources
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(name="skill", help="Manage this CLI's Claude Code skill install.", no_args_is_help=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("install")
|
|
32
|
+
def install(
|
|
33
|
+
target: Path | None = typer.Option(
|
|
34
|
+
None,
|
|
35
|
+
help="Install directory. Default is the global ~/.claude/skills/nexla-cli; "
|
|
36
|
+
"pass .claude/skills/nexla-cli for a project-local install instead",
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Copy this package's SKILL.md and AGENTS.md to where Claude Code discovers it.
|
|
40
|
+
|
|
41
|
+
Both files, not just SKILL.md: SKILL.md itself points readers at
|
|
42
|
+
AGENTS.md for fuller explanations in a few places, so installing one
|
|
43
|
+
without the other leaves a dangling reference.
|
|
44
|
+
"""
|
|
45
|
+
if target is None:
|
|
46
|
+
target = Path.home() / ".claude" / "skills" / "nexla-cli"
|
|
47
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
for name in ("SKILL.md", "AGENTS.md"):
|
|
49
|
+
src = importlib.resources.files("nexla_cli") / name
|
|
50
|
+
# Explicit utf-8 on both ends: these files contain non-ASCII
|
|
51
|
+
# (em dashes) and the platform-default encoding read_text/
|
|
52
|
+
# write_text otherwise fall back to is cp1252 on Windows, which
|
|
53
|
+
# would raise or mangle bytes on that build.
|
|
54
|
+
(target / name).write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
55
|
+
typer.echo(f"Installed skill to {target}")
|
|
56
|
+
typer.echo("Restart Claude Code (or start a new session) to pick it up.")
|
|
@@ -10,7 +10,7 @@ import json as jsonlib
|
|
|
10
10
|
|
|
11
11
|
import typer
|
|
12
12
|
|
|
13
|
-
from .. import client, dryrun, output, validate
|
|
13
|
+
from .. import client, dryrun, output, poll, validate
|
|
14
14
|
|
|
15
15
|
app = typer.Typer(name="sources", help="Manage Nexla data sources.", no_args_is_help=True)
|
|
16
16
|
|
|
@@ -56,13 +56,20 @@ def list_(
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
@app.command("get")
|
|
59
|
-
def get(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
def get(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
source_id: int,
|
|
62
|
+
wait_until: str | None = poll.wait_until_option("source_nexset_id, or status=ACTIVE"),
|
|
63
|
+
wait_timeout: int = poll.WAIT_TIMEOUT_OPT,
|
|
64
|
+
wait_interval: int = poll.WAIT_INTERVAL_OPT,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Get one source by id. `--wait-until source_nexset_id` polls until it appears."""
|
|
67
|
+
|
|
68
|
+
def fetch() -> object:
|
|
69
|
+
return client.request("GET", f"/nexla/sources/{source_id}")
|
|
70
|
+
|
|
71
|
+
body = poll.poll_until(fetch, wait_until, wait_timeout, wait_interval) if wait_until else fetch()
|
|
72
|
+
output.emit(body, mode=output.ctx_mode(ctx), fields=output.ctx_fields(ctx))
|
|
66
73
|
|
|
67
74
|
|
|
68
75
|
@app.command("create")
|
|
@@ -26,6 +26,8 @@ app = typer.Typer(
|
|
|
26
26
|
_DRY_RUN_OPT = typer.Option(
|
|
27
27
|
False, "--dry-run", help="Validate arguments locally against the tool's live schema; call nothing"
|
|
28
28
|
)
|
|
29
|
+
_FLOW_ID_ARG = typer.Argument(..., help="Flow ID")
|
|
30
|
+
_RESOURCE_ID_OPT = typer.Option(..., help="ID of the source/nexset/sink named by --resource-type")
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def _args(**kwargs: object) -> dict[str, object]:
|
|
@@ -57,7 +59,7 @@ def errors(
|
|
|
57
59
|
@app.command("status")
|
|
58
60
|
def status(
|
|
59
61
|
ctx: typer.Context,
|
|
60
|
-
flow_id: int,
|
|
62
|
+
flow_id: int = _FLOW_ID_ARG,
|
|
61
63
|
run_id: int | None = typer.Option(None, help="Pin a specific run instead of the latest"),
|
|
62
64
|
dry_run: bool = _DRY_RUN_OPT,
|
|
63
65
|
) -> None:
|
|
@@ -66,7 +68,12 @@ def status(
|
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
@app.command("run")
|
|
69
|
-
def run(
|
|
71
|
+
def run(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
flow_id: int = _FLOW_ID_ARG,
|
|
74
|
+
run_id: int = typer.Argument(..., help="Run ID"),
|
|
75
|
+
dry_run: bool = _DRY_RUN_OPT,
|
|
76
|
+
) -> None:
|
|
70
77
|
"""One flow run's execution timing (started_at/completed_at/duration_ms)."""
|
|
71
78
|
_run(ctx, "get_flow_run", _args(flow_id=flow_id, run_id=run_id), dry_run)
|
|
72
79
|
|
|
@@ -74,7 +81,7 @@ def run(ctx: typer.Context, flow_id: int, run_id: int, dry_run: bool = _DRY_RUN_
|
|
|
74
81
|
@app.command("metrics")
|
|
75
82
|
def metrics(
|
|
76
83
|
ctx: typer.Context,
|
|
77
|
-
flow_id: int,
|
|
84
|
+
flow_id: int = _FLOW_ID_ARG,
|
|
78
85
|
from_date: str | None = typer.Option(None, help="YYYY-MM-DD, UTC, inclusive"),
|
|
79
86
|
to_date: str | None = typer.Option(None, help="YYYY-MM-DD, UTC, exclusive"),
|
|
80
87
|
granularity: str = typer.Option("daily", help="'daily' or 'monthly'"),
|
|
@@ -93,7 +100,7 @@ def metrics(
|
|
|
93
100
|
def resource_status(
|
|
94
101
|
ctx: typer.Context,
|
|
95
102
|
resource_type: str = typer.Option(..., help="'sources' | 'nexsets' | 'sinks'"),
|
|
96
|
-
resource_id: int =
|
|
103
|
+
resource_id: int = _RESOURCE_ID_OPT,
|
|
97
104
|
from_date: str | None = typer.Option(None, help="YYYY-MM-DD, UTC, inclusive"),
|
|
98
105
|
to_date: str | None = typer.Option(None, help="YYYY-MM-DD, UTC, exclusive"),
|
|
99
106
|
dry_run: bool = _DRY_RUN_OPT,
|
|
@@ -152,7 +159,7 @@ def notifications(
|
|
|
152
159
|
@app.command("search")
|
|
153
160
|
def search(
|
|
154
161
|
ctx: typer.Context,
|
|
155
|
-
name: str,
|
|
162
|
+
name: str = typer.Argument(..., help="Name substring to search for"),
|
|
156
163
|
limit: int = typer.Option(10, help="Max hits"),
|
|
157
164
|
dry_run: bool = _DRY_RUN_OPT,
|
|
158
165
|
) -> None:
|
|
@@ -163,7 +170,7 @@ def search(
|
|
|
163
170
|
@app.command("logs")
|
|
164
171
|
def logs(
|
|
165
172
|
ctx: typer.Context,
|
|
166
|
-
flow_id: int,
|
|
173
|
+
flow_id: int = _FLOW_ID_ARG,
|
|
167
174
|
run_id: int | None = typer.Option(None, help="Pin to one run; omit is noisy across all runs"),
|
|
168
175
|
severity: str | None = typer.Option(None, help="ERROR|WARNING|INFO"),
|
|
169
176
|
search: str | None = typer.Option(None, help="Free-text substring match on log message bodies"),
|
|
@@ -193,7 +200,7 @@ def logs(
|
|
|
193
200
|
def quarantine(
|
|
194
201
|
ctx: typer.Context,
|
|
195
202
|
resource_type: str = typer.Option(..., help="'sources' | 'nexsets' | 'sinks'"),
|
|
196
|
-
resource_id: int =
|
|
203
|
+
resource_id: int = _RESOURCE_ID_OPT,
|
|
197
204
|
sample_size: int = typer.Option(10, help="How many rejected records to return"),
|
|
198
205
|
dry_run: bool = _DRY_RUN_OPT,
|
|
199
206
|
) -> None:
|
|
@@ -32,8 +32,10 @@ pytestmark = pytest.mark.skipif(
|
|
|
32
32
|
|
|
33
33
|
# `schema` is Phase 3's own callback-only sub-app, not one of the 17
|
|
34
34
|
# resource modules the overview plan's surface table covers -- it has no
|
|
35
|
-
# `/nexla/schema` route to check against.
|
|
36
|
-
|
|
35
|
+
# `/nexla/schema` route to check against. `skill` is a purely local
|
|
36
|
+
# filesystem operation (installs SKILL.md to ~/.claude/skills/) that makes
|
|
37
|
+
# no HTTP call at all -- same reasoning, no live route to check.
|
|
38
|
+
_NON_RESOURCE_GROUPS = {"schema", "skill"}
|
|
37
39
|
|
|
38
40
|
# Per the overview plan's surface table: these resources are fully stubbed
|
|
39
41
|
# (every route 501s) and their sub-router is deliberately hidden from
|
|
@@ -28,6 +28,23 @@ def test_get(runner: CliRunner, cli_app, respx_mock: respx.MockRouter) -> None:
|
|
|
28
28
|
assert route.called
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def test_get_wait_until_polls_until_samples_appear(
|
|
32
|
+
runner: CliRunner, cli_app, respx_mock: respx.MockRouter, monkeypatch
|
|
33
|
+
) -> None:
|
|
34
|
+
monkeypatch.setattr("nexla_cli.poll.time.sleep", lambda s: None)
|
|
35
|
+
route = respx_mock.get(f"{BASE_URL}/nexla/nexsets/1").mock(
|
|
36
|
+
side_effect=[
|
|
37
|
+
httpx.Response(200, json={"id": 1, "samples": []}),
|
|
38
|
+
httpx.Response(200, json={"id": 1, "samples": [{"x": 1}]}),
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
result = runner.invoke(
|
|
42
|
+
cli_app, ["nexsets", "get", "1", "--wait-until", "samples", "--wait-interval", "0"]
|
|
43
|
+
)
|
|
44
|
+
assert result.exit_code == 0
|
|
45
|
+
assert route.call_count == 2
|
|
46
|
+
|
|
47
|
+
|
|
31
48
|
def test_transform(runner: CliRunner, cli_app, respx_mock: respx.MockRouter) -> None:
|
|
32
49
|
route = respx_mock.post(f"{BASE_URL}/nexla/nexsets/1/transform").mock(
|
|
33
50
|
return_value=httpx.Response(201, json={"id": 2, "name": "derived"})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from nexla_cli.errors import CliError
|
|
6
|
+
from nexla_cli.poll import poll_until
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_poll_until_truthy_field_returns_immediately() -> None:
|
|
10
|
+
body = {"id": 1, "source_nexset_id": 42}
|
|
11
|
+
assert poll_until(lambda: body, "source_nexset_id", timeout=5, interval=0) == body
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_poll_until_truthy_field_waits_for_it(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
15
|
+
calls = iter([{"source_nexset_id": None}, {"source_nexset_id": None}, {"source_nexset_id": 7}])
|
|
16
|
+
sleeps: list[float] = []
|
|
17
|
+
monkeypatch.setattr("nexla_cli.poll.time.sleep", lambda s: sleeps.append(s))
|
|
18
|
+
result = poll_until(lambda: next(calls), "source_nexset_id", timeout=5, interval=1)
|
|
19
|
+
assert result == {"source_nexset_id": 7}
|
|
20
|
+
assert sleeps == [1, 1]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_poll_until_exact_match(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
24
|
+
calls = iter([{"runtime_status": "IDLE"}, {"runtime_status": "ACTIVE"}])
|
|
25
|
+
monkeypatch.setattr("nexla_cli.poll.time.sleep", lambda s: None)
|
|
26
|
+
result = poll_until(lambda: next(calls), "runtime_status=ACTIVE", timeout=5, interval=1)
|
|
27
|
+
assert result == {"runtime_status": "ACTIVE"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_poll_until_dotted_field() -> None:
|
|
31
|
+
body = {"status": {"phase": "done"}}
|
|
32
|
+
assert poll_until(lambda: body, "status.phase=done", timeout=5, interval=1) == body
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_poll_until_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
36
|
+
ticks = iter([0.0, 0.0, 10.0]) # deadline computed from the first tick, exceeded on the third
|
|
37
|
+
monkeypatch.setattr("nexla_cli.poll.time.monotonic", lambda: next(ticks))
|
|
38
|
+
monkeypatch.setattr("nexla_cli.poll.time.sleep", lambda s: None)
|
|
39
|
+
with pytest.raises(CliError) as exc_info:
|
|
40
|
+
poll_until(lambda: {"source_nexset_id": None}, "source_nexset_id", timeout=5, interval=1)
|
|
41
|
+
assert exc_info.value.code == 1
|
|
42
|
+
assert "timed out after 5s" in exc_info.value.message
|
|
@@ -35,6 +35,24 @@ def test_get(runner: CliRunner, cli_app, respx_mock: respx.MockRouter) -> None:
|
|
|
35
35
|
assert route.called
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def test_get_wait_until_exact_match(
|
|
39
|
+
runner: CliRunner, cli_app, respx_mock: respx.MockRouter, monkeypatch
|
|
40
|
+
) -> None:
|
|
41
|
+
monkeypatch.setattr("nexla_cli.poll.time.sleep", lambda s: None)
|
|
42
|
+
route = respx_mock.get(f"{BASE_URL}/nexla/sinks/1").mock(
|
|
43
|
+
side_effect=[
|
|
44
|
+
httpx.Response(200, json={"id": 1, "runtime_status": "IDLE"}),
|
|
45
|
+
httpx.Response(200, json={"id": 1, "runtime_status": "ACTIVE"}),
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
result = runner.invoke(
|
|
49
|
+
cli_app,
|
|
50
|
+
["sinks", "get", "1", "--wait-until", "runtime_status=ACTIVE", "--wait-interval", "0"],
|
|
51
|
+
)
|
|
52
|
+
assert result.exit_code == 0
|
|
53
|
+
assert route.call_count == 2
|
|
54
|
+
|
|
55
|
+
|
|
38
56
|
def test_create(runner: CliRunner, cli_app, respx_mock: respx.MockRouter) -> None:
|
|
39
57
|
route = respx_mock.post(f"{BASE_URL}/nexla/sinks").mock(
|
|
40
58
|
return_value=httpx.Response(201, json={"id": 2, "name": "new-sink"})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_install_default_target_uses_home(
|
|
9
|
+
runner: CliRunner, cli_app, monkeypatch, tmp_path: Path
|
|
10
|
+
) -> None:
|
|
11
|
+
import nexla_cli
|
|
12
|
+
|
|
13
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
14
|
+
result = runner.invoke(cli_app, ["skill", "install"])
|
|
15
|
+
assert result.exit_code == 0
|
|
16
|
+
installed_dir = tmp_path / ".claude" / "skills" / "nexla-cli"
|
|
17
|
+
packaged_dir = Path(nexla_cli.__file__).parent
|
|
18
|
+
for name in ("SKILL.md", "AGENTS.md"):
|
|
19
|
+
assert (installed_dir / name).read_text(encoding="utf-8") == (
|
|
20
|
+
packaged_dir / name
|
|
21
|
+
).read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_install_explicit_target(runner: CliRunner, cli_app, tmp_path: Path) -> None:
|
|
25
|
+
target = tmp_path / "custom"
|
|
26
|
+
result = runner.invoke(cli_app, ["skill", "install", "--target", str(target)])
|
|
27
|
+
assert result.exit_code == 0
|
|
28
|
+
assert (target / "SKILL.md").is_file()
|
|
29
|
+
assert (target / "AGENTS.md").is_file()
|
|
@@ -38,6 +38,24 @@ def test_get(runner: CliRunner, cli_app, respx_mock: respx.MockRouter) -> None:
|
|
|
38
38
|
assert "s1" in result.stdout
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def test_get_wait_until_polls_until_field_appears(
|
|
42
|
+
runner: CliRunner, cli_app, respx_mock: respx.MockRouter, monkeypatch
|
|
43
|
+
) -> None:
|
|
44
|
+
monkeypatch.setattr("nexla_cli.poll.time.sleep", lambda s: None)
|
|
45
|
+
route = respx_mock.get(f"{BASE_URL}/nexla/sources/1").mock(
|
|
46
|
+
side_effect=[
|
|
47
|
+
httpx.Response(200, json={"id": 1, "source_nexset_id": None}),
|
|
48
|
+
httpx.Response(200, json={"id": 1, "source_nexset_id": 42}),
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
result = runner.invoke(
|
|
52
|
+
cli_app, ["sources", "get", "1", "--wait-until", "source_nexset_id", "--wait-interval", "0"]
|
|
53
|
+
)
|
|
54
|
+
assert result.exit_code == 0
|
|
55
|
+
assert route.call_count == 2
|
|
56
|
+
assert "42" in result.stdout
|
|
57
|
+
|
|
58
|
+
|
|
41
59
|
def test_get_not_found(runner: CliRunner, cli_app, respx_mock: respx.MockRouter) -> None:
|
|
42
60
|
respx_mock.get(f"{BASE_URL}/nexla/sources/99").mock(
|
|
43
61
|
return_value=httpx.Response(404, json={"detail": {"error": "not_found"}})
|
nexla_cli-0.2.0/npm/README.md
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# nexla-cli (npm wrapper)
|
|
2
|
-
|
|
3
|
-
Thin npm package that installs the prebuilt `nexla` binary — no Python
|
|
4
|
-
required. `postinstall` (`scripts/install.js`) downloads the binary matching
|
|
5
|
-
the current OS/arch from the GitHub release tagged `v<package version>`;
|
|
6
|
-
`bin/nexla.js` execs it and forwards argv/stdio/exit code.
|
|
7
|
-
|
|
8
|
-
## Cutting a release
|
|
9
|
-
|
|
10
|
-
1. Bump the version in both `pyproject.toml` and `npm/package.json` — they
|
|
11
|
-
must match, since `scripts/install.js` derives the release tag from
|
|
12
|
-
`npm/package.json`'s version.
|
|
13
|
-
2. `git tag vX.Y.Z && git push --tags` — this triggers
|
|
14
|
-
`.github/workflows/release-binaries.yml`, which builds binaries for
|
|
15
|
-
macOS (arm64), Linux (x64), and Windows (x64) via PyInstaller and
|
|
16
|
-
attaches them to the GitHub release.
|
|
17
|
-
3. Once the release assets are up, publish the npm wrapper:
|
|
18
|
-
```bash
|
|
19
|
-
cd npm && npm publish
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Supported platforms: `darwin-arm64`, `linux-x64`, `win32-x64` (see
|
|
23
|
-
`scripts/install.js`'s `ASSETS` map — add an entry there and a matching
|
|
24
|
-
matrix row in the workflow to support more, e.g. Intel Mac was dropped
|
|
25
|
-
from the matrix due to GitHub's shrinking `macos-13` runner capacity;
|
|
26
|
-
re-add a `macos-13` row + a `darwin-x64` ASSETS entry if that's needed
|
|
27
|
-
again).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|