codepraxis 0.3.1__tar.gz → 0.3.3__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 (73) hide show
  1. {codepraxis-0.3.1 → codepraxis-0.3.3}/PKG-INFO +45 -2
  2. {codepraxis-0.3.1 → codepraxis-0.3.3}/README.md +44 -1
  3. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/__init__.py +1 -1
  4. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/cli.py +74 -1
  5. codepraxis-0.3.3/src/codepraxis/commands/catalog.py +114 -0
  6. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/commands/publish.py +29 -14
  7. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/remote/client.py +13 -0
  8. codepraxis-0.3.3/src/codepraxis/plugin/templates/commands/new.md +42 -0
  9. codepraxis-0.3.3/tests/test_catalog.py +67 -0
  10. {codepraxis-0.3.1 → codepraxis-0.3.3}/tests/test_publish.py +114 -0
  11. codepraxis-0.3.1/src/codepraxis/plugin/templates/commands/new.md +0 -32
  12. {codepraxis-0.3.1 → codepraxis-0.3.3}/.gitignore +0 -0
  13. {codepraxis-0.3.1 → codepraxis-0.3.3}/CONTRIBUTING.md +0 -0
  14. {codepraxis-0.3.1 → codepraxis-0.3.3}/RELEASING.md +0 -0
  15. {codepraxis-0.3.1 → codepraxis-0.3.3}/pyproject.toml +0 -0
  16. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/__main__.py +0 -0
  17. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/commands/__init__.py +0 -0
  18. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/commands/example.py +0 -0
  19. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/commands/lint.py +0 -0
  20. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/commands/login.py +0 -0
  21. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/commands/validate.py +0 -0
  22. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/domain/__init__.py +0 -0
  23. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/domain/contract.py +0 -0
  24. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/domain/pack.py +0 -0
  25. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/domain/results.py +0 -0
  26. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/errors.py +0 -0
  27. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/__init__.py +0 -0
  28. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/executor.py +0 -0
  29. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/local/__init__.py +0 -0
  30. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/local/backends.py +0 -0
  31. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/local/executor.py +0 -0
  32. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/local/worker.py +0 -0
  33. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/local/workspace.py +0 -0
  34. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/remote/__init__.py +0 -0
  35. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/remote/config.py +0 -0
  36. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/execution/remote/executor.py +0 -0
  37. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/packio/__init__.py +0 -0
  38. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/packio/archive.py +0 -0
  39. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/packio/discovery.py +0 -0
  40. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/packio/loader.py +0 -0
  41. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/packio/toc.py +0 -0
  42. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/plugin/__init__.py +0 -0
  43. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/plugin/installer.py +0 -0
  44. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/commands/validate.md +0 -0
  45. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/marketplace.json +0 -0
  46. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/plugin.json +0 -0
  47. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/plugin/templates/skills/pack-authoring/SKILL.md +0 -0
  48. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/reporting/__init__.py +0 -0
  49. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/reporting/human.py +0 -0
  50. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/reporting/json_reporter.py +0 -0
  51. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/reporting/reporter.py +0 -0
  52. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/__init__.py +0 -0
  53. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/generator.py +0 -0
  54. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/README.md +0 -0
  55. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/backend.conf +0 -0
  56. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/course_toc.json +0 -0
  57. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/feature.md +0 -0
  58. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/main.py +0 -0
  59. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/metadata.json +0 -0
  60. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/solution.py +0 -0
  61. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/scaffold/templates/test_1.py +0 -0
  62. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/validation/__init__.py +0 -0
  63. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/validation/registry.py +0 -0
  64. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/validation/rule.py +0 -0
  65. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/validation/rules/__init__.py +0 -0
  66. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/validation/rules/hygiene.py +0 -0
  67. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/validation/rules/instructions.py +0 -0
  68. {codepraxis-0.3.1 → codepraxis-0.3.3}/src/codepraxis/validation/rules/testcases.py +0 -0
  69. {codepraxis-0.3.1 → codepraxis-0.3.3}/tests/conformance/test_corpus.py +0 -0
  70. {codepraxis-0.3.1 → codepraxis-0.3.3}/tests/conftest.py +0 -0
  71. {codepraxis-0.3.1 → codepraxis-0.3.3}/tests/test_classification.py +0 -0
  72. {codepraxis-0.3.1 → codepraxis-0.3.3}/tests/test_toc.py +0 -0
  73. {codepraxis-0.3.1 → codepraxis-0.3.3}/tests/test_validation_rules.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codepraxis
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Author and validate CodePraxis challenge packs.
5
5
  Project-URL: Documentation, https://docs.codepraxis.com/authoring
