markback 0.1.2__py3-none-any.whl → 0.1.5__py3-none-any.whl

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.
markback/cli.py CHANGED
@@ -1,435 +1,122 @@
1
1
  """MarkBack command-line interface."""
2
2
 
3
- import json
3
+ import glob
4
+ import os
5
+ import shutil
6
+ import subprocess
4
7
  import sys
5
8
  from pathlib import Path
6
- from typing import Annotated, Optional
9
+ from typing import Annotated
7
10
 
8
11
  import typer
9
12
  from rich.console import Console
10
- from rich.table import Table
11
13
 
12
- from .config import Config, init_env, load_config, validate_config
13
- from .linter import format_diagnostics, lint_files, summarize_results
14
- from .parser import parse_file, parse_string
15
- from .types import Severity, parse_feedback
16
- from .writer import OutputMode, normalize_file, write_file, write_records_multi
14
+ from .writer import write_file
17
15
 
18
- app = typer.Typer(
19
- name="markback",
20
- help="MarkBack: A compact format for content + feedback",
21
- no_args_is_help=True,
22
- )
23
-
24
- console = Console()
25
16
  err_console = Console(stderr=True)
26
17
 
27
18
 
28
- @app.command()
29
- def init(
30
- path: Annotated[
31
- Path,
32
- typer.Argument(help="Path to create .env file"),
33
- ] = Path(".env"),
34
- force: Annotated[
35
- bool,
36
- typer.Option("--force", "-f", help="Overwrite existing file"),
37
- ] = False,
38
- ):
39
- """Initialize a .env configuration file."""
40
- if init_env(path, force=force):
41
- console.print(f"[green]Created {path}[/green]")
42
- else:
43
- console.print(f"[yellow]{path} already exists. Use --force to overwrite.[/yellow]")
44
- raise typer.Exit(1)
45
-
46
-
47
- @app.command()
48
- def lint(
49
- paths: Annotated[
50
- list[Path],
51
- typer.Argument(help="Files or directories to lint"),
52
- ],
53
- output_json: Annotated[
54
- bool,
55
- typer.Option("--json", "-j", help="Output as JSON"),
56
- ] = False,
57
- no_source_check: Annotated[
58
- bool,
59
- typer.Option("--no-source-check", help="Skip checking if @source files exist"),
60
- ] = False,
61
- no_canonical_check: Annotated[
62
- bool,
63
- typer.Option("--no-canonical-check", help="Skip canonical format check"),
64
- ] = False,
65
- ):
66
- """Lint MarkBack files."""
67
- results = lint_files(
68
- paths,
69
- check_sources=not no_source_check,
70
- check_canonical=not no_canonical_check,
71
- )
19
+ def get_mb_path(target: Path) -> Path:
20
+ """Get the .mb file path for a target file."""
21
+ return target.with_suffix(target.suffix + ".mb")
72
22
 
73
- summary = summarize_results(results)
74
23
 
75
- # Collect all diagnostics
76
- all_diagnostics = []
77
- for result in results:
78
- all_diagnostics.extend(result.diagnostics)
24
+ def get_feedback_path(directory: Path) -> Path:
25
+ """Get a non-colliding feedback.mb path in the given directory."""
26
+ candidate = directory / "feedback.mb"
27
+ if not candidate.exists():
28
+ return candidate
29
+ counter = 1
30
+ while True:
31
+ candidate = directory / f"feedback-{counter}.mb"
32
+ if not candidate.exists():
33
+ return candidate
34
+ counter += 1
79
35
 
80
- if output_json:
81
- output = {
82
- "summary": summary,
83
- "diagnostics": [d.to_dict() for d in all_diagnostics],
84
- }
85
- console.print(json.dumps(output, indent=2))
86
- else:
87
- # Print diagnostics
88
- for d in all_diagnostics:
89
- if d.severity == Severity.ERROR:
90
- err_console.print(f"[red]{d}[/red]")
91
- else:
92
- err_console.print(f"[yellow]{d}[/yellow]")
93
36
 
94
- # Print summary
95
- console.print()
96
- console.print(f"Files: {summary['files']}")
97
- console.print(f"Records: {summary['records']}")
98
- console.print(f"Errors: {summary['errors']}")
99
- console.print(f"Warnings: {summary['warnings']}")
37
+ def collect_glob(pattern: str) -> None:
38
+ """Expand a glob pattern and write all matches into a single .mb file."""
39
+ from .types import Record, SourceRef
100
40
 
