codeer-cli 0.1.4__tar.gz → 0.1.6__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 (28) hide show
  1. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/API_REFERENCE.md +50 -7
  2. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/PKG-INFO +23 -5
  3. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/README.md +22 -4
  4. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/pyproject.toml +1 -1
  5. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/cli.py +7 -2
  6. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/eval_cmd.py +275 -5
  7. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/kb.py +122 -11
  8. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/eval_.py +46 -0
  9. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/kb.py +10 -0
  10. codeer_cli-0.1.6/tests/test_eval_labels.py +101 -0
  11. codeer_cli-0.1.6/tests/test_kb_nodes.py +63 -0
  12. codeer_cli-0.1.6/tests/test_kb_ranges.py +35 -0
  13. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/uv.lock +1 -1
  14. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/.gitignore +0 -0
  15. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/__init__.py +0 -0
  16. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/_validate.py +0 -0
  17. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/agents.py +0 -0
  18. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/chats.py +0 -0
  19. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/client.py +0 -0
  20. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/__init__.py +0 -0
  21. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/_util.py +0 -0
  22. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/agent.py +0 -0
  23. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/check.py +0 -0
  24. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/history.py +0 -0
  25. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/commands/profile.py +0 -0
  26. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/constants.py +0 -0
  27. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/histories.py +0 -0
  28. {codeer_cli-0.1.4 → codeer_cli-0.1.6}/src/codeer_cli/parse.py +0 -0
@@ -81,23 +81,46 @@ Base path: `/external/context-object-faqs`
81
81
  Create body:
82
82
 
83
83
  ```json
84
- {"context_object_id": 123, "question": "How do I reset billing?", "ranges": [{"start_line": 12, "end_line": 18}]}
84
+ {
85
+ "context_object_id": 123,
86
+ "question": "How do I reset billing?",
87
+ "ranges": [
88
+ {
89
+ "start_line": 12,
90
+ "start_column": 0,
91
+ "end_line": 12,
92
+ "end_column": 42
93
+ }
94
+ ]
95
+ }
85
96
  ```
86
97
 
87
98
  Update body accepts any of these fields:
88
99
 
89
100
  ```json
90
- {"context_object_id": 456, "question": "How do I update billing?", "ranges": [{"start_line": 20, "end_line": 24}]}
101
+ {
102
+ "context_object_id": 456,
103
+ "question": "How do I update billing?",
104
+ "ranges": [
105
+ {
106
+ "start_line": 20,
107
+ "start_column": 0,
108
+ "end_line": 20,
109
+ "end_column": 39
110
+ }
111
+ ]
112
+ }
91
113
  ```
92
114
 
93
115
  `context_object_id` is the KB file's `snapshot_object_id` from the KB node
94
116
  listing. `ranges` is optional; use it when the FAQ route should reserve chunks
95
- overlapping a stable line range inside that file. The compact CLI output includes
96
- the target id:
117
+ overlapping a stable passage inside that file. Include both line and column
118
+ positions so the Codeer UI can map the range onto rendered Markdown. The compact
119
+ CLI output includes the target id:
97
120
 
98
121
  ```bash
99
122
  codeer kb files --kb-id <kb-id>
100
- codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --range 12:18 --dry-run
123
+ codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --range 12:0-12:42 --dry-run
101
124
  ```
102
125
 
103
126
  ## Stage 3 — Live Test on a specific version
@@ -126,10 +149,14 @@ for the apply → test → publish workflow. Pass the draft `AgentHistory.id` fr
126
149
 
127
150
  | Method & path | Purpose |
128
151
  | --- | --- |
129
- | `POST /eval/cases` | Create case (`input`, `expected_output?`, `rubric?`); rubric = user-docs "Standard" |
152
+ | `GET /eval/workspaces/{workspace_id}/case-labels` | List reusable eval case labels |
153
+ | `POST /eval/workspaces/{workspace_id}/case-labels` | Create reusable eval case label (`name`, `color?`) |
154
+ | `PUT /eval/case-labels/{label_id}` | Update eval case label name/color |
155
+ | `DELETE /eval/case-labels/{label_id}` | Delete eval case label and clear associations |
156
+ | `POST /eval/cases` | Create case (`input`, `expected_output?`, `rubric?`, `label_ids?`); rubric = user-docs "Standard" |
130
157
  | `GET /eval/agents/{agent_id}/cases` | List cases for an agent |
131
158
  | `GET /eval/cases/{case_id}` | Read one |
132
- | `PUT /eval/cases/{case_id}` | Update |
159
+ | `PUT /eval/cases/{case_id}` | Update, including replacing labels via `label_ids` |
133
160
  | `DELETE /eval/cases/{case_id}` | Delete |
134
161
  | `POST /eval/cases/upload-csv` | Bulk import |
135
162
  | `POST /eval/cases/bulk` | Bulk delete |
@@ -142,6 +169,22 @@ for the apply → test → publish workflow. Pass the draft `AgentHistory.id` fr
142
169
  | `POST /eval/rubric` | Set/override the rubric for one (case, evaluator) — write-only |
143
170
  | `POST /eval/rubrics/batch` | **Read** rubrics for a batch of (case, evaluator) pairs |
144
171
 
172
+ Eval case labels are workspace-scoped reusable objects. The case create/update
173
+ payload uses `label_ids` (stringified label IDs), not freeform label names:
174
+
175
+ ```json
176
+ {
177
+ "agent_id": "<agent_uuid>",
178
+ "input": "How do I return an item?",
179
+ "expected_output": "Explain the return policy.",
180
+ "label_ids": ["12", "13"]
181
+ }
182
+ ```
183
+
184
+ Send `label_ids: []` on update to clear all labels from a case. The
185
+ `codeer eval cases-apply` manifest can resolve label names through a separate
186
+ `labels` array; the legacy `label` field remains a local review/display label.
187
+
145
188
  ## Stage 6 — Diagnose + update
146
189
 
147
190
  | Method & path | Purpose |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeer-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Command line tools for managing Codeer agents over the Codeer API.
5
5
  Project-URL: Homepage, https://www.codeer.ai
6
6
  Author: Codeer.AI
@@ -180,19 +180,37 @@ paths containing `*` so the shell passes the wildcard to the CLI. Advanced
180
180
  settings can still be passed through `--config-json`; explicit crawler flags
181
181
  override matching JSON keys.
182
182
 