6
6
  Author: CodePraxis
@@ -35,6 +35,9 @@ codepraxis lint my-challenge # static checks, no execution
35
35
  codepraxis validate --local my-challenge # run it, fast and advisory
36
36
  codepraxis --login
37
37
  codepraxis --publish my-challenge # validates in the runner, then publishes
38
+ codepraxis --list # list company questions
39
+ codepraxis --edit 123 --open # update metadata/open a preview container
40
+ codepraxis --publish my-challenge --challenge-id 123 # publish an edit, not a duplicate
38
41
  ```
39
42
 
40
43
  ## What this is
@@ -153,7 +156,7 @@ codepraxis --install claude-plugin
153
156
  Writes a local plugin into `.codepraxis/claude-plugin/`, then tells you the two
154
157
  commands to enable it. You get:
155
158
 
156
- - **`/codepraxis:new`** — scaffold a pack from a description
159
+ - **`/codepraxis:new`** — design, build, validate, publish and return a URL
157
160
  - **`/codepraxis:validate`** — validate and fix what fails, in a loop
158
161
  - **`pack-authoring` skill** — loads automatically when Claude touches a pack,
159
162
  so it already knows the `testCases` contract and the two-fixture rule
@@ -199,6 +202,46 @@ assigned to candidates immediately:
199
202
  publish into someone else's catalog.
200
203
 
201
204
  You'll be shown the company and asked to confirm before anything is created.
205
+ When the platform can start a preview container, `--publish` prints that URL so
206
+ you can inspect the question immediately.
207
+
208
+ ## Company catalog
209
+
210
+ ```bash
211
+ codepraxis --list
212
+ codepraxis --edit 123 --title "Debug webhook delivery" --status draft
213
+ codepraxis --edit 123 --open
214
+ ```
215
+
216
+ `--list` shows the questions owned by your API key's company, including drafts.
217
+ `--edit` updates question metadata and returns a container URL for inspection.
218
+
219
+ ### Publishing an edit
220
+
221
+ Changing the *content* of a question means re-publishing the pack. Pass the
222
+ question's id, or you get a second copy:
223
+
224
+ ```bash
225
+ codepraxis --publish my-challenge --challenge-id 123
226
+ ```
227
+
228
+ With `--challenge-id` the platform adds a new version to that question and
229
+ keeps its id, assignments and history. Without it, publish always creates a
230
+ new question — which is the right behaviour the first time and a duplicate
231
+ every time after.
232
+
233
+ ### Deleting
234
+
235
+ ```bash
236
+ codepraxis --delete 123
237
+ ```
238
+
239
+ Asks first, and the platform refuses outright once the question has been
240
+ assigned to anyone — deleting it then would orphan attempts and reports that
241
+ candidates and reviewers still depend on. To take an assigned question out of
242
+ circulation, set it back to draft with `--edit 123 --status draft`.
243
+ Code changes still go through the pack workflow: edit locally, validate, and
244
+ publish a new version.
202
245
 
203
246
  ## Development
204
247
 
@@ -10,6 +10,9 @@ codepraxis lint my-challenge # static checks, no execution
10
10
  codepraxis validate --local my-challenge # run it, fast and advisory
11
11
  codepraxis --login
12
12
  codepraxis --publish my-challenge # validates in the runner, then publishes
13
+ codepraxis --list # list company questions
14
+ codepraxis --edit 123 --open # update metadata/open a preview container
15
+ codepraxis --publish my-challenge --challenge-id 123 # publish an edit, not a duplicate
13
16
  ```
14
17
 
15
18
  ## What this is
@@ -128,7 +131,7 @@ codepraxis --install claude-plugin
128
131
  Writes a local plugin into `.codepraxis/claude-plugin/`, then tells you the two
129
132
  commands to enable it. You get:
130
133
 
131
- - **`/codepraxis:new`** — scaffold a pack from a description
134
+ - **`/codepraxis:new`** — design, build, validate, publish and return a URL
132
135
  - **`/codepraxis:validate`** — validate and fix what fails, in a loop
133
136
  - **`pack-authoring` skill** — loads automatically when Claude touches a pack,
134
137
  so it already knows the `testCases` contract and the two-fixture rule
@@ -174,6 +177,46 @@ assigned to candidates immediately:
174
177
  publish into someone else's catalog.
175
178
 
176
179
  You'll be shown the company and asked to confirm before anything is created.
180
+ When the platform can start a preview container, `--publish` prints that URL so
181
+ you can inspect the question immediately.
182
+
183
+ ## Company catalog
184
+
185
+ ```bash
186
+ codepraxis --list
187
+ codepraxis --edit 123 --title "Debug webhook delivery" --status draft
188
+ codepraxis --edit 123 --open
189
+ ```
190
+
191
+ `--list` shows the questions owned by your API key's company, including drafts.
192
+ `--edit` updates question metadata and returns a container URL for inspection.
193
+
194
+ ### Publishing an edit
195
+
196
+ Changing the *content* of a question means re-publishing the pack. Pass the
197
+ question's id, or you get a second copy:
198
+
199
+ ```bash
200
+ codepraxis --publish my-challenge --challenge-id 123
201
+ ```
202
+
203
+ With `--challenge-id` the platform adds a new version to that question and
204
+ keeps its id, assignments and history. Without it, publish always creates a
205
+ new question — which is the right behaviour the first time and a duplicate
206
+ every time after.
207
+
208
+ ### Deleting
209
+
210
+ ```bash
211
+ codepraxis --delete 123
212
+ ```
213
+
214
+ Asks first, and the platform refuses outright once the question has been
215
+ assigned to anyone — deleting it then would orphan attempts and reports that
216
+ candidates and reviewers still depend on. To take an assigned question out of
217
+ circulation, set it back to draft with `--edit 123 --status draft`.
218
+ Code changes still go through the pack workflow: edit locally, validate, and
219
+ publish a new version.
177
220
 
178
221
  ## Development
179
222
 
@@ -1,3 +1,3 @@
1
1
  """Author and validate CodePraxis challenge packs."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.3"
@@ -12,6 +12,7 @@ import sys
12
12
  from pathlib import Path
13
13
 
14
14
  from . import __version__
15
+ from .commands import catalog as catalog_command
15
16
  from .commands import example as example_command
16
17
  from .commands import lint as lint_command
17
18
  from .commands import login as login_command
@@ -59,6 +60,23 @@ def build_parser() -> argparse.ArgumentParser:
59
60
  metavar="PACK",
60
61
  help="Validate a pack in the runner, then publish it to your company.",
61
62
  )
63
+ actions.add_argument(
64
+ "--list",
65
+ action="store_true",
66
+ help="List questions that exist for your company.",
67
+ )
68
+ actions.add_argument(
69
+ "--edit",
70
+ type=int,
71
+ metavar="CHALLENGE_ID",
72
+ help="Edit question metadata and open its container preview.",
73
+ )
74
+ actions.add_argument(
75
+ "--delete",
76
+ type=int,
77
+ metavar="CHALLENGE_ID",
78
+ help="Delete a question your company owns. Refused once it has been assigned.",
79
+ )
62
80
  actions.add_argument(
63
81
  "--install",
64
82
  choices=INSTALLABLES,
@@ -84,6 +102,14 @@ def build_parser() -> argparse.ArgumentParser:
84
102
  action="store_true",
85
103
  help="With --publish, publish straight to candidates instead of as a draft.",
86
104
  )
105
+ parser.add_argument(
106
+ "--challenge-id",
107
+ type=int,
108
+ help=(
109
+ "With --publish, publish a new version of this existing question "
110
+ "instead of creating another one. Get the id from --list."
111
+ ),
112
+ )
87
113
  parser.add_argument(
88
114
  "--validation-run-id",
89
115
  help="With --publish, reuse a passing validation run instead of running a new one.",
@@ -99,7 +125,27 @@ def build_parser() -> argparse.ArgumentParser:
99
125
  "--open",
100
126
  action="store_true",
101
127
  dest="open_browser",
102
- help="With --example, open the container in your browser.",
128
+ help="With --example or --edit, open the container in your browser.",
129
+ )
130
+ parser.add_argument("--title", help="With --edit, update the question title.")
131
+ parser.add_argument("--description", help="With --edit, update the question description.")
132
+ parser.add_argument(
133
+ "--status",
134
+ choices=("draft", "published"),
135
+ help="With --edit, update whether the question is draft or published.",
136
+ )
137
+ parser.add_argument("--difficulty", type=int, choices=(1, 2, 3), help="With --edit, set difficulty.")
138
+ parser.add_argument("--max-time", type=int, help="With --edit, set time limit in minutes.")
139
+ parser.add_argument("--max-attempt", type=int, help="With --edit, set maximum attempts.")
140
+ parser.add_argument(
141
+ "--tech-stack",
142
+ help="With --edit, comma-separated technologies, for example 'Python,FastAPI'.",
143
+ )
144
+ parser.add_argument(
145
+ "--ai-enabled",
146
+ action=argparse.BooleanOptionalAction,
147
+ default=None,
148
+ help="With --edit, toggle AI assistance.",
103
149
  )
104
150
 
105
151
  subparsers = parser.add_subparsers(dest="command")
@@ -245,6 +291,22 @@ def _handle_new(args: argparse.Namespace) -> int:
245
291
  return 0
246
292
 
247
293
 
294
+ def _edit_updates(args: argparse.Namespace) -> dict:
295
+ tech_stack = None
296
+ if args.tech_stack is not None:
297
+ tech_stack = [item.strip() for item in args.tech_stack.split(",") if item.strip()]
298
+ return {
299
+ "challenge_name": args.title,
300
+ "description": args.description,
301
+ "status": args.status,
302
+ "difficulty": args.difficulty,
303
+ "max_time": args.max_time,
304
+ "max_attempt": args.max_attempt,
305
+ "tech_stack": tech_stack,
306
+ "ai_enabled": args.ai_enabled,
307
+ }
308
+
309
+
248
310
  def _handle_install(target: str, force: bool) -> int:
249
311
  if target == "claude-plugin":
250
312
  result = installer.install(Path.cwd(), force=force)
@@ -268,7 +330,18 @@ def main(argv: list[str] | None = None) -> int:
268
330
  assume_yes=args.yes,
269
331
  live=args.live,
270
332
  validation_run_id=args.validation_run_id,
333
+ challenge_id=args.challenge_id,
334
+ )
335
+ if args.list:
336
+ return catalog_command.list_questions(as_json=args.json)
337
+ if args.edit is not None:
338
+ return catalog_command.edit_question(
339
+ challenge_id=args.edit,
340
+ updates=_edit_updates(args),
341
+ open_browser=args.open_browser,
271
342
  )
343
+ if args.delete is not None:
344
+ return catalog_command.delete_question(args.delete, assume_yes=args.yes)
272
345
  if args.install:
273
346
  return _handle_install(args.install, args.force)
274
347
  if args.example:
@@ -0,0 +1,114 @@
1
+ """Company challenge catalog commands for the public authoring API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import webbrowser
7
+ from typing import Any
8
+
9
+ from ..errors import PraxisError
10
+ from ..execution.remote.client import ApiClient
11
+ from ..execution.remote.config import RemoteConfig
12
+
13
+ EXIT_OK = 0
14
+ EXIT_ABORTED = 2
15
+
16
+
17
+ def list_questions(client: ApiClient | None = None, as_json: bool = False) -> int:
18
+ client = client or ApiClient(RemoteConfig.resolve())
19
+ payload = client.get("/challenges")
20
+ challenges = payload.get("challenges") or []
21
+
22
+ if as_json:
23
+ print(json.dumps({"challenges": challenges}, indent=2))
24
+ return EXIT_OK
25
+
26
+ if not challenges:
27
+ print("No questions found for this company.")
28
+ return EXIT_OK
29
+
30
+ for challenge in challenges:
31
+ _print_challenge(challenge)
32
+ return EXIT_OK
33
+
34
+
35
+ def edit_question(
36
+ challenge_id: int,
37
+ updates: dict[str, Any] | None = None,
38
+ client: ApiClient | None = None,
39
+ open_browser: bool = False,
40
+ ) -> int:
41
+ client = client or ApiClient(RemoteConfig.resolve())
42
+ updates = {key: value for key, value in (updates or {}).items() if value is not None}
43
+
44
+ if updates:
45
+ payload = client.patch_json(f"/challenges/{challenge_id}", updates)
46
+ challenge = payload.get("challenge") or {}
47
+ updated_id = challenge.get("challenge_id") or challenge_id
48
+ updated_name = challenge.get("challenge_name") or ""
49
+ print(f"Updated question {updated_id}: {updated_name}")
50
+ else:
51
+ print(f"Opening question {challenge_id} in a container...")
52
+
53
+ session = client.post_json(f"/challenges/{challenge_id}/setup-codebase", {})
54
+ url = session.get("container_url")
55
+ if not url:
56
+ raise PraxisError("The platform prepared the question but returned no container URL.")
57
+
58
+ print(f"\n {url}\n")
59
+ print(
60
+ "Use the container to inspect or adjust the question. "
61
+ "Publish a new pack version when code changes are ready."
62
+ )
63
+
64
+ if open_browser:
65
+ webbrowser.open(url)
66
+
67
+ return EXIT_OK
68
+
69
+
70
+ def _print_challenge(challenge: dict[str, Any]) -> None:
71
+ challenge_id = challenge.get("challenge_id")
72
+ name = challenge.get("challenge_name") or "(untitled)"
73
+ status = challenge.get("status") or "unknown"
74
+ version = challenge.get("challenge_version_id")
75
+ updated = challenge.get("updated_at") or challenge.get("created_at")
76
+
77
+ line = f"{challenge_id}\t{name}\t{status}"
78
+ if version:
79
+ line += f"\tv{version}"
80
+ if updated:
81
+ line += f"\t{updated}"
82
+ print(line)
83
+
84
+
85
+ def delete_question(
86
+ challenge_id: int,
87
+ client: ApiClient | None = None,
88
+ assume_yes: bool = False,
89
+ ) -> int:
90
+ """Delete a question the company owns.
91
+
92
+ Confirmed by default: unlike a draft, this is not recoverable from the
93
+ dashboard. The platform refuses outright once the question has been assigned
94
+ to anyone, so the destructive case this guards is an unassigned question the
95
+ author still wanted.
96
+ """
97
+ import sys
98
+
99
+ client = client or ApiClient(RemoteConfig.resolve())
100
+
101
+ if not assume_yes:
102
+ if not sys.stdin.isatty():
103
+ raise PraxisError(
104
+ "Deleting needs confirmation, but stdin is not a terminal. Pass --yes."
105
+ )
106
+ print(f"Permanently delete question {challenge_id}? This cannot be undone.")
107
+ if input("Continue? [y/N] ").strip().lower() not in {"y", "yes"}:
108
+ print("Aborted.", file=sys.stderr)
109
+ return EXIT_ABORTED
110
+
111
+ payload = client.delete(f"/challenges/{challenge_id}")
112
+ removed = payload.get("versions_removed", 0)
113
+ print(f"Deleted question {challenge_id} ({removed} version(s) removed).")
114
+ return EXIT_OK
@@ -40,6 +40,7 @@ def run(
40
40
  assume_yes: bool = False,
41
41
  live: bool = False,
42
42
  validation_run_id: str | None = None,
43
+ challenge_id: int | None = None,
43
44
  client: ApiClient | None = None,
44
45
  ) -> int:
45
46
  if not selector:
@@ -68,27 +69,40 @@ def run(
68
69
  run_id = _validate(pack, client, reporter)
69
70
 
70
71
  company = _company_name(client, config)
71
- if not _confirm(pack.name, company, live, assume_yes):
72
+ if not _confirm(pack.name, company, live, assume_yes, challenge_id):
72
73
  print("Aborted.", file=sys.stderr)
73
74
  return EXIT_ABORTED
74
75
 
75
- payload = client.post_json(
76
- "/challenges",
77
- {
78
- # No company_id: the server derives ownership from the API key.
79
- "name": pack.name,
80
- "validation_run_id": run_id,
81
- "status": "published" if live else "draft",
82
- },
83
- )
76
+ body = {
77
+ # No company_id: the server derives ownership from the API key.
78
+ "name": pack.name,
79
+ "validation_run_id": run_id,
80
+ "status": "published" if live else "draft",
81
+ }
82
+ # With an id, this publishes a new version of an existing question. Without
83
+ # one the platform creates a new question — which is why editing a pack and
84
+ # re-publishing used to leave a duplicate behind.
85
+ if challenge_id is not None:
86
+ body["challenge_id"] = challenge_id
87
+
88
+ payload = client.post_json("/challenges", body)
84
89
 
85
90
  challenge_id = payload.get("challenge_id")
86
91
  version_id = payload.get("challenge_version_id")
92
+ container_url = payload.get("container_url")
93
+ preview_error = payload.get("preview_error")
87
94
  state = "published" if live else "draft"
95
+ created = payload.get("created", True)
88
96
 
89
- print(f"{pack.name} {state} for {company}")
97
+ verb = "published" if created else "updated"
98
+ print(f"{pack.name} {verb} for {company} ({state})")
90
99
  if challenge_id:
91
- print(f" challenge {challenge_id}, version {version_id}")
100
+ label = "challenge" if created else "challenge (new version of)"
101
+ print(f" {label} {challenge_id}, version {version_id}")
102
+ if container_url:
103
+ print(f" {container_url}")
104
+ elif preview_error:
105
+ print(f" Preview container unavailable: {preview_error}")
92
106
  if not live:
93
107
  print(" Publish it to candidates from the dashboard when you are ready.")
94
108
  return EXIT_OK
@@ -123,7 +137,7 @@ def _company_name(client: ApiClient, config: RemoteConfig | None) -> str:
123
137
  return company.get("name") or cached or "your company"
124
138
 
125
139
 
126
- def _confirm(pack_name: str, company: str, live: bool, assume_yes: bool) -> bool:
140
+ def _confirm(pack_name: str, company: str, live: bool, assume_yes: bool, challenge_id: int | None = None) -> bool:
127
141
  if assume_yes:
128
142
  return True
129
143
  if not sys.stdin.isatty():
@@ -132,5 +146,6 @@ def _confirm(pack_name: str, company: str, live: bool, assume_yes: bool) -> bool
132
146
  "Pass --yes to publish non-interactively (CI)."
133
147
  )