101
- # Exit with error code if there were errors
102
- if summary["errors"] > 0:
41
+ matches = sorted(glob.glob(pattern))
42
+ if not matches:
43
+ err_console.print(f"[red]No files match pattern: {pattern}[/red]")
103
44
  raise typer.Exit(1)
104
45
 
46
+ records = [Record(source=SourceRef(f), feedback="") for f in matches]
105
47
 
106
- @app.command()
107
- def normalize(
108
- input_path: Annotated[
109
- Path,
110
- typer.Argument(help="Input MarkBack file"),
111
- ],
112
- output_path: Annotated[
113
- Optional[Path],
114
- typer.Argument(help="Output file (omit for in-place)"),
115
- ] = None,
116
- in_place: Annotated[
117
- bool,
118
- typer.Option("--in-place", "-i", help="Modify input file in place"),
119
- ] = False,
120
- ):
121
- """Normalize a MarkBack file to canonical format."""
122
- try:
123
- content = normalize_file(
124
- input_path,
125
- output_path=output_path,
126
- in_place=in_place or (output_path is None),
127
- )
128
-
129
- if output_path:
130
- console.print(f"[green]Wrote {output_path}[/green]")
131
- elif in_place:
132
- console.print(f"[green]Normalized {input_path}[/green]")
133
- else:
134
- console.print(content)
135
-
136
- except ValueError as e:
137
- err_console.print(f"[red]Error: {e}[/red]")
138
- raise typer.Exit(1)
48
+ # Determine output directory from the pattern's parent, defaulting to cwd
49
+ pattern_parent = Path(pattern).parent
50
+ output_dir = pattern_parent if pattern_parent != Path("") else Path(".")
51
+ output_path = get_feedback_path(output_dir)
139
52
 
53
+ write_file(output_path, records)
54
+ err_console.print(f"Created {output_path} with {len(records)} source(s)")
55
+ open_editor(output_path)
140
56
 
141
- @app.command("list")
142
- def list_records(
143
- paths: Annotated[
144
- list[Path],
145
- typer.Argument(help="Files or directories to list"),
146
- ],
147
- output_json: Annotated[
148
- bool,
149
- typer.Option("--json", "-j", help="Output as JSON"),
150
- ] = False,
151
- ):
152
- """List records in MarkBack files."""
153
- all_records = []
154
57
 
155
- for path in paths:
156
- if path.is_dir():
157
- for mb_file in path.glob("**/*.mb"):
158
- result = parse_file(mb_file)
159
- for record in result.records:
160
- all_records.append((mb_file, record))
161
- else:
162
- result = parse_file(path)
163
- for record in result.records:
164
- all_records.append((path, record))
58
+ def open_editor(path: Path) -> None:
59
+ """Open the file in the user's editor."""
60
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
165
61
 
166
- if output_json:
167
- output = []
168
- for file_path, record in all_records:
169
- output.append({
170
- "file": str(file_path),
171
- "uri": record.uri,
172
- "source": str(record.source) if record.source else None,
173
- "feedback": record.feedback,
174
- "has_content": record.has_inline_content(),
175
- })
176
- console.print(json.dumps(output, indent=2))
62
+ if editor:
63
+ subprocess.run([editor, str(path)])
64
+ elif sys.platform == "win32":
65
+ os.startfile(path)
66
+ elif sys.platform == "darwin":
67
+ subprocess.run(["open", str(path)])
177
68
  else:
178
- table = Table(show_header=True)
179
- table.add_column("URI", style="cyan")
180
- table.add_column("Source", style="green")
181
- table.add_column("Feedback", style="white")
182
-
183
- for file_path, record in all_records:
184
- uri = record.uri or "-"
185
- source = str(record.source) if record.source else "-"
186
- # Truncate feedback for display
187
- feedback = record.feedback
188
- if len(feedback) > 50:
189
- feedback = feedback[:47] + "..."
190
-
191
- table.add_row(uri, source, feedback)
192
-
193
- console.print(table)
194
- console.print(f"\nTotal: {len(all_records)} records")
195
-
196
-
197
- @app.command()
198
- def convert(
199
- input_path: Annotated[
200
- Path,
201
- typer.Argument(help="Input MarkBack file or directory"),
202
- ],
203
- output_path: Annotated[
204
- Path,
205
- typer.Argument(help="Output file or directory"),
206
- ],
207
- to: Annotated[
208
- str,
209
- typer.Option("--to", "-t", help="Output format: single, multi, compact, paired"),
210
- ] = "multi",
211
- ):
212
- """Convert between MarkBack storage modes."""
213
- # Parse input
214
- if input_path.is_dir():
215
- from .parser import parse_directory
216
- result = parse_directory(input_path)
217
- else:
218
- result = parse_file(input_path)
219
-
220
- if result.has_errors:
221
- err_console.print("[red]Cannot convert file with errors[/red]")
222
- for d in result.diagnostics:
223
- if d.severity == Severity.ERROR:
224
- err_console.print(f"[red]{d}[/red]")
225
- raise typer.Exit(1)
226
-
227
- records = result.records
228
-
229
- # Convert to output format
230
- mode_map = {
231
- "single": OutputMode.SINGLE,
232
- "multi": OutputMode.MULTI,
233
- "compact": OutputMode.COMPACT,
234
- "paired": OutputMode.PAIRED,
235
- }
236
-
237
- if to not in mode_map:
238
- err_console.print(f"[red]Unknown format: {to}. Use: single, multi, compact, paired[/red]")
69
+ # Try xdg-open first, then fall back to common editors
70
+ if shutil.which("xdg-open"):
71
+ result = subprocess.run(["xdg-open", str(path)], capture_output=True)
72
+ if result.returncode == 0:
73
+ return
74
+ for fallback in ("nano", "vi"):
75
+ if shutil.which(fallback):
76
+ subprocess.run([fallback, str(path)])
77
+ return
78
+ err_console.print(
79
+ f"[red]No editor found. Set the EDITOR environment variable, e.g.:[/red]\n"
80
+ f" export EDITOR=nano"
81
+ )
239
82
  raise typer.Exit(1)
240
83
 
241
- mode = mode_map[to]
242
-
243
- if mode == OutputMode.PAIRED:
244
- # Paired mode: create output directory with label files
245
- output_path.mkdir(parents=True, exist_ok=True)
246
- from .writer import write_paired_files
247
- for i, record in enumerate(records):
248
- # Determine filename from URI or source or index
249
- if record.source:
250
- basename = Path(str(record.source)).stem
251
- elif record.uri:
252
- basename = record.uri.split("/")[-1].split(":")[-1]
253
- else:
254
- basename = f"record_{i:04d}"
255
-
256
- label_path = output_path / f"{basename}.label.txt"
257
- write_paired_files(label_path, None, record)
258
-
259
- console.print(f"[green]Wrote {len(records)} label files to {output_path}[/green]")
260
-
261
- elif mode == OutputMode.SINGLE:
262
- if len(records) != 1:
263
- err_console.print(f"[red]Single mode requires exactly 1 record, got {len(records)}[/red]")
264
- raise typer.Exit(1)
265
- write_file(output_path, records, mode=mode)
266
- console.print(f"[green]Wrote {output_path}[/green]")
267
-
268
- else:
269
- write_file(output_path, records, mode=mode)
270
- console.print(f"[green]Wrote {len(records)} records to {output_path}[/green]")
271
-
272
-
273
- # Workflow subcommand group
274
- workflow_app = typer.Typer(
275
- name="workflow",
276
- help="Editor/Operator LLM workflow commands",
277
- )
278
- app.add_typer(workflow_app, name="workflow")
279
84
 
280
-
281
- @workflow_app.command("run")
282
- def workflow_run(
283
- dataset: Annotated[
284
- Path,
285
- typer.Argument(help="Path to MarkBack dataset file or directory"),
286
- ],
287
- prompt: Annotated[
85
+ def main(
86
+ target: Annotated[
288
87
  str,
289
- typer.Option("--prompt", "-p", help="Initial prompt (or path to prompt file)"),
290
- ] = "",
291
- output: Annotated[
292
- Path,
293
- typer.Option("--output", "-o", help="Output file for results"),
294
- ] = Path("workflow_result.json"),
295
- env_file: Annotated[
296
- Optional[Path],
297
- typer.Option("--env", "-e", help="Path to .env file"),
298
- ] = None,
299
- ):
300
- """Run the editor/operator workflow on a dataset."""
301
- from .workflow import run_workflow, save_workflow_result
302
-
303
- # Load config
304
- config = load_config(env_file)
305
-
306
- # Validate config
307
- issues = validate_config(config)
308
- if issues:
309
- for issue in issues:
310
- err_console.print(f"[yellow]Config warning: {issue}[/yellow]")
311
-
312
- if config.editor is None or config.operator is None:
313
- err_console.print("[red]Editor and Operator LLMs must be configured in .env[/red]")
314
- raise typer.Exit(1)
315
-
316
- # Load initial prompt
317
- initial_prompt = prompt
318
- if prompt and Path(prompt).exists():
319
- initial_prompt = Path(prompt).read_text(encoding="utf-8")
320
-
321
- # Load dataset
322
- if dataset.is_dir():
323
- from .parser import parse_directory
324
- result = parse_directory(dataset)
325
- else:
326
- result = parse_file(dataset)
327
-
328
- if result.has_errors:
329
- err_console.print("[red]Dataset has errors:[/red]")
330
- for d in result.diagnostics:
331
- if d.severity == Severity.ERROR:
332
- err_console.print(f"[red]{d}[/red]")
333
- raise typer.Exit(1)
334
-
335
- if not result.records:
336
- err_console.print("[red]No records found in dataset[/red]")
337
- raise typer.Exit(1)
338
-
339
- console.print(f"Loaded {len(result.records)} records from {dataset}")
340
- console.print("Running workflow...")
341
-
342
- try:
343
- workflow_result = run_workflow(config, initial_prompt, result.records)
344
-
345
- # Save result
346
- output_file = save_workflow_result(workflow_result, output, config)
347
- console.print(f"[green]Results saved to {output_file}[/green]")
348
-
349
- # Print summary
350
- console.print("\n[bold]Workflow Results:[/bold]")
351
- console.print(f"Refined prompt length: {len(workflow_result.refined_prompt)} chars")
352
- console.print(f"Outputs generated: {len(workflow_result.outputs)}")
353
-
354
- eval_result = workflow_result.evaluation
355
- console.print(f"\n[bold]Evaluation:[/bold]")
356
- console.print(f"Total: {eval_result['total']}")
357
- console.print(f"Correct: {eval_result['correct']}")
358
- console.print(f"Incorrect: {eval_result['incorrect']}")
359
- console.print(f"Accuracy: {eval_result['accuracy']:.1%}")
360
-
361
- except Exception as e:
362
- err_console.print(f"[red]Workflow error: {e}[/red]")
363
- raise typer.Exit(1)
364
-
365
-
366
- @workflow_app.command("evaluate")
367
- def workflow_evaluate(
368
- results_file: Annotated[
369
- Path,
370
- typer.Argument(help="Path to workflow results JSON"),
88
+ typer.Argument(help="Target file or glob pattern to manage feedback for"),
371
89
  ],
372
- output_json: Annotated[
373
- bool,
374
- typer.Option("--json", "-j", help="Output as JSON"),
375
- ] = False,
376
90
  ):
377
- """Show evaluation details from a workflow run."""
378
- if not results_file.exists():
379
- err_console.print(f"[red]File not found: {results_file}[/red]")
380
- raise typer.Exit(1)
381
-
382
- data = json.loads(results_file.read_text(encoding="utf-8"))
383
- evaluation = data.get("evaluation", {})
384
-
385
- if output_json:
386
- console.print(json.dumps(evaluation, indent=2))
387
- else:
388
- console.print("[bold]Evaluation Summary:[/bold]")
389
- console.print(f"Total: {evaluation.get('total', 0)}")
390
- console.print(f"Correct: {evaluation.get('correct', 0)}")
391
- console.print(f"Incorrect: {evaluation.get('incorrect', 0)}")
392
- console.print(f"Accuracy: {evaluation.get('accuracy', 0):.1%}")
393
-
394
- details = evaluation.get("details", [])
395
- if details:
396
- console.print("\n[bold]Details:[/bold]")
397
- for d in details:
398
- status = "[green]PASS[/green]" if d.get("match") else "[red]FAIL[/red]"
399
- uri = d.get("uri") or f"record {d.get('record_idx')}"
400
- console.print(f" {status} {uri}: expected={d.get('expected_label')}")
401
-
402
-
403
- @workflow_app.command("prompt")
404
- def workflow_prompt(
405
- results_file: Annotated[
406
- Path,
407
- typer.Argument(help="Path to workflow results JSON"),
408
- ],
409
- output: Annotated[
410
- Optional[Path],
411
- typer.Option("--output", "-o", help="Save prompt to file"),
412
- ] = None,
413
- ):
414
- """Extract the refined prompt from a workflow run."""
415
- if not results_file.exists():
416
- err_console.print(f"[red]File not found: {results_file}[/red]")
417
- raise typer.Exit(1)
91
+ """MarkBack: Create or view feedback for a target file."""
92
+ # Check if target is a glob pattern
93
+ if any(c in target for c in ('*', '?', '[')):
94
+ collect_glob(target)
95
+ return
96
+
97
+ target = Path(target)
98
+ mb_path = get_mb_path(target)
99
+
100
+ if not mb_path.exists():
101
+ # Create new .mb file
102
+ if not target.exists():
103
+ err_console.print(f"[red]Target file not found: {target}[/red]")
104
+ raise typer.Exit(1)
418
105
 
419
- data = json.loads(results_file.read_text(encoding="utf-8"))
420
- prompt = data.get("refined_prompt", "")
106
+ from .types import Record
107
+ record = Record(
108
+ source=target,
109
+ feedback="",
110
+ )
111
+ write_file(mb_path, [record])
421
112
 
422
- if output:
423
- output.write_text(prompt, encoding="utf-8")
424
- console.print(f"[green]Saved prompt to {output}[/green]")
425
- else:
426
- console.print(prompt)
113
+ open_editor(mb_path)
427
114
 
428
115
 
429
- def main():
116
+ def cli():
430
117
  """Entry point for the CLI."""
431
- app()
118
+ typer.run(main)
432
119
 
433
120
 
434
121
  if __name__ == "__main__":
435
- main()
122
+ cli()
markback/linter.py CHANGED
@@ -137,36 +137,61 @@ def lint_prior_exists(
137
137
  return diagnostics
138
138
 
139
139
 
140
+ def _is_position_invalid(source_ref) -> tuple[bool, str]:
141
+ """Check if a SourceRef has an invalid position range.
142
+
143
+ Returns (is_invalid, error_message).
144
+ Position is invalid if:
145
+ - end_line < start_line
146
+ - end_line == start_line and end_column < start_column
147
+ """
148
+ if source_ref.start_line is None or source_ref.end_line is None:
149
+ return False, ""
150
+
151
+ if source_ref.end_line < source_ref.start_line:
152
+ return True, f"end line {source_ref.end_line} is less than start line {source_ref.start_line}"
153
+
154
+ if source_ref.end_line == source_ref.start_line:
155
+ if (source_ref.start_column is not None and
156
+ source_ref.end_column is not None and
157
+ source_ref.end_column < source_ref.start_column):
158
+ return True, f"end column {source_ref.end_column} is less than start column {source_ref.start_column} on line {source_ref.start_line}"
159
+
160
+ return False, ""
161
+
162
+
140
163
  def lint_line_range(
141
164
  record: Record,
142
165
  record_idx: int,
143
166
  ) -> list[Diagnostic]:
144
- """Check if line ranges are valid (end >= start)."""
167
+ """Check if line/character ranges are valid (end position >= start position)."""
145
168
  diagnostics: list[Diagnostic] = []
146
169
 
147
- # Check @source line range
170
+ # Check @source range
148
171
  if record.source and record.source.start_line is not None:
149
- if record.source.end_line is not None and record.source.end_line < record.source.start_line:
172
+ is_invalid, error_msg = _is_position_invalid(record.source)
173
+ if is_invalid:
150
174
  diagnostics.append(Diagnostic(
151
175
  file=record._source_file,
152
176
  line=record._start_line,
153
177
  column=None,
154
178
  severity=Severity.ERROR,
155
179
  code=ErrorCode.E011,
156
- message=f"Invalid line range in @source: end line {record.source.end_line} is less than start line {record.source.start_line}",
180
+ message=f"Invalid range in @source: {error_msg}",
157
181
  record_index=record_idx,
158
182
  ))
159
183
 
160
- # Check @prior line range
184
+ # Check @prior range
161
185
  if record.prior and record.prior.start_line is not None:
162
- if record.prior.end_line is not None and record.prior.end_line < record.prior.start_line:
186
+ is_invalid, error_msg = _is_position_invalid(record.prior)
187
+ if is_invalid:
163
188
  diagnostics.append(Diagnostic(
164
189
  file=record._source_file,
165
190
  line=record._start_line,
166
191
  column=None,
167
192
  severity=Severity.ERROR,
168
193
  code=ErrorCode.E011,
169
- message=f"Invalid line range in @prior: end line {record.prior.end_line} is less than start line {record.prior.start_line}",
194
+ message=f"Invalid range in @prior: {error_msg}",
170
195
  record_index=record_idx,
171
196
  ))
172
197
 
markback/parser.py CHANGED
@@ -17,7 +17,7 @@ from .types import (
17
17
 
18
18
 
19
19
  # Known header keywords
20
- KNOWN_HEADERS = {"uri", "source", "prior"}
20
+ KNOWN_HEADERS = {"uri", "by", "source", "prior"}
21
21
 
22
22
  # Patterns
23
23
  HEADER_PATTERN = re.compile(r"^@([a-z]+)\s+(.+)$")
@@ -145,6 +145,7 @@ def parse_string(
145
145
  nonlocal pending_uri, in_content, had_blank_line
146
146
 
147
147
  uri = current_headers.get("uri") or pending_uri
148
+ by = current_headers.get("by")
148
149
  source_str = current_headers.get("source")
149
150
  source = SourceRef(source_str) if source_str else None
150
151
  prior_str = current_headers.get("prior")
@@ -164,6 +165,7 @@ def parse_string(
164
165
  record = Record(
165
166
  feedback=feedback,
166
167
  uri=uri,
168
+ by=by,
167
169
  source=source,
168
170
  prior=prior,
169
171
  content=content,
@@ -242,14 +244,16 @@ def parse_string(
242
244
  line_num,
243
245
  )
244
246
 
245
- # Use any pending @uri from previous line and @prior if present
247
+ # Use any pending @uri from previous line and @by, @prior if present
246
248
  uri = pending_uri or current_headers.get("uri")
249
+ by = current_headers.get("by")
247
250
  prior_str = current_headers.get("prior")
248
251
  prior = SourceRef(prior_str) if prior_str else None
249
252
 
250
253
  record = Record(
251
254
  feedback=feedback or "",
252
255
  uri=uri,
256
+ by=by,
253
257
  source=source,
254
258
  prior=prior,
255
259
  content=None,
markback/types.py CHANGED
@@ -78,8 +78,9 @@ class Diagnostic:
78
78
  }
79
79
 
80
80
 
81
- # Regex to parse line range from a path: path:start or path:start-end
82
- _LINE_RANGE_PATTERN = re.compile(r'^(.+?):(\d+)(?:-(\d+))?$')
81
+ # Regex to parse line/character range from a path
82
+ # Supports: path:line, path:line:col, path:line-line, path:line:col-line:col
83
+ _LINE_RANGE_PATTERN = re.compile(r'^(.+?):(\d+)(?::(\d+))?(?:-(\d+)(?::(\d+))?)?$')
83
84
 
84
85
 
85
86
  @dataclass
@@ -89,6 +90,8 @@ class SourceRef:
89
90
  is_uri: bool = False
90
91
  start_line: Optional[int] = None
91
92
  end_line: Optional[int] = None
93
+ start_column: Optional[int] = None
94
+ end_column: Optional[int] = None
92
95
  _path_only: str = ""
93
96
 
94
97
  def __post_init__(self):
@@ -102,16 +105,21 @@ class SourceRef:
102
105
  self.is_uri = bool(parsed.scheme) and len(parsed.scheme) > 1
103
106
 
104
107
  def _parse_line_range(self):
105
- """Parse optional line range from value."""
108
+ """Parse optional line/character range from value."""
106
109
  match = _LINE_RANGE_PATTERN.match(self.value)
107
110
  if match:
108
111
  self._path_only = match.group(1)
109
112
  self.start_line = int(match.group(2))
110
113
  if match.group(3):
111
- self.end_line = int(match.group(3))
114
+ self.start_column = int(match.group(3))
115
+ if match.group(4):
116
+ self.end_line = int(match.group(4))
117
+ if match.group(5):
118
+ self.end_column = int(match.group(5))
112
119
  else:
113
- # Single line reference: start and end are the same
120
+ # Single line/position reference: start and end are the same
114
121
  self.end_line = self.start_line
122
+ self.end_column = self.start_column
115
123
  else:
116
124
  self._path_only = self.value
117
125
 
@@ -122,12 +130,27 @@ class SourceRef:
122
130
 
123
131
  @property
124
132
  def line_range_str(self) -> Optional[str]:
125
- """Return formatted line range string, or None if no range."""
133
+ """Return formatted line/character range string, or None if no range."""
126
134
  if self.start_line is None:
127
135
  return None
128
- if self.start_line == self.end_line:
129
- return f":{self.start_line}"
130
- return f":{self.start_line}-{self.end_line}"
136
+
137
+ # Build start position
138
+ if self.start_column is not None:
139
+ start = f":{self.start_line}:{self.start_column}"
140
+ else:
141
+ start = f":{self.start_line}"
142
+
143
+ # Check if end is the same as start (single position)
144
+ if self.start_line == self.end_line and self.start_column == self.end_column:
145
+ return start
146
+
147
+ # Build end position
148
+ if self.end_column is not None:
149
+ end = f"-{self.end_line}:{self.end_column}"
150
+ else:
151
+ end = f"-{self.end_line}"
152
+
153
+ return f"{start}{end}"
131
154
 
132
155
  def resolve(self, base_path: Optional[Path] = None) -> Path:
133
156
  """Resolve to a file path (relative paths resolved against base_path)."""
@@ -162,6 +185,7 @@ class Record:
162
185
  """A MarkBack record containing content and feedback."""
163
186
  feedback: str
164
187
  uri: Optional[str] = None
188
+ by: Optional[str] = None
165
189
  source: Optional[SourceRef] = None
166
190
  prior: Optional[SourceRef] = None
167
191
  content: Optional[str] = None
@@ -195,6 +219,7 @@ class Record:
195
219
  """Convert to JSON-serializable dict."""
196
220
  return {
197
221
  "uri": self.uri,
222
+ "by": self.by,
198
223
  "source": str(self.source) if self.source else None,
199
224
  "prior": str(self.prior) if self.prior else None,
200
225
  "content": self.content,
markback/writer.py CHANGED
@@ -38,17 +38,21 @@ def write_record_canonical(
38
38
  )
39
39
 
40
40
  if use_compact:
41
- # Compact format: @uri on its own line (if present), then @prior, then @source ... <<<
41
+ # Compact format: @uri, @by, @prior on own lines (if present), then @source ... <<<
42
42
  if record.uri:
43
43
  lines.append(f"@uri {record.uri}")
44
+ if record.by:
45
+ lines.append(f"@by {record.by}")
44
46
  if record.prior:
45
47
  lines.append(f"@prior {record.prior}")
46
48
  lines.append(f"@source {record.source} <<< {record.feedback}")
47
49
  else:
48
50
  # Full format
49
- # Headers: @uri first, then @prior, then @source
51
+ # Headers: @uri first, then @by, then @prior, then @source
50
52
  if record.uri:
51
53
  lines.append(f"@uri {record.uri}")
54
+ if record.by:
55
+ lines.append(f"@by {record.by}")
52
56
  if record.prior:
53
57
  lines.append(f"@prior {record.prior}")
54
58
  if record.source:
@@ -151,7 +155,10 @@ def write_label_file(record: Record) -> str:
151
155
 
152
156
  if record.uri:
153
157
  lines.append(f"@uri {record.uri}")
154
-
158
+
159
+ if record.by:
160
+ lines.append(f"@by {record.by}")
161
+
155
162
  if record.prior:
156
163
  lines.append(f"@prior {record.prior}")
157
164
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markback
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: A compact, human-writable format for storing content paired with feedback/labels
5
5
  Project-URL: Homepage, https://github.com/dandriscoll/markback
6
6
  Project-URL: Repository, https://github.com/dandriscoll/markback
@@ -95,10 +95,17 @@ if result.has_errors:
95
95
 
96
96
  ## CLI Usage
97
97
 
98
+ The CLI is available via two commands:
99
+ - `markback` - Full command name
100
+ - `mb` - Convenient shorthand (works on all platforms including Windows)
101
+
102
+ Both commands are functionally identical. Examples below use `markback`, but you can substitute `mb` anywhere.
103
+
98
104
  ### Initialize configuration
99
105
 
100
106
  ```bash
101
107
  markback init
108
+ # or: mb init
102
109
  ```
103
110
 
104
111
  Creates a `.env` file with all configuration options.
@@ -0,0 +1,14 @@
1
+ markback/__init__.py,sha256=B0-2dpUu5nkbnUI0hPz-x7PHiOl7M-tiRi6s3UCYJFk,1540
2
+ markback/cli.py,sha256=DkqY5WlpIhbAJ9lp6Hja2ie9qzMumVqIWpALnkKzzXQ,3447
3
+ markback/config.py,sha256=eTVhb7UwDER9FRYo8QUAvneLHSqXD2ZtLUgtBtnljUs,5455
4
+ markback/linter.py,sha256=vKy5JHTEGspF4-lWRL9o4zDWieghqQWkah-bP_n8-kM,11977
5
+ markback/llm.py,sha256=ON5_2C6v4KIk7_aIceulfWjEEI6hmallaPlLv-1-s_o,4692
6
+ markback/parser.py,sha256=KHC1QKmN1wSnYgcobC36zXUqL6cdNsZajC6fHlhETZc,19016
7
+ markback/types.py,sha256=t6HdFIWgBTlCfC2KRKKhSO4VBzfxZcP3Uq_zoh-bXZ4,11006
8
+ markback/workflow.py,sha256=zC1RUm1i1wgiciFDqUilJKJ0-bgInvctxhQ0h5WSdoQ,10485
9
+ markback/writer.py,sha256=HY_RYnEk27cIsodo9gGf_MvUb_Bvdmkz05S9kyq6Tdo,8093
10
+ markback-0.1.5.dist-info/METADATA,sha256=dHKSIe4l5IxixOSII6s05lQbWGtL7OPwqyLR-VrhSgk,5405
11
+ markback-0.1.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ markback-0.1.5.dist-info/entry_points.txt,sha256=o2T8WbdHx1yJPFm9nom7mAqKpldkHygGMGixZwm-1nc,68
13
+ markback-0.1.5.dist-info/licenses/LICENSE,sha256=lLK1n13C_CXb0M10O-6itEIDY6dsXKutZYQH-09n6s0,1068
14
+ markback-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ markback = markback.cli:cli
3
+ mb = markback.cli:cli
@@ -1,14 +0,0 @@
1
- markback/__init__.py,sha256=B0-2dpUu5nkbnUI0hPz-x7PHiOl7M-tiRi6s3UCYJFk,1540
2
- markback/cli.py,sha256=5wMk1OUG7W_voS9DxeFxRJrBTMabEdOK_s_o3Irxuu0,13639
3
- markback/config.py,sha256=eTVhb7UwDER9FRYo8QUAvneLHSqXD2ZtLUgtBtnljUs,5455
4
- markback/linter.py,sha256=IctgPEFNfGDo5DcELdo0Ni3d7Dp0bIWtlDw61ccWDOQ,11210
5
- markback/llm.py,sha256=ON5_2C6v4KIk7_aIceulfWjEEI6hmallaPlLv-1-s_o,4692
6
- markback/parser.py,sha256=P7GRjlwhy8j6Tnub7XAqILtZ4pFdkfdHhB-aIjLVRYU,18881
7
- markback/types.py,sha256=tFunBAoqUEVf9mi_4N1QwWmOsKt0nocZK7M7K_rybWg,10097
8
- markback/workflow.py,sha256=zC1RUm1i1wgiciFDqUilJKJ0-bgInvctxhQ0h5WSdoQ,10485
9
- markback/writer.py,sha256=3-LeupyuruGv4WZH9pV65hU4YDKuC5HgIIZ8YZ2SZnM,7896
10
- markback-0.1.2.dist-info/METADATA,sha256=d_xMmpicyEYeakJ4hA8SsrlGgj5Qxpd6fMv7tKf-eaI,5133
11
- markback-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- markback-0.1.2.dist-info/entry_points.txt,sha256=Bc9aXvtlPxVPuOJ9BWGngAVrkx5dMvRgujjVzXC-V5U,46
13
- markback-0.1.2.dist-info/licenses/LICENSE,sha256=lLK1n13C_CXb0M10O-6itEIDY6dsXKutZYQH-09n6s0,1068
14
- markback-0.1.2.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- markback = markback.cli:app