183
+ ## KB node rename and delete
184
+
185
+ Knowledge Base roots, folders, and files are all KnowledgeNodes. Use
186
+ `codeer kb list` and `codeer kb files` to find node IDs, then preview mutations
187
+ with `--dry-run`:
188
+
189
+ ```bash
190
+ codeer kb node-rename --node-id <node-id> --name "New Name" --dry-run
191
+ codeer kb node-delete --node-id <node-id> --dry-run
192
+ ```
193
+
194
+ `node-delete` deletes the target node and all descendants. Review the dry-run
195
+ output before rerunning without `--dry-run`.
196
+
183
197
  ## Context Object FAQ
184
198
 
185
199
  Use Context Object FAQ entries to route high-value questions to a canonical KB
186
200
  file when semantic retrieval misses the right source. The FAQ target is a KB
187
- file's `snapshot_object_id`, shown by `codeer kb files`. Add `--range
188
- START_LINE:END_LINE` when the route should reserve chunks overlapping a stable
189
- line range inside that file.
201
+ file's `snapshot_object_id`, shown by `codeer kb files`. Add `--range` when the
202
+ route should reserve a stable passage inside that file. Ranges must include both
203
+ line and column positions so the Codeer UI can map them onto rendered Markdown.
190
204
 
191
205
  ```bash
192
206
  codeer kb files --kb-id <kb-id>
193
207
  codeer kb faq-list --context-object-id <snapshot-object-id>
194
- codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --range 12:18 --dry-run
208
+ codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --range 12:0-12:42 --dry-run
209
+ codeer kb faq-update <faq-id> --range 12:0-12:42 --dry-run
195
210
  ```
196
211
 
212
+ `--range` accepts `START_LINE:START_COLUMN-END_LINE:END_COLUMN`; repeat it to
213
+ reserve multiple passages.
214
+
197
215
  After reviewing the dry-run output, rerun the create/update/delete command
198
216
  without `--dry-run` to apply it.
@@ -162,19 +162,37 @@ paths containing `*` so the shell passes the wildcard to the CLI. Advanced
162
162
  settings can still be passed through `--config-json`; explicit crawler flags
163
163
  override matching JSON keys.
164
164
 
165
+ ## KB node rename and delete
166
+
167
+ Knowledge Base roots, folders, and files are all KnowledgeNodes. Use
168
+ `codeer kb list` and `codeer kb files` to find node IDs, then preview mutations
169
+ with `--dry-run`:
170
+
171
+ ```bash
172
+ codeer kb node-rename --node-id <node-id> --name "New Name" --dry-run
173
+ codeer kb node-delete --node-id <node-id> --dry-run
174
+ ```
175
+
176
+ `node-delete` deletes the target node and all descendants. Review the dry-run
177
+ output before rerunning without `--dry-run`.
178
+
165
179
  ## Context Object FAQ
166
180
 
167
181
  Use Context Object FAQ entries to route high-value questions to a canonical KB
168
182
  file when semantic retrieval misses the right source. The FAQ target is a KB
169
- file's `snapshot_object_id`, shown by `codeer kb files`. Add `--range
170
- START_LINE:END_LINE` when the route should reserve chunks overlapping a stable
171
- line range inside that file.
183
+ file's `snapshot_object_id`, shown by `codeer kb files`. Add `--range` when the
184
+ route should reserve a stable passage inside that file. Ranges must include both
185
+ line and column positions so the Codeer UI can map them onto rendered Markdown.
172
186
 
173
187
  ```bash
174
188
  codeer kb files --kb-id <kb-id>
175
189
  codeer kb faq-list --context-object-id <snapshot-object-id>
