confpub-cli 1.4.2__tar.gz → 1.4.4__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 (50) hide show
  1. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/PKG-INFO +1 -1
  2. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/cli.py +40 -13
  4. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/config.py +31 -1
  5. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/confluence.py +9 -0
  6. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/errors.py +28 -0
  7. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/guide.py +15 -2
  8. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_integration.py +104 -0
  9. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/.github/workflows/publish.yml +0 -0
  10. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/.gitignore +0 -0
  11. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/CLAUDE.md +0 -0
  12. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/LICENSE +0 -0
  13. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/PRD.md +0 -0
  14. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/README.md +0 -0
  15. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/applier.py +0 -0
  16. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/assets.py +0 -0
  17. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/converter.py +0 -0
  18. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/envelope.py +0 -0
  19. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/lockfile.py +0 -0
  20. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/manifest.py +0 -0
  21. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/output.py +0 -0
  22. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/planner.py +0 -0
  23. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/publish.py +0 -0
  24. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/puller.py +0 -0
  25. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/py.typed +0 -0
  26. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/reverse_converter.py +0 -0
  27. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/validator.py +0 -0
  28. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/verifier.py +0 -0
  29. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub.lock +0 -0
  30. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/pyproject.toml +0 -0
  31. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/__init__.py +0 -0
  32. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/conftest.py +0 -0
  33. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_applier.py +0 -0
  34. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_assets.py +0 -0
  35. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_config.py +0 -0
  36. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_confluence.py +0 -0
  37. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_converter.py +0 -0
  38. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_envelope.py +0 -0
  39. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_errors.py +0 -0
  40. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_guide.py +0 -0
  41. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_lockfile.py +0 -0
  42. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_manifest.py +0 -0
  43. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_output.py +0 -0
  44. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_planner.py +0 -0
  45. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_publish.py +0 -0
  46. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_puller.py +0 -0
  47. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_reverse_converter.py +0 -0
  48. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_validator.py +0 -0
  49. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_verifier.py +0 -0
  50. {confpub_cli-1.4.2 → confpub_cli-1.4.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.4.2
3
+ Version: 1.4.4
4
4
  Summary: Agent-first CLI to publish Markdown to Confluence
5
5
  Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
6
6
  Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "1.4.2"
3
+ __version__ = "1.4.4"
@@ -13,11 +13,30 @@ from typing import Any, Iterator, Optional
13
13
 
14
14
  import typer
15
15
 
16
+ import os
17
+
16
18
  from confpub import __version__
17
19
  from confpub.envelope import Envelope
18
20
  from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
19
21
  from confpub.output import emit_stderr, emit_stdout, is_compact, is_verbose, set_compact, set_quiet, set_verbose
20
22
 
23
+
24
+ def _resolve_space(cli_space: str | None, required: bool = False) -> str | None:
25
+ """Resolve space from CLI flag or CONFPUB_SPACE env var, with validation."""
26
+ from confpub.config import ENV_SPACE
27
+ from confpub.errors import validate_space_key
28
+
29
+ space = cli_space or os.environ.get(ENV_SPACE)
30
+ if space is not None:
31
+ validate_space_key(space)
32
+ return space
33
+ if required:
34
+ raise ConfpubError(
35
+ "ERR_VALIDATION_REQUIRED",
36
+ "Space key is required. Use --space or set CONFPUB_SPACE.",
37
+ )
38
+ return None
39
+
21
40
  # ---------------------------------------------------------------------------
22
41
  # Subcommand group apps
23
42
  # ---------------------------------------------------------------------------
@@ -197,12 +216,14 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
197
216
 
198
217
  @page_app.command("list")
199
218
  def page_list(
200
- space: str = typer.Option(..., "--space", help="Confluence space key"),
219
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
201
220
  limit: int = typer.Option(25, "--limit", help="Maximum number of pages to return"),
202
221
  start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
203
222
  ) -> None:
204
223
  """List pages in a Confluence space."""
205
- with command_context("page.list", target={"space": space}) as ctx:
224
+ with command_context("page.list") as ctx:
225
+ space = _resolve_space(space, required=True)
226
+ ctx.target = {"space": space}
206
227
  from confpub.confluence import build_client, _slim_page
207
228
  client = build_client()
208
229
  ctx.client = client
@@ -218,7 +239,7 @@ def page_list(
218
239
 
219
240
  @page_app.command("inspect")
220
241
  def page_inspect(
221
- space: str = typer.Option(None, "--space", help="Confluence space key"),
242
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
222
243
  title: str = typer.Option(None, "--title", help="Page title"),
223
244
  page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
224
245
  raw: bool = typer.Option(False, "--raw", help="Return full raw API response"),
@@ -226,6 +247,7 @@ def page_inspect(
226
247
  ) -> None:
227
248
  """Inspect a Confluence page."""
228
249
  with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
250
+ space = _resolve_space(space)
229
251
  from confpub.confluence import build_client, _slim_page
230
252
  client = build_client()
231
253
  ctx.client = client
@@ -258,7 +280,7 @@ def page_inspect(
258
280
  @page_app.command("publish")
259
281
  def page_publish(
260
282
  file: str = typer.Argument(..., help="Markdown file to publish"),
261
- space: str = typer.Option(..., "--space", help="Confluence space key"),
283
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
262
284
  parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
263
285
  title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
264
286
  title_from_h1: bool = typer.Option(False, "--title-from-h1", help="Derive title from first H1 heading in the Markdown file"),
@@ -274,6 +296,8 @@ def page_publish(
274
296
  if page_id:
275
297
  target["page_id"] = page_id
276
298
  with command_context("page.publish", target=target) as ctx:
299
+ space = _resolve_space(space, required=True)
300
+ ctx.target["space"] = space
277
301
  if not page_id and not parent:
278
302
  raise ConfpubError(
279
303
  "ERR_VALIDATION_REQUIRED",
@@ -296,7 +320,7 @@ def page_publish(
296
320
 
297
321
  @page_app.command("pull")
298
322
  def page_pull(
299
- space: str = typer.Option(None, "--space", help="Confluence space key"),
323
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
300
324
  title: str = typer.Option(None, "--title", help="Page title"),
301
325
  page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
302
326
  output: str = typer.Option(".", "--output", "-o", help="Output directory"),
@@ -307,8 +331,8 @@ def page_pull(
307
331
  manifest: bool = typer.Option(False, "--manifest", help="Generate confpub.yaml manifest"),
308
332
  ) -> None:
309
333
  """Pull Confluence pages to local Markdown files."""
310
- target = {"space": space, "title": title, "page_id": page_id}
311
- with command_context("page.pull", target=target) as ctx:
334
+ with command_context("page.pull", target={"space": space, "title": title, "page_id": page_id}) as ctx:
335
+ space = _resolve_space(space)
312
336
  from confpub.errors import ERR_VALIDATION_REQUIRED
313
337
  if not page_id and not (space and title):
314
338
  raise ConfpubError(
@@ -333,13 +357,14 @@ def page_pull(
333
357
 
334
358
  @page_app.command("delete")
335
359
  def page_delete(
336
- space: Optional[str] = typer.Option(None, "--space", help="Confluence space key"),
360
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
337
361
  title: Optional[str] = typer.Option(None, "--title", help="Page title"),
338
362
  page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID"),
339
363
  cascade: bool = typer.Option(False, "--cascade", help="Also delete child pages"),
340
364
  ) -> None:
341
365
  """Delete a Confluence page."""
342
366
  with command_context("page.delete", target={"space": space, "title": title, "page_id": page_id}) as ctx:
367
+ space = _resolve_space(space)
343
368
  if not page_id and not (space and title):
344
369
  raise ConfpubError(
345
370
  "ERR_VALIDATION_REQUIRED",
@@ -384,12 +409,13 @@ def page_delete(
384
409
  def page_move(
385
410
  page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
386
411
  target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
387
- space: Optional[str] = typer.Option(None, "--space", help="Space key (required with --target-parent)"),
412
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
388
413
  target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
389
414
  ) -> None:
390
415
  """Move a page under a new parent."""
391
416
  target = {"page_id": page_id}
392
417
  with command_context("page.move", target=target) as ctx:
418
+ space = _resolve_space(space)
393
419
  if not target_parent and not target_parent_id:
394
420
  raise ConfpubError(
395
421
  "ERR_VALIDATION_REQUIRED",
@@ -460,11 +486,12 @@ def attachment_upload(
460
486
  def plan_create(
461
487
  manifest: str = typer.Option(..., "--manifest", help="Path to confpub.yaml manifest"),
462
488
  output: Optional[str] = typer.Option(None, "--output", help="Output path for plan artifact"),
463
- space: Optional[str] = typer.Option(None, "--space", help="Override manifest space"),
489
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
464
490
  parent: Optional[str] = typer.Option(None, "--parent", help="Override manifest parent"),
465
491
  ) -> None:
466
492
  """Generate a plan artifact from a manifest."""
467
493
  with command_context("plan.create", target={"manifest": manifest}) as ctx:
494
+ space = _resolve_space(space)
468
495
  from confpub.planner import create_plan
469
496
  result = create_plan(
470
497
  manifest_path=manifest,
@@ -668,7 +695,7 @@ def comment_add(
668
695
  @app.command("search")
669
696
  def search(
670
697
  cql: Optional[str] = typer.Option(None, "--cql", help="Raw CQL query"),
671
- space: Optional[str] = typer.Option(None, "--space", help="Filter by space key"),
698
+ space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
672
699
  title: Optional[str] = typer.Option(None, "--title", help="Search by page title (fuzzy match)"),
673
700
  content_type: Optional[str] = typer.Option(None, "--type", help="Filter by content type (page, blogpost, etc.)"),
674
701
  limit: int = typer.Option(25, "--limit", help="Maximum results to return"),
@@ -677,8 +704,8 @@ def search(
677
704
  excerpt_length: int = typer.Option(200, "--excerpt-length", help="Max excerpt chars (0 = unlimited)"),
678
705
  ) -> None:
679
706
  """Search Confluence content using CQL."""
680
- target = {"cql": cql, "space": space, "title": title, "type": content_type}
681
- with command_context("search", target=target) as ctx:
707
+ with command_context("search", target={"cql": cql, "space": space, "title": title, "type": content_type}) as ctx:
708
+ space = _resolve_space(space)
682
709
  # Build effective CQL from flags
683
710
  fragments: list[str] = []
684
711
  if space:
@@ -22,6 +22,8 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
22
22
  ENV_URL = "CONFPUB_URL"
23
23
  ENV_TOKEN = "CONFPUB_TOKEN"
24
24
  ENV_USER = "CONFPUB_USER"
25
+ ENV_SSL_VERIFY = "CONFPUB_SSL_VERIFY"
26
+ ENV_SPACE = "CONFPUB_SPACE"
25
27
 
26
28
 
27
29
  class ConfigModel(BaseModel):
@@ -30,6 +32,7 @@ class ConfigModel(BaseModel):
30
32
  base_url: Optional[str] = None
31
33
  user: Optional[str] = None
32
34
  token: Optional[str] = None
35
+ ssl_verify: Optional[str] = None
33
36
 
34
37
 
35
38
  class ResolvedConfig:
@@ -41,11 +44,13 @@ class ResolvedConfig:
41
44
  user: str | None = None,
42
45
  token: str | None = None,
43
46
  token_source: str | None = None,
47
+ ssl_verify: bool | str = False,
44
48
  ) -> None:
45
49
  self.base_url = base_url
46
50
  self.user = user
47
51
  self.token = token
48
52
  self.token_source = token_source
53
+ self.ssl_verify = ssl_verify
49
54
 
50
55
  @property
51
56
  def is_cloud(self) -> bool:
@@ -128,10 +133,28 @@ def _try_keyring(service: str, username: str) -> str | None:
128
133
  return None
129
134
 
130
135
 
136
+ def _resolve_ssl_verify(raw: str | None) -> bool | str:
137
+ """Parse an ssl_verify value into bool or CA-bundle path.
138
+
139
+ Accepts "true"/"false" (case-insensitive) or a filesystem path.
140
+ Returns False (default) when *raw* is None or empty.
141
+ """
142
+ if not raw:
143
+ return False
144
+ lower = raw.strip().lower()
145
+ if lower == "true":
146
+ return True
147
+ if lower == "false":
148
+ return False
149
+ # Treat as CA bundle path
150
+ return raw.strip()
151
+
152
+
131
153
  def load_config(
132
154
  cli_url: str | None = None,
133
155
  cli_user: str | None = None,
134
156
  cli_token: str | None = None,
157
+ cli_ssl_verify: str | None = None,
135
158
  ) -> ResolvedConfig:
136
159
  """Resolve config using precedence: CLI → env → file → keychain."""
137
160
  file_cfg = _load_config_file()
@@ -143,6 +166,10 @@ def load_config(
143
166
  # Token
144
167
  token = cli_token or os.environ.get(ENV_TOKEN) or file_cfg.token
145
168
 
169
+ # SSL verification
170
+ ssl_raw = cli_ssl_verify or os.environ.get(ENV_SSL_VERIFY) or file_cfg.ssl_verify
171
+ ssl_verify = _resolve_ssl_verify(ssl_raw)
172
+
146
173
  # Determine source
147
174
  token_source = None
148
175
  if cli_token:
@@ -164,6 +191,7 @@ def load_config(
164
191
  user=user,
165
192
  token=token,
166
193
  token_source=token_source,
194
+ ssl_verify=ssl_verify,
167
195
  )
168
196
 
169
197
 
@@ -178,11 +206,13 @@ def set_config_value(key: str, value: str) -> None:
178
206
  cfg.user = value
179
207
  elif key == "token":
180
208
  cfg.token = value
209
+ elif key == "ssl_verify":
210
+ cfg.ssl_verify = value
181
211
  else:
182
212
  from confpub.errors import ERR_VALIDATION_REQUIRED, validation_error
183
213
  raise validation_error(
184
214
  ERR_VALIDATION_REQUIRED,
185
- f"Unknown config key: {key}. Valid keys: base_url, user, token",
215
+ f"Unknown config key: {key}. Valid keys: base_url, user, token, ssl_verify",
186
216
  )
187
217
 
188
218
  CONFIG_FILE.write_text(json.dumps(cfg.model_dump(exclude_none=True), indent=2), encoding="utf-8")
@@ -48,6 +48,15 @@ class ConfluenceClient:
48
48
  kwargs["cloud"] = True
49
49
  else:
50
50
  kwargs["token"] = config.token
51
+
52
+ kwargs["verify_ssl"] = config.ssl_verify
53
+
54
+ # Suppress noisy per-request InsecureRequestWarning when SSL
55
+ # verification is disabled (common in corporate environments).
56
+ if not config.ssl_verify:
57
+ import urllib3
58
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
59
+
51
60
  return Confluence(**kwargs)
52
61
 
53
62
  def _handle_error(self, exc: Exception, context: str = "") -> None:
@@ -16,6 +16,7 @@ ERR_VALIDATION_ASSET_MISSING = "ERR_VALIDATION_ASSET_MISSING"
16
16
  ERR_VALIDATION_SPACE_MISMATCH = "ERR_VALIDATION_SPACE_MISMATCH"
17
17
  ERR_VALIDATION_NOT_FOUND = "ERR_VALIDATION_NOT_FOUND"
18
18
  ERR_VALIDATION_LABEL = "ERR_VALIDATION_LABEL"
19
+ ERR_VALIDATION_SPACE_KEY = "ERR_VALIDATION_SPACE_KEY"
19
20
 
20
21
  # Auth (exit 20)
21
22
  ERR_AUTH_REQUIRED = "ERR_AUTH_REQUIRED"
@@ -169,6 +170,33 @@ def io_error(
169
170
  return ConfpubError(code, message, details=details if details else None)
170
171
 
171
172
 
173
+ def validate_space_key(space: str | None) -> None:
174
+ """Reject space values that look like shell-expanded paths.
175
+
176
+ PowerShell expands unquoted ``~username`` to a Windows home path
177
+ (e.g. ``C:\\Users\\username``). Catching this early gives the caller an
178
+ actionable error instead of a confusing Confluence API failure.
179
+ """
180
+ if space is None:
181
+ return
182
+ import re
183
+ if "\\" in space or "/" in space or re.match(r"^[A-Za-z]:", space):
184
+ raise ConfpubError(
185
+ ERR_VALIDATION_SPACE_KEY,
186
+ (
187
+ f"Space key '{space}' appears to be a shell-expanded path. "
188
+ "Quote the value: --space '~username' or set CONFPUB_SPACE=~username"
189
+ ),
190
+ details={
191
+ "fix_options": [
192
+ "Quote the --space value: --space '~username'",
193
+ "Set the CONFPUB_SPACE environment variable instead",
194
+ "Use the space key without shell-expandable characters",
195
+ ],
196
+ },
197
+ )
198
+
199
+
172
200
  def internal_error(
173
201
  code: str = ERR_INTERNAL_CONVERTER,
174
202
  message: str = "Internal error",
@@ -25,6 +25,7 @@ from confpub.errors import (
25
25
  ERR_IO_TIMEOUT,
26
26
  ERR_VALIDATION_ASSET_MISSING,
27
27
  ERR_VALIDATION_LABEL,
28
+ ERR_VALIDATION_SPACE_KEY,
28
29
  ERR_VALIDATION_MANIFEST,
29
30
  ERR_VALIDATION_MARKDOWN,
30
31
  ERR_VALIDATION_NOT_FOUND,
@@ -102,7 +103,9 @@ def build_guide() -> dict[str, Any]:
102
103
  },
103
104
  "agent_hint": (
104
105
  "Use --start and --limit for pagination: first call with --start 0 --limit 25, "
105
- "then if has_more is true, call again with --start 25 --limit 25, and so on."
106
+ "then if has_more is true, call again with --start 25 --limit 25, and so on. "
107
+ "For personal spaces, quote the tilde: --space '~username' "
108
+ "(PowerShell expands unquoted ~). Or set CONFPUB_SPACE env var."
106
109
  ),
107
110
  },
108
111
  "page.inspect": {
@@ -140,7 +143,9 @@ def build_guide() -> dict[str, Any]:
140
143
  "the stem is extracted, hyphens and underscores are replaced with spaces, "
141
144
  "and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'. "
142
145
  "Use --title-from-h1 to extract the title from the first # heading in the file. "
143
- "Use --label to apply labels (repeatable): --label api --label docs."
146
+ "Use --label to apply labels (repeatable): --label api --label docs. "
147
+ "For personal spaces, quote the tilde: --space '~username' "
148
+ "(PowerShell expands unquoted ~). Or set CONFPUB_SPACE env var."
144
149
  ),
145
150
  },
146
151
  "page.move": {
@@ -307,6 +312,7 @@ def build_guide() -> dict[str, Any]:
307
312
  ERR_VALIDATION_NOT_FOUND: _error_code_entry(ERR_VALIDATION_NOT_FOUND),
308
313
  ERR_VALIDATION_SPACE_MISMATCH: _error_code_entry(ERR_VALIDATION_SPACE_MISMATCH),
309
314
  ERR_VALIDATION_LABEL: _error_code_entry(ERR_VALIDATION_LABEL),
315
+ ERR_VALIDATION_SPACE_KEY: _error_code_entry(ERR_VALIDATION_SPACE_KEY),
310
316
  ERR_AUTH_REQUIRED: _error_code_entry(ERR_AUTH_REQUIRED),
311
317
  ERR_AUTH_EXPIRED: _error_code_entry(ERR_AUTH_EXPIRED),
312
318
  ERR_AUTH_FORBIDDEN: _error_code_entry(ERR_AUTH_FORBIDDEN),
@@ -405,6 +411,13 @@ def build_guide() -> dict[str, Any]:
405
411
  "config_file",
406
412
  "os_keychain",
407
413
  ],
414
+ "env_vars": {
415
+ "CONFPUB_URL": "Confluence base URL",
416
+ "CONFPUB_TOKEN": "API token or PAT",
417
+ "CONFPUB_USER": "User email or username",
418
+ "CONFPUB_SSL_VERIFY": "SSL verification (true/false/ca-bundle path)",
419
+ "CONFPUB_SPACE": "Default space key (avoids shell expansion issues with --space)",
420
+ },
408
421
  "non_interactive": (
409
422
  "Never prompts when LLM=true or stdin is non-interactive"
410
423
  ),
@@ -125,6 +125,110 @@ class TestPersonalSpaceKeyCLI:
125
125
  assert captured["space"] == "~thro"
126
126
 
127
127
 
128
+ class TestSpaceKeyValidation:
129
+ """Reject space values that look like shell-expanded paths."""
130
+
131
+ def test_windows_path_detected(self):
132
+ result = runner.invoke(app, ["page", "list", "--space", "C:\\Users\\thro"])
133
+ assert result.exit_code == 10
134
+ data = json.loads(result.output)
135
+ assert data["ok"] is False
136
+ assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
137
+
138
+ def test_unix_home_path_detected(self):
139
+ result = runner.invoke(app, ["page", "list", "--space", "/home/thro"])
140
+ assert result.exit_code == 10
141
+ data = json.loads(result.output)
142
+ assert data["ok"] is False
143
+ assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
144
+
145
+ def test_backslash_in_value_detected(self):
146
+ result = runner.invoke(app, ["page", "list", "--space", "some\\path"])
147
+ assert result.exit_code == 10
148
+ data = json.loads(result.output)
149
+ assert data["ok"] is False
150
+ assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
151
+
152
+ def test_valid_tilde_space_passes(self, monkeypatch):
153
+ def fake_list_pages(self, space, **kwargs):
154
+ return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
155
+
156
+ from confpub.confluence import ConfluenceClient
157
+ monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
158
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
159
+
160
+ result = runner.invoke(app, ["page", "list", "--space", "~thro"])
161
+ assert result.exit_code == 0
162
+ data = json.loads(result.output)
163
+ assert data["ok"] is True
164
+
165
+ def test_plain_space_key_passes(self, monkeypatch):
166
+ def fake_list_pages(self, space, **kwargs):
167
+ return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
168
+
169
+ from confpub.confluence import ConfluenceClient
170
+ monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
171
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
172
+
173
+ result = runner.invoke(app, ["page", "list", "--space", "DEV"])
174
+ assert result.exit_code == 0
175
+ data = json.loads(result.output)
176
+ assert data["ok"] is True
177
+
178
+
179
+ class TestConfpubSpaceEnvVar:
180
+ """CONFPUB_SPACE env var as an alternative to --space."""
181
+
182
+ def test_env_var_used_when_no_flag(self, monkeypatch):
183
+ def fake_list_pages(self, space, **kwargs):
184
+ self._captured_space = space
185
+ return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
186
+
187
+ from confpub.confluence import ConfluenceClient
188
+ mock_client = ConfluenceClient.__new__(ConfluenceClient)
189
+ monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
190
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: mock_client)
191
+ monkeypatch.setenv("CONFPUB_SPACE", "~thro")
192
+
193
+ result = runner.invoke(app, ["page", "list"])
194
+ assert result.exit_code == 0
195
+ data = json.loads(result.output)
196
+ assert data["ok"] is True
197
+ assert mock_client._captured_space == "~thro"
198
+
199
+ def test_cli_flag_overrides_env(self, monkeypatch):
200
+ captured = {}
201
+
202
+ def fake_list_pages(self, space, **kwargs):
203
+ captured["space"] = space
204
+ return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
205
+
206
+ from confpub.confluence import ConfluenceClient
207
+ monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
208
+ monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
209
+ monkeypatch.setenv("CONFPUB_SPACE", "~env")
210
+
211
+ result = runner.invoke(app, ["page", "list", "--space", "~cli"])
212
+ assert result.exit_code == 0
213
+ assert captured["space"] == "~cli"
214
+
215
+ def test_env_var_also_validated(self, monkeypatch):
216
+ monkeypatch.setenv("CONFPUB_SPACE", "C:\\Users\\thro")
217
+
218
+ result = runner.invoke(app, ["page", "list"])
219
+ assert result.exit_code == 10
220
+ data = json.loads(result.output)
221
+ assert data["ok"] is False
222
+ assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
223
+
224
+ def test_missing_space_mentions_env(self):
225
+ result = runner.invoke(app, ["page", "list"])
226
+ assert result.exit_code == 10
227
+ data = json.loads(result.output)
228
+ assert data["ok"] is False
229
+ assert "CONFPUB_SPACE" in data["errors"][0]["message"]
230
+
231
+
128
232
  class TestSearchCommand:
129
233
  def test_search_help(self):
130
234
  result = runner.invoke(app, ["search", "--help"])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes