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.
Files changed (81) hide show
  1. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/.github/workflows/publish-pypi.yml +7 -4
  2. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/.github/workflows/release-binaries.yml +21 -3
  3. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/PKG-INFO +13 -40
  4. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/README.md +12 -39
  5. nexla_cli-0.2.1/RELEASING.md +43 -0
  6. nexla_cli-0.2.1/npm/README.md +8 -0
  7. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/npm/package.json +1 -1
  8. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/pyproject.toml +1 -1
  9. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/AGENTS.md +26 -3
  10. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/SKILL.md +5 -0
  11. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/__init__.py +2 -0
  12. nexla_cli-0.2.1/src/nexla_cli/poll.py +71 -0
  13. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/nexsets.py +15 -8
  14. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/sinks.py +15 -8
  15. nexla_cli-0.2.1/src/nexla_cli/resources/skill.py +56 -0
  16. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/sources.py +15 -8
  17. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/triage.py +14 -7
  18. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_contract.py +4 -2
  19. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_nexsets.py +17 -0
  20. nexla_cli-0.2.1/tests/test_poll.py +42 -0
  21. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_sinks.py +18 -0
  22. nexla_cli-0.2.1/tests/test_skill.py +29 -0
  23. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_sources.py +18 -0
  24. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/uv.lock +1 -1
  25. nexla_cli-0.2.0/npm/README.md +0 -27
  26. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/.gitignore +0 -0
  27. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/LICENSE +0 -0
  28. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/npm/bin/nexla.js +0 -0
  29. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/npm/scripts/install.js +0 -0
  30. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/scripts/pyinstaller_entry.py +0 -0
  31. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/client.py +0 -0
  32. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/dryrun.py +0 -0
  33. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/errors.py +0 -0
  34. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/login.py +0 -0
  35. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/mcp_client.py +0 -0
  36. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/openapi_client.py +0 -0
  37. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/output.py +0 -0
  38. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/py.typed +0 -0
  39. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/__init__.py +0 -0
  40. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/code_containers.py +0 -0
  41. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/connectors.py +0 -0
  42. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/context.py +0 -0
  43. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/credentials.py +0 -0
  44. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/flows.py +0 -0
  45. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/mcp_servers.py +0 -0
  46. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/metrics.py +0 -0
  47. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/notifications.py +0 -0
  48. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/orgs.py +0 -0
  49. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/probe.py +0 -0
  50. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/tools.py +0 -0
  51. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/toolsets.py +0 -0
  52. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/transforms.py +0 -0
  53. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/resources/users.py +0 -0
  54. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/sanitize.py +0 -0
  55. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/schema.py +0 -0
  56. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/src/nexla_cli/validate.py +0 -0
  57. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/__init__.py +0 -0
  58. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/conftest.py +0 -0
  59. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_app.py +0 -0
  60. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_client.py +0 -0
  61. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_connectors.py +0 -0
  62. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_context.py +0 -0
  63. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_credentials.py +0 -0
  64. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_dryrun.py +0 -0
  65. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_errors.py +0 -0
  66. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_flows.py +0 -0
  67. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_login.py +0 -0
  68. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_mcp_servers.py +0 -0
  69. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_openapi_client.py +0 -0
  70. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_orgs.py +0 -0
  71. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_output.py +0 -0
  72. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_packaging.py +0 -0
  73. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_probe.py +0 -0
  74. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_sanitize.py +0 -0
  75. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_schema.py +0 -0
  76. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_stub_resources.py +0 -0
  77. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_tools.py +0 -0
  78. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_toolsets.py +0 -0
  79. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_transforms.py +0 -0
  80. {nexla_cli-0.2.0 → nexla_cli-0.2.1}/tests/test_triage.py +0 -0
  81. {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 (project nexla-cli -> Settings -> Publishing -> add this
24
- # repo + workflow filename + "pypi" environment as a trusted
25
- # publisher) -- see README.md's "Publishing to PyPI" section. No
26
- # secret/token needed here.
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
- - run: >
27
- pyinstaller --onefile --name ${{ matrix.asset }}
28
- scripts/pyinstaller_entry.py
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.0
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 (published to GitHub Releases by
56
- `.github/workflows/release-binaries.yml`) behind a thin `npm/` wrapper — no
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 pip package isn't picked up automatically. Symlink it in once, after installing the CLI. `pipx`/`uv tool install` both use an isolated venv, so a plain system `python3 -c "import nexla_cli"` won't find it — locate it inside that isolated venv instead:
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
- mkdir -p ~/.claude/skills/nexla-cli
115
-
116
- # if installed with `uv tool install`:
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
- A symlink (not a copy) means `nexla-cli` upgrades automatically pick up any future skill content updates. Restart Claude Code (or start a new session) after installing for it to be picked up.
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 (published to GitHub Releases by
27
- `.github/workflows/release-binaries.yml`) behind a thin `npm/` wrapper — no
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 pip package isn't picked up automatically. Symlink it in once, after installing the CLI. `pipx`/`uv tool install` both use an isolated venv, so a plain system `python3 -c "import nexla_cli"` won't find it — locate it inside that isolated venv instead:
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
- mkdir -p ~/.claude/skills/nexla-cli
86
-
87
- # if installed with `uv tool install`:
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
- A symlink (not a copy) means `nexla-cli` upgrades automatically pick up any future skill content updates. Restart Claude Code (or start a new session) after installing for it to be picked up.
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`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexla-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Command-line client for the Nexla agent API (native binary, no Python required)",
5
5
  "bin": {
6
6
  "nexla": "bin/nexla.js"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nexla-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Command-line client for the Nexla agent API."
5
5
  readme = "README.md"
6
6
  # Same MIT license already declared for this codebase in npm/package.json.
@@ -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 <sink_id>` and
145
- `nexla triage logs <flow_id>` will surface the JDBC error directly —
146
- see "Flow/log triage" below — instead of needing the web UI.)
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(ctx: typer.Context, nexset_id: int) -> None:
60
- """Get one nexset, including samples and schema."""
61
- output.emit(
62
- client.request("GET", f"/nexla/nexsets/{nexset_id}"),
63
- mode=output.ctx_mode(ctx),
64
- fields=output.ctx_fields(ctx),
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(ctx: typer.Context, sink_id: int) -> None:
164
- """Get one sink by id."""
165
- output.emit(
166
- client.request("GET", f"/nexla/sinks/{sink_id}"),
167
- mode=output.ctx_mode(ctx),
168
- fields=output.ctx_fields(ctx),
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(ctx: typer.Context, source_id: int) -> None:
60
- """Get one source by id."""
61
- output.emit(
62
- client.request("GET", f"/nexla/sources/{source_id}"),
63
- mode=output.ctx_mode(ctx),
64
- fields=output.ctx_fields(ctx),
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(ctx: typer.Context, flow_id: int, run_id: int, dry_run: bool = _DRY_RUN_OPT) -> None:
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 = typer.Option(...),
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 = typer.Option(...),
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
- _NON_RESOURCE_GROUPS = {"schema"}
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"}})
@@ -280,7 +280,7 @@ wheels = [
280
280
 
281
281
  [[package]]
282
282
  name = "nexla-cli"
283
- version = "0.2.0"
283
+ version = "0.2.1"
284
284
  source = { editable = "." }
285
285
  dependencies = [
286
286
  { name = "httpx" },
@@ -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