indico-cli 0.4.4__tar.gz → 0.5.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.
- indico_cli-0.5.1/.gitlab-ci.yml +23 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/PKG-INFO +9 -1
- {indico_cli-0.4.4 → indico_cli-0.5.1}/README.md +8 -0
- indico_cli-0.5.1/docs/superpowers/plans/2026-06-04-secure-config-storage.md +543 -0
- indico_cli-0.5.1/docs/superpowers/specs/2026-06-04-secure-config-storage-design.md +137 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/indico_cli/_version.py +2 -2
- {indico_cli-0.4.4 → indico_cli-0.5.1}/indico_cli/cli.py +56 -21
- indico_cli-0.5.1/indico_cli/config.py +72 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/indico_cli/indico_api.py +27 -2
- {indico_cli-0.4.4 → indico_cli-0.5.1}/indico_cli/utils.py +14 -6
- {indico_cli-0.4.4 → indico_cli-0.5.1}/skills/indico-skill/SKILL.md +4 -0
- indico_cli-0.5.1/tests/test_cli_config.py +73 -0
- indico_cli-0.5.1/tests/test_config.py +74 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/tests/test_write_commands.py +130 -0
- indico_cli-0.4.4/.gitlab-ci.yml +0 -50
- {indico_cli-0.4.4 → indico_cli-0.5.1}/.gitignore +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/CLAUDE.md +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/LICENSE +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/docs/superpowers/plans/2026-03-24-search-results-and-error-messages.md +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/docs/superpowers/specs/2026-03-24-search-results-and-error-messages-design.md +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/indico_cli/__init__.py +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/pyproject.toml +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/tests/__init__.py +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/tests/test_error_messages.py +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/tests/test_format_search_result.py +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/tests/test_get_files.py +0 -0
- {indico_cli-0.4.4 → indico_cli-0.5.1}/uv.lock +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
stages:
|
|
2
|
+
- publish
|
|
3
|
+
|
|
4
|
+
# Publish to PyPI on git tag. The acc-py index (acc-py-repo.cern.ch) is a Nexus
|
|
5
|
+
# group that proxies PyPI, so it serves this package from PyPI automatically
|
|
6
|
+
# once its metadata cache refreshes. Publishing to the acc-py hosted repo is not
|
|
7
|
+
# possible for a package that exists on PyPI (Nexus returns 409 Conflict), so
|
|
8
|
+
# PyPI is the single source of truth.
|
|
9
|
+
publish-pypi:
|
|
10
|
+
stage: publish
|
|
11
|
+
image: python:3.12-slim
|
|
12
|
+
rules:
|
|
13
|
+
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
|
|
14
|
+
script:
|
|
15
|
+
- apt-get update && apt-get install -y git
|
|
16
|
+
- git fetch --tags
|
|
17
|
+
- pip install build twine
|
|
18
|
+
- python -m build
|
|
19
|
+
- twine upload --skip-existing dist/*
|
|
20
|
+
variables:
|
|
21
|
+
TWINE_USERNAME: __token__
|
|
22
|
+
TWINE_PASSWORD: $PYPI_API_TOKEN
|
|
23
|
+
GIT_DEPTH: 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: indico-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: CLI tool for interacting with Indico event management systems
|
|
5
5
|
Project-URL: Repository, https://gitlab.cern.ch/cern-agent-skills/indico-cli
|
|
6
6
|
License: MIT
|
|
@@ -50,6 +50,11 @@ Or use the `configure` command to save credentials:
|
|
|
50
50
|
indico-cli configure --base-url https://indico.cern.ch --token indp_...
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
Saved credentials live in `$XDG_CONFIG_HOME/indico-cli/config.json` (or
|
|
54
|
+
`~/.config/indico-cli/config.json` when `XDG_CONFIG_HOME` is unset), written with
|
|
55
|
+
owner-only permissions (`0600`). Inspect or reset them with `indico-cli config
|
|
56
|
+
show`, `indico-cli config path`, and `indico-cli config clear`.
|
|
57
|
+
|
|
53
58
|
API tokens can be generated at `<your-indico-url>/user/preferences/api`. Required scopes: `read:legacy_api`, `write:legacy_api` (for create operations), `read:user` (optional).
|
|
54
59
|
|
|
55
60
|
## Usage
|
|
@@ -117,6 +122,9 @@ indico-cli create-event-with-agenda --title "Workshop" --category-id 4648 \
|
|
|
117
122
|
| Command | Description |
|
|
118
123
|
|---------|-------------|
|
|
119
124
|
| `configure` | Save Indico base URL and API token, and verify the connection. |
|
|
125
|
+
| `config show` | Show the saved base URL and a masked token, plus the config file path. |
|
|
126
|
+
| `config path` | Print the path to the config file. |
|
|
127
|
+
| `config clear` | Delete the saved configuration (use `--yes` to skip the prompt). |
|
|
120
128
|
| `user-info` | Show the current user's name, email, and admin status. |
|
|
121
129
|
| `search-events` | List events in a category, with optional date range and public-only filter. |
|
|
122
130
|
| `search-by-term` | Full-text search across categories, events, contributions, attachments, and notes. |
|
|
@@ -27,6 +27,11 @@ Or use the `configure` command to save credentials:
|
|
|
27
27
|
indico-cli configure --base-url https://indico.cern.ch --token indp_...
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Saved credentials live in `$XDG_CONFIG_HOME/indico-cli/config.json` (or
|
|
31
|
+
`~/.config/indico-cli/config.json` when `XDG_CONFIG_HOME` is unset), written with
|
|
32
|
+
owner-only permissions (`0600`). Inspect or reset them with `indico-cli config
|
|
33
|
+
show`, `indico-cli config path`, and `indico-cli config clear`.
|
|
34
|
+
|
|
30
35
|
API tokens can be generated at `<your-indico-url>/user/preferences/api`. Required scopes: `read:legacy_api`, `write:legacy_api` (for create operations), `read:user` (optional).
|
|
31
36
|
|
|
32
37
|
## Usage
|
|
@@ -94,6 +99,9 @@ indico-cli create-event-with-agenda --title "Workshop" --category-id 4648 \
|
|
|
94
99
|
| Command | Description |
|
|
95
100
|
|---------|-------------|
|
|
96
101
|
| `configure` | Save Indico base URL and API token, and verify the connection. |
|
|
102
|
+
| `config show` | Show the saved base URL and a masked token, plus the config file path. |
|
|
103
|
+
| `config path` | Print the path to the config file. |
|
|
104
|
+
| `config clear` | Delete the saved configuration (use `--yes` to skip the prompt). |
|
|
97
105
|
| `user-info` | Show the current user's name, email, and admin status. |
|
|
98
106
|
| `search-events` | List events in a category, with optional date range and public-only filter. |
|
|
99
107
|
| `search-by-term` | Full-text search across categories, events, contributions, attachments, and notes. |
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
# Secure Config Storage + `config` Commands Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Store the Indico token+URL in a generic config file with owner-only permissions, and add `config show/path/clear` commands to inspect and manage it.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Extract on-disk config persistence from `cli.py` into a new focused module `indico_cli/config.py` that honors `XDG_CONFIG_HOME` and writes the file as `0600` (dir `0700`). `cli.py` imports from it and gains a `config` Click group. Credential resolution (args -> env -> file) is unchanged.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Python 3.12, Click 8, pytest, Click `CliRunner` for CLI tests.
|
|
10
|
+
|
|
11
|
+
**Linting:** Before every Python commit, run `uvx ruff check indico_cli tests` and fix anything it reports. The repo is not typed (no `[tool.mypy]`, no type-checking config), so mypy is skipped. Docs-only commits (Task 4) need no lint.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
- **Create** `indico_cli/config.py` — on-disk credential config: path resolution, load/save (with permission hardening), clear, token masking. Single responsibility.
|
|
18
|
+
- **Create** `tests/test_config.py` — unit tests for `config.py` (tmp `XDG_CONFIG_HOME`).
|
|
19
|
+
- **Create** `tests/test_cli_config.py` — Click `CliRunner` tests for the `config` group.
|
|
20
|
+
- **Modify** `indico_cli/cli.py` — import from `config.py`, delete the inline config code, add the `config` command group.
|
|
21
|
+
- **Modify** `README.md` — document the `config` commands and file permissions.
|
|
22
|
+
- **Modify** `skills/indico-skill/SKILL.md` — mention `config show/path/clear`.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Task 1: Create the `config` module (with tests)
|
|
27
|
+
|
|
28
|
+
**Files:**
|
|
29
|
+
- Create: `indico_cli/config.py`
|
|
30
|
+
- Test: `tests/test_config.py`
|
|
31
|
+
|
|
32
|
+
- [ ] **Step 1: Write the failing tests**
|
|
33
|
+
|
|
34
|
+
Create `tests/test_config.py`:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
"""Unit tests for indico_cli.config on-disk credential storage."""
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
import stat
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import pytest
|
|
44
|
+
|
|
45
|
+
from indico_cli import config
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def tmp_xdg(tmp_path, monkeypatch):
|
|
50
|
+
"""Point config storage at a temp XDG_CONFIG_HOME."""
|
|
51
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
52
|
+
return tmp_path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_get_config_path_honors_xdg(tmp_xdg):
|
|
56
|
+
assert config.get_config_path() == tmp_xdg / "indico-cli" / "config.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_get_config_path_defaults_to_home(monkeypatch):
|
|
60
|
+
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
61
|
+
assert config.get_config_path() == Path.home() / ".config" / "indico-cli" / "config.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_save_then_load_roundtrips(tmp_xdg):
|
|
65
|
+
config.save_config("https://indico.cern.ch", "indp_secrettoken")
|
|
66
|
+
assert config.load_config() == {
|
|
67
|
+
"base_url": "https://indico.cern.ch",
|
|
68
|
+
"token": "indp_secrettoken",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.skipif(os.name == "nt", reason="POSIX permissions only")
|
|
73
|
+
def test_save_sets_owner_only_permissions(tmp_xdg):
|
|
74
|
+
config.save_config("https://indico.cern.ch", "indp_secrettoken")
|
|
75
|
+
config_file = config.get_config_path()
|
|
76
|
+
assert stat.S_IMODE(config_file.stat().st_mode) == 0o600
|
|
77
|
+
assert stat.S_IMODE(config_file.parent.stat().st_mode) == 0o700
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_load_returns_empty_when_missing(tmp_xdg):
|
|
81
|
+
assert config.load_config() == {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_load_returns_empty_on_corrupt_json(tmp_xdg):
|
|
85
|
+
config_file = config.get_config_path()
|
|
86
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
config_file.write_text("{not valid json")
|
|
88
|
+
assert config.load_config() == {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_clear_removes_file_and_returns_true(tmp_xdg):
|
|
92
|
+
config.save_config("https://indico.cern.ch", "indp_secrettoken")
|
|
93
|
+
assert config.clear_config() is True
|
|
94
|
+
assert not config.get_config_path().exists()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_clear_returns_false_when_nothing(tmp_xdg):
|
|
98
|
+
assert config.clear_config() is False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_mask_token_empty():
|
|
102
|
+
assert config.mask_token("") == "(none)"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_mask_token_short():
|
|
106
|
+
assert config.mask_token("indp_123") == "****"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_mask_token_normal():
|
|
110
|
+
assert config.mask_token("indp_abcdefgh1234") == "indp_...1234"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
114
|
+
|
|
115
|
+
Run: `uv run pytest tests/test_config.py -v`
|
|
116
|
+
Expected: FAIL — `ModuleNotFoundError: No module named 'indico_cli.config'` (or `AttributeError`).
|
|
117
|
+
|
|
118
|
+
- [ ] **Step 3: Write the module**
|
|
119
|
+
|
|
120
|
+
Create `indico_cli/config.py`:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
#!/usr/bin/env python3
|
|
124
|
+
"""On-disk credential configuration for indico-cli.
|
|
125
|
+
|
|
126
|
+
Stores the Indico base URL and API token in a generic config file
|
|
127
|
+
(``$XDG_CONFIG_HOME/indico-cli/config.json`` or
|
|
128
|
+
``~/.config/indico-cli/config.json``). The file holds a token, so it is written
|
|
129
|
+
with owner-only permissions (0600).
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
import json
|
|
133
|
+
import os
|
|
134
|
+
from pathlib import Path
|
|
135
|
+
|
|
136
|
+
APP_DIR_NAME = "indico-cli"
|
|
137
|
+
CONFIG_FILENAME = "config.json"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_config_path() -> Path:
|
|
141
|
+
"""Return the config file path, honoring XDG_CONFIG_HOME.
|
|
142
|
+
|
|
143
|
+
Computed on each call so runtime/test environment changes are respected.
|
|
144
|
+
Falls back to ~/.config when XDG_CONFIG_HOME is unset (backward compatible).
|
|
145
|
+
"""
|
|
146
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
147
|
+
config_home = Path(base) if base else Path.home() / ".config"
|
|
148
|
+
return config_home / APP_DIR_NAME / CONFIG_FILENAME
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_config() -> dict:
|
|
152
|
+
"""Load saved configuration, returning an empty dict on any failure."""
|
|
153
|
+
config_file = get_config_path()
|
|
154
|
+
if config_file.exists():
|
|
155
|
+
try:
|
|
156
|
+
return json.loads(config_file.read_text())
|
|
157
|
+
except (json.JSONDecodeError, OSError):
|
|
158
|
+
return {}
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def save_config(base_url: str, token: str) -> None:
|
|
163
|
+
"""Persist configuration with owner-only permissions (dir 0700, file 0600)."""
|
|
164
|
+
config_file = get_config_path()
|
|
165
|
+
config_dir = config_file.parent
|
|
166
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
config_file.write_text(
|
|
168
|
+
json.dumps({"base_url": base_url, "token": token}, indent=2)
|
|
169
|
+
)
|
|
170
|
+
# Best-effort permission hardening. A filesystem without POSIX permission
|
|
171
|
+
# bits (e.g. some Windows setups) must not crash the save.
|
|
172
|
+
try:
|
|
173
|
+
os.chmod(config_dir, 0o700)
|
|
174
|
+
os.chmod(config_file, 0o600)
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def clear_config() -> bool:
|
|
180
|
+
"""Delete the config file. Return True if a file was removed."""
|
|
181
|
+
config_file = get_config_path()
|
|
182
|
+
if config_file.exists():
|
|
183
|
+
config_file.unlink()
|
|
184
|
+
return True
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def mask_token(token: str) -> str:
|
|
189
|
+
"""Return a masked representation of a token, safe for display."""
|
|
190
|
+
if not token:
|
|
191
|
+
return "(none)"
|
|
192
|
+
if len(token) <= 8:
|
|
193
|
+
return "****"
|
|
194
|
+
return f"{token[:5]}...{token[-4:]}"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
198
|
+
|
|
199
|
+
Run: `uv run pytest tests/test_config.py -v`
|
|
200
|
+
Expected: PASS (all tests; the permissions test is skipped only on Windows).
|
|
201
|
+
|
|
202
|
+
- [ ] **Step 5: Lint, then commit**
|
|
203
|
+
|
|
204
|
+
Run: `uvx ruff check indico_cli tests` (fix any reported issues, then re-run until clean).
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
git add indico_cli/config.py tests/test_config.py
|
|
208
|
+
git commit -m "feat: add config module with XDG path and 0600 permissions"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Task 2: Wire `cli.py` to the config module
|
|
214
|
+
|
|
215
|
+
Replace the inline config code in `cli.py` with imports from `indico_cli.config`, keeping `configure` and `get_client` behavior identical. This is a refactor verified by the existing test suite.
|
|
216
|
+
|
|
217
|
+
**Files:**
|
|
218
|
+
- Modify: `indico_cli/cli.py` (lines ~8, 15-32, 94-95)
|
|
219
|
+
|
|
220
|
+
- [ ] **Step 1: Remove the now-unused `pathlib` import**
|
|
221
|
+
|
|
222
|
+
In `indico_cli/cli.py`, delete this line (Task 1's module owns path handling now; `click.Path` is unrelated):
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from pathlib import Path
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- [ ] **Step 2: Replace the inline config block with an import**
|
|
229
|
+
|
|
230
|
+
Delete this entire block (currently lines 15-32):
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
CONFIG_DIR = Path.home() / ".config" / "indico-cli"
|
|
234
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def load_config() -> dict:
|
|
238
|
+
"""Load saved configuration from disk, returning empty dict on failure."""
|
|
239
|
+
if CONFIG_FILE.exists():
|
|
240
|
+
try:
|
|
241
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
242
|
+
except (json.JSONDecodeError, OSError):
|
|
243
|
+
return {}
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def save_config(base_url: str, token: str) -> None:
|
|
248
|
+
"""Persist configuration to ~/.config/indico-cli/config.json."""
|
|
249
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
CONFIG_FILE.write_text(json.dumps({"base_url": base_url, "token": token}, indent=2))
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Replace it with this import (place it just below `from indico_cli.indico_api import IndicoAPI, IndicoClient`):
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from indico_cli.config import (
|
|
257
|
+
clear_config,
|
|
258
|
+
get_config_path,
|
|
259
|
+
load_config,
|
|
260
|
+
mask_token,
|
|
261
|
+
save_config,
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- [ ] **Step 3: Update the `configure` command's saved-path message**
|
|
266
|
+
|
|
267
|
+
In the `configure` command, replace:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
save_config(base_url, token)
|
|
271
|
+
click.echo(f"Configuration saved to {CONFIG_FILE}")
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
with:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
save_config(base_url, token)
|
|
278
|
+
click.echo(f"Configuration saved to {get_config_path()}")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- [ ] **Step 4: Run the full test suite**
|
|
282
|
+
|
|
283
|
+
Run: `uv run pytest -v`
|
|
284
|
+
Expected: PASS — all existing tests plus `tests/test_config.py`. No import errors.
|
|
285
|
+
|
|
286
|
+
- [ ] **Step 5: Smoke-test the CLI loads**
|
|
287
|
+
|
|
288
|
+
Run: `uv run indico-cli --help`
|
|
289
|
+
Expected: the help text lists existing commands and exits 0 (no traceback).
|
|
290
|
+
|
|
291
|
+
- [ ] **Step 6: Lint, then commit**
|
|
292
|
+
|
|
293
|
+
Run: `uvx ruff check indico_cli tests` (fix any reported issues, then re-run until clean).
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
git add indico_cli/cli.py
|
|
297
|
+
git commit -m "refactor: use indico_cli.config for credential persistence"
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Task 3: Add the `config` command group (with tests)
|
|
303
|
+
|
|
304
|
+
**Files:**
|
|
305
|
+
- Modify: `indico_cli/cli.py` (add group after the `configure` command, before `user-info`)
|
|
306
|
+
- Test: `tests/test_cli_config.py`
|
|
307
|
+
|
|
308
|
+
- [ ] **Step 1: Write the failing CLI tests**
|
|
309
|
+
|
|
310
|
+
Create `tests/test_cli_config.py`:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
"""CLI tests for the `config` command group."""
|
|
314
|
+
|
|
315
|
+
import json
|
|
316
|
+
|
|
317
|
+
import pytest
|
|
318
|
+
from click.testing import CliRunner
|
|
319
|
+
|
|
320
|
+
from indico_cli.cli import cli
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@pytest.fixture
|
|
324
|
+
def tmp_xdg(tmp_path, monkeypatch):
|
|
325
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
326
|
+
monkeypatch.delenv("INDICO_BASE_URL", raising=False)
|
|
327
|
+
monkeypatch.delenv("INDICO_API_TOKEN", raising=False)
|
|
328
|
+
return tmp_path
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _write_config(tmp_path, base_url="https://indico.cern.ch", token="indp_abcdefgh1234"):
|
|
332
|
+
config_file = tmp_path / "indico-cli" / "config.json"
|
|
333
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
config_file.write_text(json.dumps({"base_url": base_url, "token": token}))
|
|
335
|
+
return config_file
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_config_show_masks_token(tmp_xdg):
|
|
339
|
+
_write_config(tmp_xdg)
|
|
340
|
+
result = CliRunner().invoke(cli, ["config", "show"])
|
|
341
|
+
assert result.exit_code == 0
|
|
342
|
+
assert "indp_...1234" in result.output
|
|
343
|
+
assert "indp_abcdefgh1234" not in result.output
|
|
344
|
+
assert "https://indico.cern.ch" in result.output
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_config_show_no_config(tmp_xdg):
|
|
348
|
+
result = CliRunner().invoke(cli, ["config", "show"])
|
|
349
|
+
assert result.exit_code == 0
|
|
350
|
+
assert "No saved configuration" in result.output
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_config_show_notes_env_override(tmp_xdg, monkeypatch):
|
|
354
|
+
_write_config(tmp_xdg)
|
|
355
|
+
monkeypatch.setenv("INDICO_API_TOKEN", "indp_envtoken")
|
|
356
|
+
result = CliRunner().invoke(cli, ["config", "show"])
|
|
357
|
+
assert result.exit_code == 0
|
|
358
|
+
assert "INDICO_API_TOKEN" in result.output
|
|
359
|
+
assert "override" in result.output
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_config_path_prints_path(tmp_xdg):
|
|
363
|
+
result = CliRunner().invoke(cli, ["config", "path"])
|
|
364
|
+
assert result.exit_code == 0
|
|
365
|
+
assert str(tmp_xdg / "indico-cli" / "config.json") in result.output
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_config_clear_with_yes(tmp_xdg):
|
|
369
|
+
_write_config(tmp_xdg)
|
|
370
|
+
result = CliRunner().invoke(cli, ["config", "clear", "--yes"])
|
|
371
|
+
assert result.exit_code == 0
|
|
372
|
+
assert not (tmp_xdg / "indico-cli" / "config.json").exists()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_config_clear_nothing(tmp_xdg):
|
|
376
|
+
result = CliRunner().invoke(cli, ["config", "clear", "--yes"])
|
|
377
|
+
assert result.exit_code == 0
|
|
378
|
+
assert "Nothing to clear" in result.output
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_config_clear_aborts_without_confirmation(tmp_xdg):
|
|
382
|
+
_write_config(tmp_xdg)
|
|
383
|
+
result = CliRunner().invoke(cli, ["config", "clear"], input="n\n")
|
|
384
|
+
assert result.exit_code != 0 # click confirm abort -> non-zero exit
|
|
385
|
+
assert (tmp_xdg / "indico-cli" / "config.json").exists()
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
389
|
+
|
|
390
|
+
Run: `uv run pytest tests/test_cli_config.py -v`
|
|
391
|
+
Expected: FAIL — `config` is not a command (Click reports "No such command 'config'").
|
|
392
|
+
|
|
393
|
+
- [ ] **Step 3: Expand the config import, then add the `config` group to `cli.py`**
|
|
394
|
+
|
|
395
|
+
First, widen the existing import line in `indico_cli/cli.py` (Task 2 left it importing only the three names it used) to also bring in `clear_config` and `mask_token`:
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from indico_cli.config import (
|
|
399
|
+
clear_config,
|
|
400
|
+
get_config_path,
|
|
401
|
+
load_config,
|
|
402
|
+
mask_token,
|
|
403
|
+
save_config,
|
|
404
|
+
)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Then insert this block immediately after the `configure` command function (after its final line, before the `user-info` command). It uses `load_config`, `get_config_path`, `clear_config`, `mask_token` and the already-imported `os`/`click`:
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
@cli.group("config")
|
|
411
|
+
def config_group():
|
|
412
|
+
"""Inspect and manage saved Indico CLI configuration."""
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@config_group.command("show")
|
|
416
|
+
def config_show():
|
|
417
|
+
"""Show saved configuration (token masked)."""
|
|
418
|
+
cfg = load_config()
|
|
419
|
+
path = get_config_path()
|
|
420
|
+
if not cfg:
|
|
421
|
+
click.echo(f"No saved configuration at {path}")
|
|
422
|
+
else:
|
|
423
|
+
click.echo(f"Config file: {path}")
|
|
424
|
+
click.echo(f"Base URL: {cfg.get('base_url', '(none)')}")
|
|
425
|
+
click.echo(f"Token: {mask_token(cfg.get('token', ''))}")
|
|
426
|
+
overrides = [
|
|
427
|
+
name for name in ("INDICO_BASE_URL", "INDICO_API_TOKEN") if os.getenv(name)
|
|
428
|
+
]
|
|
429
|
+
if overrides:
|
|
430
|
+
click.echo(
|
|
431
|
+
f"Note: {' and '.join(overrides)} set in environment; "
|
|
432
|
+
"these override the config file."
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@config_group.command("path")
|
|
437
|
+
def config_path():
|
|
438
|
+
"""Print the path to the config file."""
|
|
439
|
+
path = get_config_path()
|
|
440
|
+
state = "exists" if path.exists() else "does not exist"
|
|
441
|
+
click.echo(f"{path} ({state})")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@config_group.command("clear")
|
|
445
|
+
@click.option("--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt")
|
|
446
|
+
def config_clear(yes):
|
|
447
|
+
"""Delete the saved configuration file."""
|
|
448
|
+
path = get_config_path()
|
|
449
|
+
if not path.exists():
|
|
450
|
+
click.echo("Nothing to clear")
|
|
451
|
+
return
|
|
452
|
+
if not yes:
|
|
453
|
+
click.confirm(f"Delete saved configuration at {path}?", abort=True)
|
|
454
|
+
clear_config()
|
|
455
|
+
click.echo(f"Removed {path}")
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
- [ ] **Step 4: Run the CLI tests to verify they pass**
|
|
459
|
+
|
|
460
|
+
Run: `uv run pytest tests/test_cli_config.py -v`
|
|
461
|
+
Expected: PASS (all 7 tests).
|
|
462
|
+
|
|
463
|
+
- [ ] **Step 5: Run the full suite**
|
|
464
|
+
|
|
465
|
+
Run: `uv run pytest -v`
|
|
466
|
+
Expected: PASS — everything green.
|
|
467
|
+
|
|
468
|
+
- [ ] **Step 6: Lint, then commit**
|
|
469
|
+
|
|
470
|
+
Run: `uvx ruff check indico_cli tests` (fix any reported issues, then re-run until clean).
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
git add indico_cli/cli.py tests/test_cli_config.py
|
|
474
|
+
git commit -m "feat: add config show/path/clear commands"
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Task 4: Documentation
|
|
480
|
+
|
|
481
|
+
**Files:**
|
|
482
|
+
- Modify: `README.md`
|
|
483
|
+
- Modify: `skills/indico-skill/SKILL.md`
|
|
484
|
+
|
|
485
|
+
- [ ] **Step 1: Update the README commands table**
|
|
486
|
+
|
|
487
|
+
In `README.md`, in the "All Commands" table, add these three rows immediately after the `configure` row:
|
|
488
|
+
|
|
489
|
+
```markdown
|
|
490
|
+
| `config show` | Show the saved base URL and a masked token, plus the config file path. |
|
|
491
|
+
| `config path` | Print the path to the config file. |
|
|
492
|
+
| `config clear` | Delete the saved configuration (use `--yes` to skip the prompt). |
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
- [ ] **Step 2: Add a permissions/location note to the README Configuration section**
|
|
496
|
+
|
|
497
|
+
In `README.md`, append this paragraph to the "Configuration" section (after the `configure` example block, before the "API tokens can be generated..." line):
|
|
498
|
+
|
|
499
|
+
```markdown
|
|
500
|
+
Saved credentials live in `$XDG_CONFIG_HOME/indico-cli/config.json` (or
|
|
501
|
+
`~/.config/indico-cli/config.json` when `XDG_CONFIG_HOME` is unset), written with
|
|
502
|
+
owner-only permissions (`0600`). Inspect or reset them with `indico-cli config
|
|
503
|
+
show`, `indico-cli config path`, and `indico-cli config clear`.
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
- [ ] **Step 3: Update SKILL.md Configure section**
|
|
507
|
+
|
|
508
|
+
In `skills/indico-skill/SKILL.md`, immediately after the line
|
|
509
|
+
`Environment variables \`INDICO_BASE_URL\` and \`INDICO_API_TOKEN\` override the config file.`,
|
|
510
|
+
add:
|
|
511
|
+
|
|
512
|
+
```markdown
|
|
513
|
+
|
|
514
|
+
Inspect or reset saved credentials with `uvx indico-cli config show` (token
|
|
515
|
+
masked), `uvx indico-cli config path`, or `uvx indico-cli config clear`. The
|
|
516
|
+
config file is written with `0600` permissions.
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
- [ ] **Step 4: Commit**
|
|
520
|
+
|
|
521
|
+
```bash
|
|
522
|
+
git add README.md skills/indico-skill/SKILL.md
|
|
523
|
+
git commit -m "docs: document config show/path/clear and file permissions"
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
528
|
+
## Self-Review
|
|
529
|
+
|
|
530
|
+
**Spec coverage:**
|
|
531
|
+
- Harden permissions (dir 0700, file 0600) -> Task 1 `save_config` + permission test.
|
|
532
|
+
- `config` group `show`/`path`/`clear` -> Task 3.
|
|
533
|
+
- Extract dedicated module -> Task 1 (`config.py`) + Task 2 (wire-in).
|
|
534
|
+
- Honor `XDG_CONFIG_HOME` -> Task 1 `get_config_path` + test.
|
|
535
|
+
- `mask_token` format -> Task 1 + tests.
|
|
536
|
+
- `config show` env-override note -> Task 3 + `test_config_show_notes_env_override`.
|
|
537
|
+
- No-file / nothing-to-clear exit-0 states -> Task 3 + tests.
|
|
538
|
+
- Docs (README + SKILL.md) -> Task 4.
|
|
539
|
+
- Resolution unchanged (args -> env -> file) -> Task 2 verified by full suite.
|
|
540
|
+
|
|
541
|
+
**Placeholder scan:** none — every code/test step shows complete content.
|
|
542
|
+
|
|
543
|
+
**Type/name consistency:** `get_config_path`, `load_config`, `save_config`, `clear_config`, `mask_token` are defined in Task 1 and used with identical names in Tasks 2-3. Click group function `config_group` (command name `config`) does not shadow the imported names. `--yes/-y` flag matches between command and test.
|