134
148
  visibility = "LIVE to candidates" if live else "as a draft"
135
- print(f"\nAbout to publish {pack_name} to {company}, {visibility}.")
149
+ target = f"as a new version of question {challenge_id}" if challenge_id else "as a new question"
150
+ print(f"\nAbout to publish {pack_name} to {company} {target}, {visibility}.")
136
151
  return input("Continue? [y/N] ").strip().lower() in {"y", "yes"}
@@ -11,6 +11,8 @@ Endpoints used, served by ``api_authoring_controller`` under ``/api/public``
11
11
  ``POST /validation-runs`` -> submit a bundle, returns a run id
12
12
  ``GET /validation-runs/{id}`` -> poll status + result
13
13
  ``POST /challenges`` -> publish a validated pack
14
+ ``GET /challenges`` -> list company challenges
15
+ ``PATCH /challenges/{id}`` -> edit challenge metadata
14
16
 
15
17
  All four require an API key holding the ``challenges:write`` scope, except
16
18
  ``/me`` which only requires a valid key.
@@ -115,6 +117,17 @@ class ApiClient:
115
117
  content_type="application/json",
116
118
  )
117
119
 
120
+ def patch_json(self, path: str, payload: dict) -> dict[str, Any]:
121
+ return self.request(
122
+ "PATCH",
123
+ path,
124
+ body=json.dumps(payload).encode("utf-8"),
125
+ content_type="application/json",
126
+ )
127
+
128
+ def delete(self, path: str) -> dict[str, Any]:
129
+ return self.request("DELETE", path)
130
+
118
131
  def post_bytes(self, path: str, blob: bytes) -> dict[str, Any]:
119
132
  return self.request("POST", path, body=blob, content_type="application/zip")
120
133
 
