indico-cli 0.4.3__tar.gz → 0.5.0__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.0/.gitlab-ci.yml +23 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/PKG-INFO +9 -1
- {indico_cli-0.4.3 → indico_cli-0.5.0}/README.md +8 -0
- indico_cli-0.5.0/docs/superpowers/plans/2026-06-04-secure-config-storage.md +543 -0
- indico_cli-0.5.0/docs/superpowers/specs/2026-06-04-secure-config-storage-design.md +137 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/_version.py +2 -2
- {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/cli.py +56 -21
- indico_cli-0.5.0/indico_cli/config.py +72 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/skills/indico-skill/SKILL.md +4 -0
- indico_cli-0.5.0/tests/test_cli_config.py +73 -0
- indico_cli-0.5.0/tests/test_config.py +74 -0
- indico_cli-0.4.3/.gitlab-ci.yml +0 -18
- {indico_cli-0.4.3 → indico_cli-0.5.0}/.gitignore +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/CLAUDE.md +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/LICENSE +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/docs/superpowers/plans/2026-03-24-search-results-and-error-messages.md +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/docs/superpowers/specs/2026-03-24-search-results-and-error-messages-design.md +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/__init__.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/indico_api.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/utils.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/pyproject.toml +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/__init__.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_error_messages.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_format_search_result.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_get_files.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_write_commands.py +0 -0
- {indico_cli-0.4.3 → indico_cli-0.5.0}/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.0
|
|
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.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Secure config storage + `config` management commands
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-04
|
|
4
|
+
**Status:** Approved (design)
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
`indico-cli` already persists credentials to a generic config file
|
|
9
|
+
(`~/.config/indico-cli/config.json`) via the `configure` command, and resolves
|
|
10
|
+
credentials in the order CLI args -> env vars -> config file. Two gaps remain:
|
|
11
|
+
|
|
12
|
+
1. **Security:** the file is written with the default umask (typically `0644`,
|
|
13
|
+
world-readable). A plaintext API token should not sit world-readable.
|
|
14
|
+
2. **No management surface:** there is no way to inspect what is stored, find the
|
|
15
|
+
file path, or reset the saved config without manually opening the file.
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
In scope:
|
|
20
|
+
1. Harden permissions of the saved config (dir `0700`, file `0600`).
|
|
21
|
+
2. Add a `config` command group: `show`, `path`, `clear`.
|
|
22
|
+
3. Extract config persistence into a dedicated, unit-testable module.
|
|
23
|
+
4. Honor `XDG_CONFIG_HOME` for the config location (backward compatible).
|
|
24
|
+
|
|
25
|
+
Out of scope (explicitly deferred):
|
|
26
|
+
- `.env` auto-loading (no `python-dotenv`).
|
|
27
|
+
- Named profiles / multiple Indico instances.
|
|
28
|
+
- Storing additional defaults (category-id, download dir, timezone).
|
|
29
|
+
|
|
30
|
+
## Design
|
|
31
|
+
|
|
32
|
+
### 1. New module `indico_cli/config.py`
|
|
33
|
+
|
|
34
|
+
Today `CONFIG_DIR`, `CONFIG_FILE`, `load_config`, and `save_config` live inside
|
|
35
|
+
`cli.py`. Move them into a focused module whose single purpose is "manage the
|
|
36
|
+
on-disk credential config." This keeps `cli.py` about command wiring, follows the
|
|
37
|
+
"use util modules, do not repeat code" rule, and makes the logic testable in
|
|
38
|
+
isolation. (`utils.py` is the wrong home: it builds Indico API request payloads.)
|
|
39
|
+
|
|
40
|
+
Public functions:
|
|
41
|
+
|
|
42
|
+
- `get_config_path() -> Path`
|
|
43
|
+
Computed on each call:
|
|
44
|
+
`Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "indico-cli" / "config.json"`.
|
|
45
|
+
Reading the environment per call (not at import) keeps it backward compatible
|
|
46
|
+
(unset -> today's `~/.config/...`) and makes tests trivial via a tmp
|
|
47
|
+
`XDG_CONFIG_HOME`.
|
|
48
|
+
|
|
49
|
+
- `load_config() -> dict`
|
|
50
|
+
Unchanged behavior: returns the parsed dict, or `{}` on missing file, JSON
|
|
51
|
+
decode error, or `OSError`.
|
|
52
|
+
|
|
53
|
+
- `save_config(base_url: str, token: str) -> None`
|
|
54
|
+
Creates the config dir, writes the JSON, then sets dir mode `0700` and file
|
|
55
|
+
mode `0600`. The permission steps are best-effort: wrapped so a filesystem that
|
|
56
|
+
does not support POSIX permissions cannot crash the save.
|
|
57
|
+
|
|
58
|
+
- `clear_config() -> bool`
|
|
59
|
+
Deletes the config file if present. Returns `True` if a file was removed,
|
|
60
|
+
`False` if there was nothing to remove.
|
|
61
|
+
|
|
62
|
+
- `mask_token(token: str) -> str`
|
|
63
|
+
- empty/`None` -> `(none)`
|
|
64
|
+
- length <= 8 -> `****`
|
|
65
|
+
- otherwise -> `<first 5 chars>...<last 4 chars>` (e.g. `indp_...a1b2`).
|
|
66
|
+
Uses literal `...`, no unicode ellipsis.
|
|
67
|
+
|
|
68
|
+
`cli.py` imports these. `configure` and `get_client` keep identical behavior and
|
|
69
|
+
gain the hardened permissions for free (both go through `save_config` /
|
|
70
|
+
`load_config`).
|
|
71
|
+
|
|
72
|
+
### 2. `config` command group
|
|
73
|
+
|
|
74
|
+
A Click group `config` with three subcommands:
|
|
75
|
+
|
|
76
|
+
- `indico-cli config show`
|
|
77
|
+
Loads the stored config and prints `base_url` and the **masked** token, plus
|
|
78
|
+
the config file path. If no config file exists: print
|
|
79
|
+
`No saved configuration at <path>` and exit 0 (this is a valid state, not an
|
|
80
|
+
error). If `INDICO_BASE_URL` or `INDICO_API_TOKEN` is set in the environment,
|
|
81
|
+
print a one-line note that env vars override the file.
|
|
82
|
+
|
|
83
|
+
- `indico-cli config path`
|
|
84
|
+
Prints the absolute config file path and whether it currently exists.
|
|
85
|
+
Script-friendly.
|
|
86
|
+
|
|
87
|
+
- `indico-cli config clear`
|
|
88
|
+
Deletes the config file. Prompts for confirmation (`Are you sure?`) unless
|
|
89
|
+
`--yes/-y` is passed. If nothing is stored: print `Nothing to clear` and
|
|
90
|
+
exit 0.
|
|
91
|
+
|
|
92
|
+
### 3. Security
|
|
93
|
+
|
|
94
|
+
The token at rest moves from `0644` (world-readable) to `0600`; the containing
|
|
95
|
+
directory to `0700`. `configure` inherits this automatically because it calls
|
|
96
|
+
`save_config`.
|
|
97
|
+
|
|
98
|
+
### 4. Testing (TDD)
|
|
99
|
+
|
|
100
|
+
`tests/test_config.py` (unit, against `config.py`, using a tmp `XDG_CONFIG_HOME`):
|
|
101
|
+
- save then load round-trips `base_url` and `token`
|
|
102
|
+
- after `save_config`, file mode is `0600` and dir mode is `0700`
|
|
103
|
+
- `clear_config` removes the file and returns `True`; returns `False` when
|
|
104
|
+
nothing is present
|
|
105
|
+
- `mask_token`: empty, short (<=8), and normal-length cases
|
|
106
|
+
- `get_config_path` honors `XDG_CONFIG_HOME` when set and falls back to
|
|
107
|
+
`~/.config` when unset
|
|
108
|
+
|
|
109
|
+
`tests/test_cli_config.py` (Click `CliRunner`):
|
|
110
|
+
- `config show`: masked token output; no-file message; env-override note when
|
|
111
|
+
env vars set
|
|
112
|
+
- `config path`: prints the path
|
|
113
|
+
- `config clear`: with `--yes` removes; without `--yes` respects confirmation;
|
|
114
|
+
nothing-to-clear message
|
|
115
|
+
|
|
116
|
+
POSIX permission assertions are guarded for non-POSIX platforms (skip if the
|
|
117
|
+
filesystem cannot represent the modes).
|
|
118
|
+
|
|
119
|
+
### 5. Docs
|
|
120
|
+
|
|
121
|
+
- `README.md`: add `config show`, `config path`, `config clear` rows to the
|
|
122
|
+
commands table; add a sentence to the Configuration section noting the `0600`
|
|
123
|
+
file permission and `XDG_CONFIG_HOME` support.
|
|
124
|
+
- `skills/indico-skill/SKILL.md`: mention `config show` / `config path` /
|
|
125
|
+
`config clear` in the Configure section.
|
|
126
|
+
|
|
127
|
+
## Acceptance criteria
|
|
128
|
+
|
|
129
|
+
- `indico-cli configure ...` writes `config.json` with mode `0600` in a dir with
|
|
130
|
+
mode `0700`.
|
|
131
|
+
- `indico-cli config show` prints the base URL and a masked token, or a clear
|
|
132
|
+
no-config message; never prints the full token.
|
|
133
|
+
- `indico-cli config path` prints the resolved path (honoring `XDG_CONFIG_HOME`).
|
|
134
|
+
- `indico-cli config clear` removes the saved config, with confirmation unless
|
|
135
|
+
`--yes`.
|
|
136
|
+
- Existing credential resolution (args -> env -> file) is unchanged.
|
|
137
|
+
- New unit and CLI tests pass; existing tests still pass.
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -5,31 +5,18 @@ import asyncio
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
8
|
from typing import Optional
|
|
10
9
|
|
|
11
10
|
import click
|
|
12
11
|
|
|
13
12
|
from indico_cli.indico_api import IndicoAPI, IndicoClient
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if CONFIG_FILE.exists():
|
|
22
|
-
try:
|
|
23
|
-
return json.loads(CONFIG_FILE.read_text())
|
|
24
|
-
except (json.JSONDecodeError, OSError):
|
|
25
|
-
return {}
|
|
26
|
-
return {}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def save_config(base_url: str, token: str) -> None:
|
|
30
|
-
"""Persist configuration to ~/.config/indico-cli/config.json."""
|
|
31
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
32
|
-
CONFIG_FILE.write_text(json.dumps({"base_url": base_url, "token": token}, indent=2))
|
|
13
|
+
from indico_cli.config import (
|
|
14
|
+
clear_config,
|
|
15
|
+
get_config_path,
|
|
16
|
+
load_config,
|
|
17
|
+
mask_token,
|
|
18
|
+
save_config,
|
|
19
|
+
)
|
|
33
20
|
|
|
34
21
|
|
|
35
22
|
def get_client(base_url: Optional[str], token: Optional[str]) -> IndicoClient:
|
|
@@ -92,13 +79,61 @@ def configure(base_url, token):
|
|
|
92
79
|
click.echo("Connection successful!")
|
|
93
80
|
click.echo(result)
|
|
94
81
|
save_config(base_url, token)
|
|
95
|
-
click.echo(f"Configuration saved to {
|
|
82
|
+
click.echo(f"Configuration saved to {get_config_path()}")
|
|
96
83
|
except Exception as error:
|
|
97
84
|
click.echo(f"Connection test failed: {error}", err=True)
|
|
98
85
|
click.echo("Check that your token starts with 'indp_' and has the correct scopes.", err=True)
|
|
99
86
|
sys.exit(1)
|
|
100
87
|
|
|
101
88
|
|
|
89
|
+
@cli.group("config")
|
|
90
|
+
def config_group():
|
|
91
|
+
"""Inspect and manage saved Indico CLI configuration."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@config_group.command("show")
|
|
95
|
+
def config_show():
|
|
96
|
+
"""Show saved configuration (token masked)."""
|
|
97
|
+
cfg = load_config()
|
|
98
|
+
path = get_config_path()
|
|
99
|
+
if not cfg:
|
|
100
|
+
click.echo(f"No saved configuration at {path}")
|
|
101
|
+
else:
|
|
102
|
+
click.echo(f"Config file: {path}")
|
|
103
|
+
click.echo(f"Base URL: {cfg.get('base_url', '(none)')}")
|
|
104
|
+
click.echo(f"Token: {mask_token(cfg.get('token', ''))}")
|
|
105
|
+
overrides = [
|
|
106
|
+
name for name in ("INDICO_BASE_URL", "INDICO_API_TOKEN") if os.getenv(name)
|
|
107
|
+
]
|
|
108
|
+
if overrides:
|
|
109
|
+
click.echo(
|
|
110
|
+
f"Note: {' and '.join(overrides)} set in environment; "
|
|
111
|
+
"these override the config file."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@config_group.command("path")
|
|
116
|
+
def config_path():
|
|
117
|
+
"""Print the path to the config file."""
|
|
118
|
+
path = get_config_path()
|
|
119
|
+
state = "exists" if path.exists() else "does not exist"
|
|
120
|
+
click.echo(f"{path} ({state})")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@config_group.command("clear")
|
|
124
|
+
@click.option("--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt")
|
|
125
|
+
def config_clear(yes):
|
|
126
|
+
"""Delete the saved configuration file."""
|
|
127
|
+
path = get_config_path()
|
|
128
|
+
if not path.exists():
|
|
129
|
+
click.echo("Nothing to clear")
|
|
130
|
+
return
|
|
131
|
+
if not yes:
|
|
132
|
+
click.confirm(f"Delete saved configuration at {path}?", abort=True)
|
|
133
|
+
clear_config()
|
|
134
|
+
click.echo(f"Removed {path}")
|
|
135
|
+
|
|
136
|
+
|
|
102
137
|
@cli.command("user-info")
|
|
103
138
|
@click.pass_context
|
|
104
139
|
def user_info(ctx):
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""On-disk credential configuration for indico-cli.
|
|
3
|
+
|
|
4
|
+
Stores the Indico base URL and API token in a generic config file
|
|
5
|
+
(``$XDG_CONFIG_HOME/indico-cli/config.json`` or
|
|
6
|
+
``~/.config/indico-cli/config.json``). The file holds a token, so it is written
|
|
7
|
+
with owner-only permissions (0600).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
APP_DIR_NAME = "indico-cli"
|
|
15
|
+
CONFIG_FILENAME = "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_config_path() -> Path:
|
|
19
|
+
"""Return the config file path, honoring XDG_CONFIG_HOME.
|
|
20
|
+
|
|
21
|
+
Computed on each call so runtime/test environment changes are respected.
|
|
22
|
+
Falls back to ~/.config when XDG_CONFIG_HOME is unset (backward compatible).
|
|
23
|
+
"""
|
|
24
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
25
|
+
config_home = Path(base) if base else Path.home() / ".config"
|
|
26
|
+
return config_home / APP_DIR_NAME / CONFIG_FILENAME
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config() -> dict:
|
|
30
|
+
"""Load saved configuration, returning an empty dict on any failure."""
|
|
31
|
+
config_file = get_config_path()
|
|
32
|
+
if config_file.exists():
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(config_file.read_text())
|
|
35
|
+
except (json.JSONDecodeError, OSError):
|
|
36
|
+
return {}
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_config(base_url: str, token: str) -> None:
|
|
41
|
+
"""Persist configuration with owner-only permissions (dir 0700, file 0600)."""
|
|
42
|
+
config_file = get_config_path()
|
|
43
|
+
config_dir = config_file.parent
|
|
44
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
config_file.write_text(
|
|
46
|
+
json.dumps({"base_url": base_url, "token": token}, indent=2)
|
|
47
|
+
)
|
|
48
|
+
# Best-effort permission hardening. A filesystem without POSIX permission
|
|
49
|
+
# bits (e.g. some Windows setups) must not crash the save.
|
|
50
|
+
try:
|
|
51
|
+
os.chmod(config_dir, 0o700)
|
|
52
|
+
os.chmod(config_file, 0o600)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def clear_config() -> bool:
|
|
58
|
+
"""Delete the config file. Return True if a file was removed."""
|
|
59
|
+
config_file = get_config_path()
|
|
60
|
+
if config_file.exists():
|
|
61
|
+
config_file.unlink()
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mask_token(token: str) -> str:
|
|
67
|
+
"""Return a masked representation of a token, safe for display."""
|
|
68
|
+
if not token:
|
|
69
|
+
return "(none)"
|
|
70
|
+
if len(token) <= 8:
|
|
71
|
+
return "****"
|
|
72
|
+
return f"{token[:5]}...{token[-4:]}"
|
|
@@ -35,6 +35,10 @@ Set up or update Indico CLI credentials.
|
|
|
35
35
|
|
|
36
36
|
Environment variables `INDICO_BASE_URL` and `INDICO_API_TOKEN` override the config file.
|
|
37
37
|
|
|
38
|
+
Inspect or reset saved credentials with `uvx indico-cli config show` (token
|
|
39
|
+
masked), `uvx indico-cli config path`, or `uvx indico-cli config clear`. The
|
|
40
|
+
config file is written with `0600` permissions.
|
|
41
|
+
|
|
38
42
|
---
|
|
39
43
|
|
|
40
44
|
## Working with Results
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""CLI tests for the `config` command group."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from click.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from indico_cli.cli import cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def tmp_xdg(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
14
|
+
monkeypatch.delenv("INDICO_BASE_URL", raising=False)
|
|
15
|
+
monkeypatch.delenv("INDICO_API_TOKEN", raising=False)
|
|
16
|
+
return tmp_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _write_config(tmp_path, base_url="https://indico.cern.ch", token="indp_abcdefgh1234"):
|
|
20
|
+
config_file = tmp_path / "indico-cli" / "config.json"
|
|
21
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
config_file.write_text(json.dumps({"base_url": base_url, "token": token}))
|
|
23
|
+
return config_file
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_config_show_masks_token(tmp_xdg):
|
|
27
|
+
_write_config(tmp_xdg)
|
|
28
|
+
result = CliRunner().invoke(cli, ["config", "show"])
|
|
29
|
+
assert result.exit_code == 0
|
|
30
|
+
assert "indp_...1234" in result.output
|
|
31
|
+
assert "indp_abcdefgh1234" not in result.output
|
|
32
|
+
assert "https://indico.cern.ch" in result.output
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_config_show_no_config(tmp_xdg):
|
|
36
|
+
result = CliRunner().invoke(cli, ["config", "show"])
|
|
37
|
+
assert result.exit_code == 0
|
|
38
|
+
assert "No saved configuration" in result.output
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_config_show_notes_env_override(tmp_xdg, monkeypatch):
|
|
42
|
+
_write_config(tmp_xdg)
|
|
43
|
+
monkeypatch.setenv("INDICO_API_TOKEN", "indp_envtoken")
|
|
44
|
+
result = CliRunner().invoke(cli, ["config", "show"])
|
|
45
|
+
assert result.exit_code == 0
|
|
46
|
+
assert "INDICO_API_TOKEN" in result.output
|
|
47
|
+
assert "override" in result.output
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_config_path_prints_path(tmp_xdg):
|
|
51
|
+
result = CliRunner().invoke(cli, ["config", "path"])
|
|
52
|
+
assert result.exit_code == 0
|
|
53
|
+
assert str(tmp_xdg / "indico-cli" / "config.json") in result.output
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_config_clear_with_yes(tmp_xdg):
|
|
57
|
+
_write_config(tmp_xdg)
|
|
58
|
+
result = CliRunner().invoke(cli, ["config", "clear", "--yes"])
|
|
59
|
+
assert result.exit_code == 0
|
|
60
|
+
assert not (tmp_xdg / "indico-cli" / "config.json").exists()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_config_clear_nothing(tmp_xdg):
|
|
64
|
+
result = CliRunner().invoke(cli, ["config", "clear", "--yes"])
|
|
65
|
+
assert result.exit_code == 0
|
|
66
|
+
assert "Nothing to clear" in result.output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_config_clear_aborts_without_confirmation(tmp_xdg):
|
|
70
|
+
_write_config(tmp_xdg)
|
|
71
|
+
result = CliRunner().invoke(cli, ["config", "clear"], input="n\n")
|
|
72
|
+
assert result.exit_code != 0 # click confirm abort -> non-zero exit
|
|
73
|
+
assert (tmp_xdg / "indico-cli" / "config.json").exists()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Unit tests for indico_cli.config on-disk credential storage."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from indico_cli import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def tmp_xdg(tmp_path, monkeypatch):
|
|
14
|
+
"""Point config storage at a temp XDG_CONFIG_HOME."""
|
|
15
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
16
|
+
return tmp_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_get_config_path_honors_xdg(tmp_xdg):
|
|
20
|
+
assert config.get_config_path() == tmp_xdg / "indico-cli" / "config.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_get_config_path_defaults_to_home(monkeypatch):
|
|
24
|
+
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
25
|
+
assert config.get_config_path() == Path.home() / ".config" / "indico-cli" / "config.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_save_then_load_roundtrips(tmp_xdg):
|
|
29
|
+
config.save_config("https://indico.cern.ch", "indp_secrettoken")
|
|
30
|
+
assert config.load_config() == {
|
|
31
|
+
"base_url": "https://indico.cern.ch",
|
|
32
|
+
"token": "indp_secrettoken",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.skipif(os.name == "nt", reason="POSIX permissions only")
|
|
37
|
+
def test_save_sets_owner_only_permissions(tmp_xdg):
|
|
38
|
+
config.save_config("https://indico.cern.ch", "indp_secrettoken")
|
|
39
|
+
config_file = config.get_config_path()
|
|
40
|
+
assert stat.S_IMODE(config_file.stat().st_mode) == 0o600
|
|
41
|
+
assert stat.S_IMODE(config_file.parent.stat().st_mode) == 0o700
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_load_returns_empty_when_missing(tmp_xdg):
|
|
45
|
+
assert config.load_config() == {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_load_returns_empty_on_corrupt_json(tmp_xdg):
|
|
49
|
+
config_file = config.get_config_path()
|
|
50
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
config_file.write_text("{not valid json")
|
|
52
|
+
assert config.load_config() == {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_clear_removes_file_and_returns_true(tmp_xdg):
|
|
56
|
+
config.save_config("https://indico.cern.ch", "indp_secrettoken")
|
|
57
|
+
assert config.clear_config() is True
|
|
58
|
+
assert not config.get_config_path().exists()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_clear_returns_false_when_nothing(tmp_xdg):
|
|
62
|
+
assert config.clear_config() is False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_mask_token_empty():
|
|
66
|
+
assert config.mask_token("") == "(none)"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_mask_token_short():
|
|
70
|
+
assert config.mask_token("indp_123") == "****"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_mask_token_normal():
|
|
74
|
+
assert config.mask_token("indp_abcdefgh1234") == "indp_...1234"
|
indico_cli-0.4.3/.gitlab-ci.yml
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
stages:
|
|
2
|
-
- publish
|
|
3
|
-
|
|
4
|
-
publish-pypi:
|
|
5
|
-
stage: publish
|
|
6
|
-
image: python:3.12-slim
|
|
7
|
-
rules:
|
|
8
|
-
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
|
|
9
|
-
script:
|
|
10
|
-
- apt-get update && apt-get install -y git
|
|
11
|
-
- git fetch --tags
|
|
12
|
-
- pip install build twine
|
|
13
|
-
- python -m build
|
|
14
|
-
- twine upload dist/*
|
|
15
|
-
variables:
|
|
16
|
-
TWINE_USERNAME: __token__
|
|
17
|
-
TWINE_PASSWORD: $PYPI_API_TOKEN
|
|
18
|
-
GIT_DEPTH: 0
|
|
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
|