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.
Files changed (27) hide show
  1. indico_cli-0.5.0/.gitlab-ci.yml +23 -0
  2. {indico_cli-0.4.3 → indico_cli-0.5.0}/PKG-INFO +9 -1
  3. {indico_cli-0.4.3 → indico_cli-0.5.0}/README.md +8 -0
  4. indico_cli-0.5.0/docs/superpowers/plans/2026-06-04-secure-config-storage.md +543 -0
  5. indico_cli-0.5.0/docs/superpowers/specs/2026-06-04-secure-config-storage-design.md +137 -0
  6. {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/_version.py +2 -2
  7. {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/cli.py +56 -21
  8. indico_cli-0.5.0/indico_cli/config.py +72 -0
  9. {indico_cli-0.4.3 → indico_cli-0.5.0}/skills/indico-skill/SKILL.md +4 -0
  10. indico_cli-0.5.0/tests/test_cli_config.py +73 -0
  11. indico_cli-0.5.0/tests/test_config.py +74 -0
  12. indico_cli-0.4.3/.gitlab-ci.yml +0 -18
  13. {indico_cli-0.4.3 → indico_cli-0.5.0}/.gitignore +0 -0
  14. {indico_cli-0.4.3 → indico_cli-0.5.0}/CLAUDE.md +0 -0
  15. {indico_cli-0.4.3 → indico_cli-0.5.0}/LICENSE +0 -0
  16. {indico_cli-0.4.3 → indico_cli-0.5.0}/docs/superpowers/plans/2026-03-24-search-results-and-error-messages.md +0 -0
  17. {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
  18. {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/__init__.py +0 -0
  19. {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/indico_api.py +0 -0
  20. {indico_cli-0.4.3 → indico_cli-0.5.0}/indico_cli/utils.py +0 -0
  21. {indico_cli-0.4.3 → indico_cli-0.5.0}/pyproject.toml +0 -0
  22. {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/__init__.py +0 -0
  23. {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_error_messages.py +0 -0
  24. {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_format_search_result.py +0 -0
  25. {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_get_files.py +0 -0
  26. {indico_cli-0.4.3 → indico_cli-0.5.0}/tests/test_write_commands.py +0 -0
  27. {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.4.3
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.4.3'
22
- __version_tuple__ = version_tuple = (0, 4, 3)
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
- CONFIG_DIR = Path.home() / ".config" / "indico-cli"
16
- CONFIG_FILE = CONFIG_DIR / "config.json"
17
-
18
-
19
- def load_config() -> dict:
20
- """Load saved configuration from disk, returning empty dict on failure."""
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 {CONFIG_FILE}")
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"
@@ -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