@@ -0,0 +1,42 @@
1
+ ---
2
+ description: Design, build, validate, publish, and preview a CodePraxis question
3
+ argument-hint: "<what the challenge should test>"
4
+ allowed-tools: Bash(codepraxis validate:*), Bash(codepraxis --publish:*), Read, Write, Edit, Glob
5
+ ---
6
+
7
+ Create and publish a new CodePraxis question for: **$ARGUMENTS**
8
+
9
+ Build it in this order. Do not skip the design step, and do not claim the
10
+ question is done until publish returns a URL.
11
+
12
+ 1. **Decide the question architecture.** Write down what skill this tests, the
13
+ expected task shape, the test cases you will need, and roughly how much code
14
+ the candidate should write. Prefer a realistic engineering task with one
15
+ obvious entry point, concrete sample input, and an output contract a test can
16
+ check automatically. Not a toy exercise, not a puzzle.
17
+
18
+ 2. **Implement the pack** under `challenges/<slug>/`:
19
+ - `metadata.json` — a unique snake_case `name`
20
+ - `backend.conf` — `BACKEND` and `LANGUAGE`
21
+ - `source/` — the minimal stub the grader calls, plus a `README.md` saying
22
+ what is broken and how to run it. No working implementation.
23
+ - `._tests/test_1.py` — the `testCases` class
24
+ - `._course_data/course_toc.json` and `feature.md` — the candidate brief
25
+ - `setup.sh` at the pack root if dependencies are needed
26
+
27
+ 3. **Write the reference solution** in `challenges/<slug>/../solution/` — a
28
+ sibling of the pack, never inside it.
29
+
30
+ 4. **Validate without asking the user to choose tiers.** Run
31
+ `codepraxis validate --local <slug>` and iterate until the solution passes
32
+ everything and the starter passes nothing. Then publish with
33
+ `codepraxis --publish <slug>`; the CLI runs the authoritative remote
34
+ validation before it publishes.
35
+
36
+ 5. **Publish and return the URL.** When `codepraxis --publish <slug>` succeeds,
37
+ copy the container URL from its output into your final answer so the user can
38
+ open the question and inspect it.
39
+
40
+ Consult the `pack-authoring` skill for the `testCases` contract. The two
41
+ mistakes worth checking before you finish: a starter that already passes, and a
42
+ two-argument `__init__`.
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from codepraxis.commands import catalog
4
+
5
+
6
+ class FakeClient:
7
+ def __init__(self) -> None:
8
+ self.calls = []
9
+
10
+ def get(self, path: str) -> dict:
11
+ self.calls.append(("GET", path))
12
+ return {
13
+ "challenges": [
14
+ {
15
+ "challenge_id": 7,
16
+ "challenge_name": "Debug webhooks",
17
+ "status": "draft",
18
+ "challenge_version_id": 11,
19
+ }
20
+ ]
21
+ }
22
+
23
+ def patch_json(self, path: str, payload: dict) -> dict:
24
+ self.calls.append(("PATCH", path, payload))
25
+ return {"challenge": {"challenge_id": 7, "challenge_name": payload["challenge_name"]}}
26
+
27
+ def post_json(self, path: str, payload: dict) -> dict:
28
+ self.calls.append(("POST", path, payload))
29
+ return {"container_url": "https://container.example/?folder=/home/praxis/debug_webhooks"}
30
+
31
+
32
+ def test_lists_company_questions(capsys):
33
+ client = FakeClient()
34
+
35
+ assert catalog.list_questions(client=client) == 0
36
+
37
+ assert client.calls == [("GET", "/challenges")]
38
+ output = capsys.readouterr().out
39
+ assert "Debug webhooks" in output
40
+ assert "draft" in output
41
+
42
+
43
+ def test_edit_updates_metadata_and_prints_container_url(capsys):
44
+ client = FakeClient()
45
+
46
+ assert catalog.edit_question(
47
+ challenge_id=7,
48
+ updates={"challenge_name": "Debug queues", "description": None},
49
+ client=client,
50
+ ) == 0
51
+
52
+ assert client.calls == [
53
+ ("PATCH", "/challenges/7", {"challenge_name": "Debug queues"}),
54
+ ("POST", "/challenges/7/setup-codebase", {}),
55
+ ]
56
+ output = capsys.readouterr().out
57
+ assert "Updated question 7" in output
58
+ assert "https://container.example" in output
59
+
60
+
61
+ def test_edit_can_open_without_metadata_changes(capsys):
62
+ client = FakeClient()
63
+
64
+ assert catalog.edit_question(challenge_id=7, updates={}, client=client) == 0
65
+
66
+ assert client.calls == [("POST", "/challenges/7/setup-codebase", {})]
67
+ assert "Opening question 7" in capsys.readouterr().out
@@ -203,3 +203,117 @@ def test_live_publishes_immediately(tmp_path: Path):
203
203
  )
204
204
 
205
205
  assert client.posted[0][1]["status"] == "published"