176
- codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --range 12:18 --dry-run
190
+ codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --range 12:0-12:42 --dry-run
191
+ codeer kb faq-update <faq-id> --range 12:0-12:42 --dry-run
177
192
  ```
178
193
 
194
+ `--range` accepts `START_LINE:START_COLUMN-END_LINE:END_COLUMN`; repeat it to
195
+ reserve multiple passages.
196
+
179
197
  After reviewing the dry-run output, rerun the create/update/delete command
180
198
  without `--dry-run` to apply it.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codeer-cli"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "Command line tools for managing Codeer agents over the Codeer API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,8 +2,8 @@
2
2
 
3
3
  codeer check
4
4
  codeer agent list|get|apply|diff|versions
5
- codeer kb list|files|upload|faq-list|faq-get|faq-create|faq-update|faq-delete
6
- codeer eval list|case-update|case-delete|evaluators|evaluator-create|evaluator-update|run|export|reconcile|cases-apply|rubrics|rubrics-apply
5
+ codeer kb list|files|upload|node-rename|node-delete|faq-list|faq-get|faq-create|faq-update|faq-delete
6
+ codeer eval list|label-list|label-create|label-update|label-delete|case-update|case-delete|evaluators|evaluator-create|evaluator-update|run|export|reconcile|cases-apply|rubrics|rubrics-apply
7
7
  codeer history list|get|conversations|negative-feedback
8
8
  """
9
9
 
@@ -28,7 +28,9 @@ Safe workflow for coding agents:
28
28
  codeer agent list
29
29
  codeer agent get <agent-id> --full
30
30
  codeer kb list
31
+ codeer kb files --kb-id <kb-id>
31
32
  codeer eval list --agent <agent-id>
33
+ codeer eval label-list
32
34
  codeer eval case-update --case <case-id> --input "..." --dry-run
33
35
  codeer eval evaluators
34
36
  codeer agent diff --agent <agent-id> --from-version <n> --to-version <n>
@@ -37,10 +39,13 @@ Safe workflow for coding agents:
37
39
  Preview mutations before applying:
38
40
  codeer agent apply --payload agent.json --dry-run
39
41
  codeer eval case-update --case <case-id> --input "..." --dry-run
42
+ codeer eval label-create --name "routing" --color "#0969da" --dry-run
40
43
  codeer eval case-delete --case <case-id> --dry-run
41
44
  codeer eval cases-apply --agent <agent-id> --cases eval_cases.json --dry-run
42
45
  codeer eval rubrics-apply --rubrics rubrics.json --dry-run
43
46
  codeer kb upload --dir kb --name "Product KB" --dry-run
47
+ codeer kb node-rename --node-id <node-id> --name "New Name" --dry-run
48
+ codeer kb node-delete --node-id <node-id> --dry-run
44
49
  codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --dry-run
45
50
 
46
51
  Use --out <path> for large raw artifacts; stdout defaults to compact summaries.
@@ -46,6 +46,34 @@ def register(subparsers):
46
46
  help="Write stripped full case payloads to this file; stdout stays compact unless --full.")
47
47
  p.set_defaults(func=run_list)
48
48
 
49
+ # codeer eval label-list/create/update/delete
50
+ p = sub.add_parser("label-list", help="List eval case labels in the workspace")
51
+ p.add_argument("--workspace", default=None, help="Workspace UUID (default: active API-key workspace)")
52
+ p.add_argument("--out", default=None)
53
+ p.set_defaults(func=run_label_list)
54
+
55
+ p = sub.add_parser("label-create", help="Create an eval case label; run --dry-run first")
56
+ p.add_argument("--name", required=True)
57
+ p.add_argument("--color", default=None, help="Hex color like #0969da (default: server default)")
58
+ p.add_argument("--workspace", default=None, help="Workspace UUID (default: active API-key workspace)")
59
+ p.add_argument("--dry-run", action="store_true")
60
+ p.add_argument("--out", default=None)
61
+ p.set_defaults(func=run_label_create)
62
+
63
+ p = sub.add_parser("label-update", help="Update an eval case label; run --dry-run first")
64
+ p.add_argument("--label", required=True, dest="label_id", help="Eval case label ID")
65
+ p.add_argument("--name", default=None)
66
+ p.add_argument("--color", default=None, help="Hex color like #0969da")
67
+ p.add_argument("--dry-run", action="store_true")
68
+ p.add_argument("--out", default=None)
69
+ p.set_defaults(func=run_label_update)
70
+
71
+ p = sub.add_parser("label-delete", help="Delete an eval case label; run --dry-run first")
72
+ p.add_argument("--label", required=True, dest="label_id", help="Eval case label ID")
73
+ p.add_argument("--dry-run", action="store_true")
74
+ p.add_argument("--out", default=None)
75
+ p.set_defaults(func=run_label_delete)
76
+
49
77
  # codeer eval case-update
50
78
  p = sub.add_parser("case-update", help="Update one eval case by UUID; run --dry-run first")
51
79
  p.add_argument("--case", required=True, dest="case_id", help="Eval case UUID")
@@ -66,6 +94,11 @@ def register(subparsers):
66
94
  g.add_argument("--meta-file", help="Path to new case meta JSON object")
67
95
  p.add_argument("--attachment-ids", default=None,
68
96
  help="Comma-separated file UUIDs to set as the case attachments")
97
+ g = p.add_mutually_exclusive_group()
98
+ g.add_argument("--label-ids", default=None,
99
+ help="Comma-separated eval case label IDs to set on the case")
100
+ g.add_argument("--clear-labels", action="store_true",
101
+ help="Remove all labels from the case")
69
102
  p.add_argument("--dry-run", action="store_true",
70
103
  help="Validate inputs and print intended mutation without writing server state.")
71
104
  p.add_argument("--out", default=None)
@@ -154,6 +187,8 @@ def register(subparsers):
154
187
  p.add_argument("--agent", required=True)
155
188
  p.add_argument("--attachments-dir", default=None, dest="attachments_dir")
156
189
  p.add_argument("--allow-duplicates", action="store_true")
190
+ p.add_argument("--create-labels", action="store_true",
191
+ help="Create missing labels referenced by manifest case labels.")
157
192
  p.add_argument("--dry-run", action="store_true",
158
193
  help="Validate manifest and print intended mutations without writing server state.")
159
194
  p.add_argument("--out", default=None)
@@ -184,6 +219,7 @@ def register(subparsers):
184
219
  # ---------------------------------------------------------------------------
185
220
 
186
221
  def _case_summary(case: dict, *, full: bool = False) -> dict:
222
+ labels = case.get("labels") or []
187
223
  row = {
188
224
  "id": case.get("id"),
189
225
  "input_preview": truncate(case.get("input") or "", 240 if full else 80),
@@ -191,6 +227,11 @@ def _case_summary(case: dict, *, full: bool = False) -> dict:
191
227
  "expected_output_chars": len(case.get("expected_output") or ""),
192
228
  "note_preview": truncate(case.get("note") or "", 180 if full else 100),
193
229
  "attachment_count": len(case.get("attachments") or case.get("attachment_ids") or []),
230
+ "labels": [
231
+ {"id": label.get("id"), "name": label.get("name"), "color": label.get("color")}
232
+ for label in labels
233
+ if isinstance(label, dict)
234
+ ],
194
235
  }
195
236
  if full:
196
237
  row["created_at"] = case.get("created_at")
@@ -239,6 +280,116 @@ def run_list(args, client) -> int:
239
280
  return 0
240
281
 
241
282
 
283
+ # ---------------------------------------------------------------------------
284
+ # eval case labels
285
+ # ---------------------------------------------------------------------------
286
+
287
+ def _workspace_arg_or_default(client, workspace_id: str | None) -> str:
288
+ if workspace_id:
289
+ return workspace_id
290
+ ws, _ = client.resolve_scope()
291
+ return ws
292
+
293
+
294
+ def _label_summary(label: dict) -> dict:
295
+ return {
296
+ "id": label.get("id"),
297
+ "name": label.get("name"),
298
+ "color": label.get("color"),
299
+ "workspace_id": label.get("workspace_id"),
300
+ }
301
+
302
+
303
+ def run_label_list(args, client) -> int:
304
+ workspace_id = _workspace_arg_or_default(client, args.workspace)
305
+ labels = eval_mod.list_case_labels(client, workspace_id=workspace_id)
306
+ out = {
307
+ "workspace_id": workspace_id,
308
+ "label_count": len(labels),
309
+ "labels": [_label_summary(label) for label in labels],
310
+ }
311
+ print_json(out)
312
+ write_json(args.out, out)
313
+ return 0
314
+
315
+
316
+ def run_label_create(args, client) -> int:
317
+ workspace_id = _workspace_arg_or_default(client, args.workspace)
318
+ if args.dry_run:
319
+ out = {
320
+ "dry_run": True,
321
+ "operation": "label_create",
322
+ "method": "POST",
323
+ "path": f"/eval/workspaces/{workspace_id}/case-labels",
324
+ "workspace_id": workspace_id,
325
+ "name": args.name,
326
+ "color": args.color,
327
+ "would_write_server_state": True,
328
+ "next_step": "Review this summary, then rerun without --dry-run after approval.",
329
+ }
330
+ print_json(out)
331
+ write_json(args.out, out)
332
+ return 0
333
+
334
+ label = eval_mod.create_case_label(
335
+ client, workspace_id=workspace_id, name=args.name, color=args.color
336
+ )
337
+ out = _label_summary(strip_noisy_fields(label))
338
+ print_json(out)
339
+ write_json(args.out, out)
340
+ return 0
341
+
342
+
343
+ def run_label_update(args, client) -> int:
344
+ if args.name is None and args.color is None:
345
+ log("error: provide --name and/or --color")
346
+ return 2
347
+
348
+ if args.dry_run:
349
+ out = {
350
+ "dry_run": True,
351
+ "operation": "label_update",
352
+ "method": "PUT",
353
+ "path": f"/eval/case-labels/{args.label_id}",
354
+ "label_id": args.label_id,
355
+ "updates": {"name": args.name, "color": args.color},
356
+ "would_write_server_state": True,
357
+ "next_step": "Review this summary, then rerun without --dry-run after approval.",
358
+ }
359
+ print_json(out)
360
+ write_json(args.out, out)
361
+ return 0
362
+
363
+ label = eval_mod.update_case_label(
364
+ client, label_id=args.label_id, name=args.name, color=args.color
365
+ )
366
+ out = _label_summary(strip_noisy_fields(label))
367
+ print_json(out)
368
+ write_json(args.out, out)
369
+ return 0
370
+
371
+
372
+ def run_label_delete(args, client) -> int:
373
+ if args.dry_run:
374
+ out = {
375
+ "dry_run": True,
376
+ "operation": "label_delete",
377
+ "method": "DELETE",
378
+ "path": f"/eval/case-labels/{args.label_id}",
379
+ "label_id": args.label_id,
380
+ "would_write_server_state": True,
381
+ "next_step": "Review this summary, then rerun without --dry-run after approval.",
382
+ }
383
+ print_json(out)
384
+ write_json(args.out, out)
385
+ return 0
386
+
387
+ deleted = strip_noisy_fields(eval_mod.delete_case_label(client, label_id=args.label_id))
388
+ print_json(deleted)
389
+ write_json(args.out, deleted)
390
+ return 0
391
+
392
+
242
393
  # ---------------------------------------------------------------------------
243
394
  # eval case-update / case-delete
244
395
  # ---------------------------------------------------------------------------
@@ -274,16 +425,17 @@ def run_case_update(args, client) -> int:
274
425
  log(f"error: {e}")
275
426
  return 2
276
427
  attachment_ids = _ids(args.attachment_ids)
428
+ label_ids = [] if args.clear_labels else _ids(args.label_ids)
277
429
 
278
430
  has_update = any(
279
431
  value is not None
280
- for value in (input_text, expected_output, rubric, note, meta, attachment_ids)
281
- )
432
+ for value in (input_text, expected_output, rubric, note, meta, attachment_ids, label_ids)
433
+ ) or args.clear_labels
282
434
  if not has_update:
283
435
  log(
284
436
  "error: provide at least one of --input, --input-file, --expected-output, "
285
437
  "--expected-output-file, --rubric, --rubric-file, --note, --note-file, "
286
- "--meta-json, --meta-file, --attachment-ids"
438
+ "--meta-json, --meta-file, --attachment-ids, --label-ids, --clear-labels"
287
439
  )
288
440
  return 2
289
441
 
@@ -305,6 +457,7 @@ def run_case_update(args, client) -> int:
305
457
  "note_chars": len(note) if note is not None else None,
306
458
  "meta": meta,
307
459
  "attachment_ids": attachment_ids,
460
+ "label_ids": label_ids,
308
461
  },
309
462
  "would_write_server_state": True,
310
463
  "next_step": "Review this summary, then rerun without --dry-run after approval.",
@@ -320,6 +473,7 @@ def run_case_update(args, client) -> int:
320
473
  expected_output=expected_output,
321
474
  rubric=rubric,
322
475
  attachment_ids=attachment_ids,
476
+ label_ids=label_ids,
323
477
  meta=meta,
324
478
  note=note,
325
479
  )
@@ -941,6 +1095,50 @@ def _upload_attachment(client: CodeerClient, *, file_path: Path, workspace_id: s
941
1095
  return uuid
942
1096
 
943
1097
 
1098
+ def _manifest_label_names(case: dict) -> list[str]:
1099
+ raw = case.get("labels")
1100
+ if raw is None:
1101
+ return []
1102
+ if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
1103
+ raise ValueError(f"case '{case.get('label')}' labels must be a list of label names")
1104
+ return [item.strip() for item in raw if item.strip()]
1105
+
1106
+
1107
+ def _manifest_label_ids(case: dict) -> list[str] | None:
1108
+ raw = case.get("label_ids")
1109
+ if raw is None:
1110
+ return None
1111
+ if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
1112
+ raise ValueError(f"case '{case.get('label')}' label_ids must be a list of label ID strings")
1113
+ return [item.strip() for item in raw if item.strip()]
1114
+
1115
+
1116
+ def _dedupe_preserve_order(items: list[str]) -> list[str]:
1117
+ seen: set[str] = set()
1118
+ out: list[str] = []
1119
+ for item in items:
1120
+ if item in seen:
1121
+ continue
1122
+ seen.add(item)
1123
+ out.append(item)
1124
+ return out
1125
+
1126
+
1127
+ def _resolve_case_label_ids(case: dict, labels_by_name: dict[str, dict]) -> tuple[list[str] | None, list[str]]:
1128
+ explicit_ids = _manifest_label_ids(case)
1129
+ label_names = _manifest_label_names(case)
1130
+ if explicit_ids is None and not label_names:
1131
+ return None, []
1132
+
1133
+ resolved_ids = list(explicit_ids or [])
1134
+ for name in label_names:
1135
+ label = labels_by_name.get(name.casefold())
1136
+ if label is None:
1137
+ raise ValueError(f"case '{case.get('label')}' references unknown label '{name}'")
1138
+ resolved_ids.append(str(label["id"]))
1139
+ return _dedupe_preserve_order(resolved_ids), label_names
1140
+
1141
+
944
1142
  def run_cases_apply(args, client) -> int:
945
1143
  payload = json.loads(Path(args.cases).read_text())
946
1144
  cases = payload.get("cases") or []
@@ -961,6 +1159,53 @@ def run_cases_apply(args, client) -> int:
961
1159
  return 2
962
1160
 
963
1161
  workspace_id, _ = client.resolve_scope()
1162
+ try:
1163
+ manifest_label_names = sorted({
1164
+ name
1165
+ for case in cases
1166
+ for name in _manifest_label_names(case)
1167
+ }, key=str.casefold)
1168
+ for case in cases:
1169
+ _manifest_label_ids(case)
1170
+ except ValueError as e:
1171
+ log(f"error: {e}")
1172
+ return 2
1173
+
1174
+ labels_by_name: dict[str, dict] = {}
1175
+ created_labels: list[dict] = []
1176
+ would_create_labels: list[str] = []
1177
+ if manifest_label_names:
1178
+ labels_by_name = {
1179
+ (label.get("name") or "").casefold(): label
1180
+ for label in eval_mod.list_case_labels(client, workspace_id=workspace_id)
1181
+ if label.get("name")
1182
+ }
1183
+ missing_label_names = [
1184
+ name for name in manifest_label_names
1185
+ if name.casefold() not in labels_by_name
1186
+ ]
1187
+ if missing_label_names and not args.create_labels:
1188
+ log(
1189
+ "error: manifest references missing labels: "
1190
+ + ", ".join(missing_label_names)
1191
+ + ". Create them first with `codeer eval label-create`, "
1192
+ + "or rerun cases-apply with --create-labels."
1193
+ )
1194
+ return 2
1195
+ if args.dry_run:
1196
+ would_create_labels = missing_label_names
1197
+ for name in missing_label_names:
1198
+ labels_by_name[name.casefold()] = {
1199
+ "id": f"(new:{name})",
1200
+ "name": name,
1201
+ "color": "#0969da",
1202
+ }
1203
+ else:
1204
+ for name in missing_label_names:
1205
+ log(f"creating label: {name}")
1206
+ label = eval_mod.create_case_label(client, workspace_id=workspace_id, name=name)
1207
+ labels_by_name[name.casefold()] = label
1208
+ created_labels.append(_label_summary(label))
964
1209
 
965
1210
  existing_by_input: dict[str, dict] = {}
966
1211
  if not args.allow_duplicates:
@@ -984,6 +1229,11 @@ def run_cases_apply(args, client) -> int:
984
1229
  return 2
985
1230
 
986
1231
  label = case.get("label", "(unlabeled)")
1232
+ try:
1233
+ case_label_ids, case_label_names = _resolve_case_label_ids(case, labels_by_name)
1234
+ except ValueError as e:
1235
+ log(f"error: {e}")
1236
+ return 2
987
1237
  attachment_ids: list[str] = []
988
1238
  for fname in case.get("attachment_files") or []:
989
1239
  fp = (attach_dir / fname).resolve() if attach_dir else None
@@ -1012,16 +1262,26 @@ def run_cases_apply(args, client) -> int:
1012
1262
  or attachment_ids
1013
1263
  or case.get("meta") is not None
1014
1264
  or case.get("note") is not None
1265
+ or case_label_ids is not None
1015
1266
  ),
1267
+ "labels": case_label_names,
1268
+ "label_ids": case_label_ids,
1016
1269
  "rubric_count": len(rubrics),
1017
1270
  })
1018
1271
  continue
1019
1272
  log(f"reusing existing case: {label} ({case_id[:8]})")
1020
- if case.get("expected_output") is not None or attachment_ids or case.get("meta") is not None or case.get("note") is not None:
1273
+ if (
1274
+ case.get("expected_output") is not None
1275
+ or attachment_ids
1276
+ or case.get("meta") is not None
1277
+ or case.get("note") is not None
1278
+ or case_label_ids is not None
1279
+ ):
1021
1280
  eval_mod.update_case(
1022
1281
  client, case_id,
1023
1282
  expected_output=case.get("expected_output"),
1024
1283
  attachment_ids=attachment_ids or None,
1284
+ label_ids=case_label_ids,
1025
1285
  meta=case.get("meta"),
1026
1286
  note=case.get("note"),
1027
1287
  )
@@ -1040,6 +1300,8 @@ def run_cases_apply(args, client) -> int:
1040
1300
  "input_chars": len(case.get("input") or ""),
1041
1301
  "expected_output_chars": len(case.get("expected_output") or ""),
1042
1302
  "attachment_count": len(attachment_ids),
1303
+ "labels": case_label_names,
1304
+ "label_ids": case_label_ids,
1043
1305
  "rubric_count": len(rubrics),
1044
1306
  })
1045
1307
  continue
@@ -1049,6 +1311,7 @@ def run_cases_apply(args, client) -> int:
1049
1311
  client, agent_id=args.agent, input=case["input"],
1050
1312
  expected_output=case.get("expected_output"),
1051
1313
  attachment_ids=attachment_ids or None,
1314
+ label_ids=case_label_ids,
1052
1315
  rubrics_by_evaluator=rubrics, meta=case.get("meta"),
1053
1316
  note=case.get("note"),
1054
1317
  )
@@ -1056,12 +1319,19 @@ def run_cases_apply(args, client) -> int:
1056
1319
  labels.append(label)
1057
1320
  created.append({"case_id": result["id"], "label": label})
1058
1321
 
1059
- out = {"case_ids": case_ids, "labels": labels, "created": created, "reused": reused}
1322
+ out = {
1323
+ "case_ids": case_ids,
1324
+ "labels": labels,
1325
+ "created": created,
1326
+ "reused": reused,
1327
+ "created_case_labels": created_labels,
1328
+ }
1060
1329
  if args.dry_run:
1061
1330
  out.update({
1062
1331
  "dry_run": True,
1063
1332
  "operation": "cases_apply",
1064
1333
  "agent_id": args.agent,
1334
+ "would_create_case_labels": would_create_labels,
1065
1335
  "updates": dry_run_updates,
1066
1336
  "would_write_server_state": True,
1067
1337
  "next_step": "Review this summary, then rerun without --dry-run after approval.",
@@ -87,6 +87,21 @@ def register(subparsers):
87
87
  p.add_argument("--poll-timeout", type=int, default=POLL_TIMEOUT)
88
88
  p.set_defaults(func=run_upload)
89
89
 
90
+ p = sub.add_parser("node-rename", help="Rename a KB root, folder, or file node; run --dry-run first")
91
+ p.add_argument("--node-id", required=True, help="KnowledgeNode UUID")
92
+ p.add_argument("--name", required=True, help="New display name")
93
+ p.add_argument("--dry-run", action="store_true",
94
+ help="Print intended request without writing server state.")
95
+ p.add_argument("--out", default=None, help="Write result JSON to this file too")
96
+ p.set_defaults(func=run_node_rename)
97
+
98
+ p = sub.add_parser("node-delete", help="Delete a KB root, folder, or file node and descendants; run --dry-run first")
99
+ p.add_argument("--node-id", required=True, help="KnowledgeNode UUID")
100
+ p.add_argument("--dry-run", action="store_true",
101
+ help="Print intended request without writing server state.")
102
+ p.add_argument("--out", default=None, help="Write result JSON to this file too")
103
+ p.set_defaults(func=run_node_delete)
104
+
90
105
  p = sub.add_parser("faq-list", help="List Context Object FAQ entries")
91
106
  p.add_argument("--context-object-id", type=int, default=None,
92
107
  help="Filter to a KB file snapshot_object_id")
@@ -107,8 +122,7 @@ def register(subparsers):
107
122
  p.add_argument("--context-object-id", type=int, required=True,
108
123
  help="KB file snapshot_object_id from `codeer kb files`")
109
124
  p.add_argument("--question", required=True)
110
- p.add_argument("--range", dest="ranges", action="append", type=_parse_faq_range, default=None,
111
- help="Reserve matching chunks that overlap START_LINE:END_LINE; repeatable")
125
+ _add_faq_range_args(p)
112
126
  p.add_argument("--dry-run", action="store_true",
113
127
  help="Print intended request without writing server state.")
114
128
  p.add_argument("--out", default=None, help="Write result JSON to this file too")
@@ -119,8 +133,7 @@ def register(subparsers):
119
133
  p.add_argument("--context-object-id", type=int, default=None,
120
134
  help="Move FAQ to a different KB file snapshot_object_id")
121
135
  p.add_argument("--question", default=None)
122
- p.add_argument("--range", dest="ranges", action="append", type=_parse_faq_range, default=None,
123
- help="Replace reserved ranges with START_LINE:END_LINE; repeatable")
136
+ _add_faq_range_args(p, verb="Replace")
124
137
  p.add_argument("--dry-run", action="store_true",
125
138
  help="Print intended request without writing server state.")
126
139
  p.add_argument("--out", default=None, help="Write result JSON to this file too")
@@ -217,18 +230,50 @@ def _dry_run(path: str | None, result: dict) -> int:
217
230
  return 0
218
231
 
219
232
 
233
+ def _add_faq_range_args(parser, *, verb: str = "Reserve") -> None:
234
+ parser.add_argument(
235
+ "--range",
236
+ dest="ranges",
237
+ action="append",
238
+ type=_parse_faq_range,
239
+ default=None,
240
+ help=(
241
+ f"{verb} matching passages as "
242
+ "START_LINE:START_COLUMN-END_LINE:END_COLUMN; repeatable"
243
+ ),
244
+ )
245
+
246
+
220
247
  def _parse_faq_range(value: str) -> dict[str, int]:
221
248
  try:
222
- start_raw, end_raw = value.split(":", 1)
223
- start_line = int(start_raw)
224
- end_line = int(end_raw)
249
+ start_raw, end_raw = value.split("-", 1)
250
+ start_line_raw, start_column_raw = start_raw.split(":", 1)
251
+ end_line_raw, end_column_raw = end_raw.split(":", 1)
252
+ faq_range = {
253
+ "start_line": int(start_line_raw),
254
+ "start_column": int(start_column_raw),
255
+ "end_line": int(end_line_raw),
256
+ "end_column": int(end_column_raw),
257
+ }
225
258
  except ValueError as exc:
226
- raise argparse.ArgumentTypeError("expected START_LINE:END_LINE") from exc
259
+ raise argparse.ArgumentTypeError(
260
+ "expected START_LINE:START_COLUMN-END_LINE:END_COLUMN"
261
+ ) from exc
262
+ _validate_faq_range_position(faq_range)
263
+ return faq_range
264
+
265
+
266
+ def _validate_faq_range_position(faq_range: dict[str, int]) -> None:
267
+ start_line = faq_range["start_line"]
268
+ end_line = faq_range["end_line"]
269
+ start_column = faq_range["start_column"]
270
+ end_column = faq_range["end_column"]
227
271
  if start_line < 1 or end_line < 1:
228
272
  raise argparse.ArgumentTypeError("line numbers must be >= 1")
229
- if end_line < start_line:
230
- raise argparse.ArgumentTypeError("END_LINE must be >= START_LINE")
231
- return {"start_line": start_line, "end_line": end_line}
273
+ if start_column < 0 or end_column < 0:
274
+ raise argparse.ArgumentTypeError("column numbers must be >= 0")
275
+ if (end_line, end_column) < (start_line, start_column):
276
+ raise argparse.ArgumentTypeError("end position must be >= start position")
232
277
 
233
278
 
234
279
  def _parse_config_json(config_json: str | None) -> dict | None:
@@ -383,6 +428,72 @@ def run_upload(args, client) -> int:
383
428
  return 0 if not not_ready else 1
384
429
 
385
430
 
431
+ def run_node_rename(args, client) -> int:
432
+ workspace_id, organization_id = client.resolve_scope()
433
+ path = f"/external/knowledge-bases/nodes/{args.node_id}"
434
+ body = {"name": args.name}
435
+ if args.dry_run:
436
+ return _dry_run(
437
+ args.out,
438
+ {
439
+ "dry_run": True,
440
+ "operation": "kb_node_rename",
441
+ "method": "PATCH",
442
+ "path": path,
443
+ "workspace_id": workspace_id,
444
+ "organization_id": organization_id,
445
+ "node_id": args.node_id,
446
+ "body": body,
447
+ "would_write_server_state": True,
448
+ "next_step": "Review this summary, then rerun without --dry-run after approval.",
449
+ },
450
+ )
451
+
452
+ response = strip_noisy_fields(
453
+ kb_mod.update_node(
454
+ client,
455
+ organization_id=organization_id,
456
+ workspace_id=workspace_id,
457
+ node_id=args.node_id,
458
+ name=args.name,
459
+ )
460
+ )
461
+ _print_and_write(args.out, response)
462
+ return 0
463
+
464
+
465
+ def run_node_delete(args, client) -> int:
466
+ workspace_id, organization_id = client.resolve_scope()
467
+ path = f"/external/knowledge-bases/nodes/{args.node_id}"
468
+ if args.dry_run:
469
+ return _dry_run(
470
+ args.out,
471
+ {
472
+ "dry_run": True,
473
+ "operation": "kb_node_delete",
474
+ "method": "DELETE",
475
+ "path": path,
476
+ "workspace_id": workspace_id,
477
+ "organization_id": organization_id,
478
+ "node_id": args.node_id,
479
+ "deletes_descendants": True,
480
+ "would_write_server_state": True,
481
+ "next_step": "Review this summary, then rerun without --dry-run after approval.",
482
+ },
483
+ )
484
+
485
+ response = strip_noisy_fields(
486
+ kb_mod.delete_node(
487
+ client,
488
+ organization_id=organization_id,
489
+ workspace_id=workspace_id,
490
+ node_id=args.node_id,
491
+ )
492
+ )
493
+ _print_and_write(args.out, response)
494
+ return 0
495
+
496
+
386
497
  def run_faq_list(args, client) -> int:
387
498
  faqs = kb_mod.list_context_obj_faqs(
388
499
  client,
@@ -22,6 +22,7 @@ def create_case(
22
22
  expected_output: Optional[str] = None,
23
23
  rubric: Optional[str] = None,
24
24
  attachment_ids: Optional[List[str]] = None,
25
+ label_ids: Optional[List[str]] = None,
25
26
  meta: Optional[dict] = None,
26
27
  note: Optional[str] = None,
27
28
  ) -> dict:
@@ -41,6 +42,8 @@ def create_case(
41
42
  body["rubric"] = rubric
42
43
  if attachment_ids:
43
44
  body["attachment_ids"] = attachment_ids
45
+ if label_ids is not None:
46
+ body["label_ids"] = label_ids
44
47
  if meta:
45
48
  body["meta"] = meta
46
49
  if note is not None:
@@ -64,6 +67,7 @@ def update_case(
64
67
  expected_output: Optional[str] = None,
65
68
  rubric: Optional[str] = None,
66
69
  attachment_ids: Optional[List[str]] = None,
70
+ label_ids: Optional[List[str]] = None,
67
71
  meta: Optional[dict] = None,
68
72
  note: Optional[str] = None,
69
73
  ) -> dict:
@@ -76,6 +80,8 @@ def update_case(
76
80
  body["rubric"] = rubric
77
81
  if attachment_ids is not None:
78
82
  body["attachment_ids"] = attachment_ids
83
+ if label_ids is not None:
84
+ body["label_ids"] = label_ids
79
85
  if meta is not None:
80
86
  body["meta"] = meta
81
87
  if note is not None:
@@ -87,6 +93,44 @@ def delete_case(client: CodeerClient, case_id: str) -> dict:
87
93
  return client.delete(f"/external/eval/cases/{case_id}")
88
94
 
89
95
 
96
+ # --- case labels -----------------------------------------------------------
97
+
98
+ def list_case_labels(client: CodeerClient, *, workspace_id: str) -> list[dict]:
99
+ return client.get(f"/eval/workspaces/{workspace_id}/case-labels")
100
+
101
+
102
+ def create_case_label(
103
+ client: CodeerClient,
104
+ *,
105
+ workspace_id: str,
106
+ name: str,
107
+ color: Optional[str] = None,
108
+ ) -> dict:
109
+ body: dict[str, Any] = {"name": name}
110
+ if color is not None:
111
+ body["color"] = color
112
+ return client.post(f"/eval/workspaces/{workspace_id}/case-labels", json=body)
113
+
114
+
115
+ def update_case_label(
116
+ client: CodeerClient,
117
+ *,
118
+ label_id: str,
119
+ name: Optional[str] = None,
120
+ color: Optional[str] = None,
121
+ ) -> dict:
122
+ body: dict[str, Any] = {}
123
+ if name is not None:
124
+ body["name"] = name
125
+ if color is not None:
126
+ body["color"] = color
127
+ return client.put(f"/eval/case-labels/{label_id}", json=body)
128
+
129
+
130
+ def delete_case_label(client: CodeerClient, *, label_id: str) -> dict:
131
+ return client.delete(f"/eval/case-labels/{label_id}")
132
+
133
+
90
134
 
91
135
  # --- evaluators -----------------------------------------------------------
92
136
 
@@ -397,6 +441,7 @@ def create_case_with_rubrics(
397
441
  rubrics_by_evaluator: dict[str, str],
398
442
  expected_output: Optional[str] = None,
399
443
  attachment_ids: Optional[List[str]] = None,
444
+ label_ids: Optional[List[str]] = None,
400
445
  meta: Optional[dict] = None,
401
446
  note: Optional[str] = None,
402
447
  ) -> dict:
@@ -416,6 +461,7 @@ def create_case_with_rubrics(
416
461
  input=input,
417
462
  expected_output=expected_output,
418
463
  attachment_ids=attachment_ids,
464
+ label_ids=label_ids,
419
465
  meta=meta,
420
466
  note=note,
421
467
  )
@@ -138,6 +138,16 @@ def update_node(
138
138
  return client.patch(f"{_base(organization_id, workspace_id)}/nodes/{node_id}", json=body)
139
139
 
140
140
 
141
+ def delete_node(
142
+ client: CodeerClient,
143
+ *,
144
+ organization_id: str,
145
+ workspace_id: str,
146
+ node_id: str,
147
+ ) -> dict:
148
+ return client.delete(f"{_base(organization_id, workspace_id)}/nodes/{node_id}")
149
+
150
+
141
151
  def upload_file(
142
152
  client: CodeerClient,
143
153
  *,
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from codeer_cli import eval_ as eval_mod
6
+ from codeer_cli.commands.eval_cmd import _resolve_case_label_ids
7
+
8
+
9
+ class FakeClient:
10
+ def __init__(self) -> None:
11
+ self.calls: list[tuple[str, str, dict]] = []
12
+
13
+ def get(self, path: str):
14
+ self.calls.append(("GET", path, {}))
15
+ return []
16
+
17
+ def post(self, path: str, **kwargs):
18
+ self.calls.append(("POST", path, kwargs))
19
+ return {"id": "created"}
20
+
21
+ def put(self, path: str, **kwargs):
22
+ self.calls.append(("PUT", path, kwargs))
23
+ return {"id": "updated"}
24
+
25
+ def delete(self, path: str, **kwargs):
26
+ self.calls.append(("DELETE", path, kwargs))
27
+ return {"ok": True}
28
+
29
+
30
+ class EvalLabelClientTests(unittest.TestCase):
31
+ def test_create_case_sends_label_ids(self) -> None:
32
+ client = FakeClient()
33
+
34
+ eval_mod.create_case(
35
+ client, # type: ignore[arg-type]
36
+ agent_id="agent-1",
37
+ input="How much?",
38
+ label_ids=["10", "11"],
39
+ )
40
+
41
+ self.assertEqual(client.calls[0][0], "POST")
42
+ self.assertEqual(client.calls[0][1], "/external/eval/cases")
43
+ self.assertEqual(client.calls[0][2]["json"]["label_ids"], ["10", "11"])
44
+
45
+ def test_update_case_sends_empty_label_ids_to_clear(self) -> None:
46
+ client = FakeClient()
47
+
48
+ eval_mod.update_case(
49
+ client, # type: ignore[arg-type]
50
+ "case-1",
51
+ label_ids=[],
52
+ )
53
+
54
+ self.assertEqual(client.calls[0][0], "PUT")
55
+ self.assertEqual(client.calls[0][1], "/external/eval/cases/case-1")
56
+ self.assertEqual(client.calls[0][2]["json"]["label_ids"], [])
57
+
58
+ def test_case_label_crud_paths(self) -> None:
59
+ client = FakeClient()
60
+
61
+ eval_mod.list_case_labels(client, workspace_id="ws-1") # type: ignore[arg-type]
62
+ eval_mod.create_case_label(client, workspace_id="ws-1", name="Routing") # type: ignore[arg-type]
63
+ eval_mod.update_case_label(client, label_id="5", color="#0969da") # type: ignore[arg-type]
64
+ eval_mod.delete_case_label(client, label_id="5") # type: ignore[arg-type]
65
+
66
+ self.assertEqual(client.calls[0], ("GET", "/eval/workspaces/ws-1/case-labels", {}))
67
+ self.assertEqual(client.calls[1][0:2], ("POST", "/eval/workspaces/ws-1/case-labels"))
68
+ self.assertEqual(client.calls[1][2]["json"], {"name": "Routing"})
69
+ self.assertEqual(client.calls[2][0:2], ("PUT", "/eval/case-labels/5"))
70
+ self.assertEqual(client.calls[2][2]["json"], {"color": "#0969da"})
71
+ self.assertEqual(client.calls[3][0:2], ("DELETE", "/eval/case-labels/5"))
72
+
73
+
74
+ class EvalLabelManifestTests(unittest.TestCase):
75
+ def test_resolve_case_label_ids_from_ids_and_names(self) -> None:
76
+ label_ids, label_names = _resolve_case_label_ids(
77
+ {"label_ids": ["7"], "labels": ["Routing", "Billing"]},
78
+ {
79
+ "routing": {"id": "8", "name": "Routing"},
80
+ "billing": {"id": "9", "name": "Billing"},
81
+ },
82
+ )
83
+
84
+ self.assertEqual(label_ids, ["7", "8", "9"])
85
+ self.assertEqual(label_names, ["Routing", "Billing"])
86
+
87
+ def test_resolve_case_label_ids_dedupes_preserving_order(self) -> None:
88
+ label_ids, _ = _resolve_case_label_ids(
89
+ {"label_ids": ["7", "8"], "labels": ["Routing"]},
90
+ {"routing": {"id": "8", "name": "Routing"}},
91
+ )
92
+
93
+ self.assertEqual(label_ids, ["7", "8"])
94
+
95
+ def test_unknown_label_name_is_error(self) -> None:
96
+ with self.assertRaises(ValueError):
97
+ _resolve_case_label_ids({"labels": ["Missing"]}, {})
98
+
99
+
100
+ if __name__ == "__main__":
101
+ unittest.main()
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from codeer_cli import kb as kb_mod
6
+
7
+
8
+ class FakeClient:
9
+ def __init__(self) -> None:
10
+ self.calls: list[tuple[str, str, dict]] = []
11
+
12
+ def patch(self, path: str, **kwargs):
13
+ self.calls.append(("PATCH", path, kwargs))
14
+ return {"id": "node-1"}
15
+
16
+ def delete(self, path: str, **kwargs):
17
+ self.calls.append(("DELETE", path, kwargs))
18
+ return {"ok": True}
19
+
20
+
21
+ class KbNodeClientTests(unittest.TestCase):
22
+ def test_update_node_renames_by_patch(self) -> None:
23
+ client = FakeClient()
24
+
25
+ kb_mod.update_node(
26
+ client, # type: ignore[arg-type]
27
+ organization_id="org-1",
28
+ workspace_id="ws-1",
29
+ node_id="node-1",
30
+ name="New Name",
31
+ )
32
+
33
+ self.assertEqual(
34
+ client.calls[0],
35
+ (
36
+ "PATCH",
37
+ "/external/knowledge-bases/nodes/node-1",
38
+ {"json": {"name": "New Name"}},
39
+ ),
40
+ )
41
+
42
+ def test_delete_node_deletes_by_node_id(self) -> None:
43
+ client = FakeClient()
44
+
45
+ kb_mod.delete_node(
46
+ client, # type: ignore[arg-type]
47
+ organization_id="org-1",
48
+ workspace_id="ws-1",
49
+ node_id="node-1",
50
+ )
51
+
52
+ self.assertEqual(
53
+ client.calls[0],
54
+ (
55
+ "DELETE",
56
+ "/external/knowledge-bases/nodes/node-1",
57
+ {},
58
+ ),
59
+ )
60
+
61
+
62
+ if __name__ == "__main__":
63
+ unittest.main()
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import unittest
5
+
6
+ from codeer_cli.commands.kb import _parse_faq_range
7
+
8
+
9
+ class FaqRangeTests(unittest.TestCase):
10
+ def test_parse_column_range(self) -> None:
11
+ self.assertEqual(
12
+ _parse_faq_range("47:2-48:9"),
13
+ {
14
+ "start_line": 47,
15
+ "start_column": 2,
16
+ "end_line": 48,
17
+ "end_column": 9,
18
+ },
19
+ )
20
+
21
+ def test_rejects_single_line_range(self) -> None:
22
+ with self.assertRaises(argparse.ArgumentTypeError):
23
+ _parse_faq_range("47")
24
+
25
+ def test_rejects_legacy_line_range(self) -> None:
26
+ with self.assertRaises(argparse.ArgumentTypeError):
27
+ _parse_faq_range("47:48")
28
+
29
+ def test_rejects_reversed_column_range(self) -> None:
30
+ with self.assertRaises(argparse.ArgumentTypeError):
31
+ _parse_faq_range("48:1-47:1")
32
+
33
+
34
+ if __name__ == "__main__":
35
+ unittest.main()
@@ -26,7 +26,7 @@ wheels = [
26
26
 
27
27
  [[package]]
28
28
  name = "codeer-cli"
29
- version = "0.1.4"
29
+ version = "0.1.6"
30
30
  source = { editable = "." }
31
31
  dependencies = [
32
32
  { name = "httpx" },
File without changes