206
+
207
+
208
+ def test_prints_container_url_when_platform_returns_one(tmp_path: Path, capsys):
209
+ build_pack(tmp_path)
210
+
211
+ class PreviewClient(FakeClient):
212
+ def post_json(self, path: str, payload: dict) -> dict:
213
+ self.posted.append((path, payload))
214
+ return {
215
+ "challenge_id": 7,
216
+ "challenge_version_id": 9,
217
+ "container_url": "https://container.example/?folder=/home/praxis/demo",
218
+ }
219
+
220
+ publish.run(
221
+ root=tmp_path,
222
+ selector="demo",
223
+ reporter=NullReporter(),
224
+ assume_yes=True,
225
+ validation_run_id="vr_1",
226
+ client=PreviewClient(),
227
+ )
228
+
229
+ assert "https://container.example" in capsys.readouterr().out
230
+
231
+
232
+ class TestRepublishingAnEdit:
233
+ """Editing a pack and publishing again must update, not duplicate.
234
+
235
+ Without a challenge_id the platform creates a new question every time, which
236
+ is how a typo fix used to leave two copies in the catalog.
237
+ """
238
+
239
+ def test_challenge_id_is_sent_when_given(self, tmp_path):
240
+ build_pack(tmp_path)
241
+ client = FakeClient()
242
+
243
+ publish.run(
244
+ root=tmp_path,
245
+ selector="demo",
246
+ reporter=NullReporter(),
247
+ assume_yes=True,
248
+ validation_run_id="vr_1",
249
+ challenge_id=64,
250
+ client=client,
251
+ )
252
+
253
+ assert client.posted[0][1]["challenge_id"] == 64
254
+
255
+ def test_no_challenge_id_means_create(self, tmp_path):
256
+ build_pack(tmp_path)
257
+ client = FakeClient()
258
+
259
+ publish.run(
260
+ root=tmp_path,
261
+ selector="demo",
262
+ reporter=NullReporter(),
263
+ assume_yes=True,
264
+ validation_run_id="vr_1",
265
+ client=client,
266
+ )
267
+
268
+ assert "challenge_id" not in client.posted[0][1]
269
+
270
+ def test_output_says_updated_when_the_platform_reused_a_question(self, tmp_path, capsys):
271
+ build_pack(tmp_path)
272
+ client = FakeClient()
273
+ client.post_json = lambda path, payload: {
274
+ "challenge_id": 64,
275
+ "challenge_version_id": 91,
276
+ "created": False,
277
+ }
278
+
279
+ publish.run(
280
+ root=tmp_path,
281
+ selector="demo",
282
+ reporter=NullReporter(),
283
+ assume_yes=True,
284
+ validation_run_id="vr_1",
285
+ challenge_id=64,
286
+ client=client,
287
+ )
288
+
289
+ output = capsys.readouterr().out
290
+ assert "updated" in output
291
+ assert "published for" not in output
292
+
293
+
294
+ class TestDelete:
295
+ class Client:
296
+ def __init__(self, payload=None):
297
+ self.deleted = []
298
+ self.payload = payload or {"challenge_id": 7, "versions_removed": 2}
299
+
300
+ def delete(self, path):
301
+ self.deleted.append(path)
302
+ return self.payload
303
+
304
+ def test_deletes_after_confirmation_is_waived(self, capsys):
305
+ from codepraxis.commands import catalog
306
+
307
+ client = self.Client()
308
+ assert catalog.delete_question(7, client=client, assume_yes=True) == 0
309
+ assert client.deleted == ["/challenges/7"]
310
+ assert "Deleted question 7" in capsys.readouterr().out
311
+
312
+ def test_refuses_without_a_terminal_and_without_yes(self):
313
+ from codepraxis.commands import catalog
314
+ from codepraxis.errors import PraxisError
315
+
316
+ client = self.Client()
317
+ with pytest.raises(PraxisError, match="not a terminal"):
318
+ catalog.delete_question(7, client=client)
319
+ assert client.deleted == []
@@ -1,32 +0,0 @@
1
- ---
2
- description: Scaffold a new CodePraxis challenge pack from a description
3
- argument-hint: "<what the challenge should test>"
4
- allowed-tools: Bash(codepraxis validate:*), Read, Write, Edit, Glob
5
- ---
6
-
7
- Create a new CodePraxis challenge pack for: **$ARGUMENTS**
8
-
9
- Build it in this order, and validate before you claim it is done.
10
-
11
- 1. **Decide the shape.** A realistic engineering task with one obvious entry
12
- point, concrete sample input, and an output contract a test can check
13
- automatically. Not a toy exercise, not a puzzle.
14
-
15
- 2. **Write the pack** under `challenges/<slug>/`:
16
- - `metadata.json` — a unique snake_case `name`
17
- - `backend.conf` — `BACKEND` and `LANGUAGE`
18
- - `source/` — the minimal stub the grader calls, plus a `README.md` saying
19
- what is broken and how to run it. No working implementation.
20
- - `._tests/test_1.py` — the `testCases` class
21
- - `._course_data/course_toc.json` and `feature.md` — the candidate brief
22
- - `setup.sh` at the pack root if dependencies are needed
23
-
24
- 3. **Write the reference solution** in `challenges/<slug>/../solution/` — a
25
- sibling of the pack, never inside it.
26
-
27
- 4. **Validate**: `codepraxis validate --local <slug>`. Iterate until the
28
- solution passes everything and the starter passes nothing.
29
-
30
- Consult the `pack-authoring` skill for the `testCases` contract. The two
31
- mistakes worth checking before you finish: a starter that already passes, and a
32
- two-argument `__init__`.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes