intentspec 0.1.0__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.
- intentspec/__init__.py +3 -0
- intentspec/cli.py +620 -0
- intentspec-0.1.0.dist-info/METADATA +384 -0
- intentspec-0.1.0.dist-info/RECORD +53 -0
- intentspec-0.1.0.dist-info/WHEEL +5 -0
- intentspec-0.1.0.dist-info/entry_points.txt +3 -0
- intentspec-0.1.0.dist-info/licenses/LICENSE +22 -0
- intentspec-0.1.0.dist-info/top_level.txt +3 -0
- intentspec_core/__init__.py +71 -0
- intentspec_core/common/__init__.py +2 -0
- intentspec_core/common/errors.py +52 -0
- intentspec_core/common/utils.py +125 -0
- intentspec_core/doctor.py +112 -0
- intentspec_core/draft.py +430 -0
- intentspec_core/examples.py +52 -0
- intentspec_core/iir/__init__.py +2 -0
- intentspec_core/iir/builder.py +130 -0
- intentspec_core/iir/models.py +35 -0
- intentspec_core/importers/__init__.py +1 -0
- intentspec_core/importers/_parsing.py +218 -0
- intentspec_core/importers/cursor_rules.py +45 -0
- intentspec_core/importers/gemini_structured.py +65 -0
- intentspec_core/importers/instruction_markdown.py +290 -0
- intentspec_core/importers/openai_structured.py +70 -0
- intentspec_core/importers/reverse.py +168 -0
- intentspec_core/imports/__init__.py +2 -0
- intentspec_core/imports/resolver.py +171 -0
- intentspec_core/resources/__init__.py +1 -0
- intentspec_core/resources/examples/__init__.py +1 -0
- intentspec_core/resources/examples/code_review.intent.yaml +84 -0
- intentspec_core/resources/examples/customer_brief.intent.yaml +112 -0
- intentspec_core/resources/examples/customer_brief.output.md +31 -0
- intentspec_core/resources/examples/imported_customer_brief.intent.yaml +68 -0
- intentspec_core/resources/examples/imported_customer_brief.output.md +18 -0
- intentspec_core/resources/examples/invalid_dangerous_permission.intent.yaml +45 -0
- intentspec_core/resources/examples/invalid_missing_goal.intent.yaml +34 -0
- intentspec_core/resources/examples/market_analysis.intent.yaml +80 -0
- intentspec_core/resources/examples/packs/no-network.intent.yaml +20 -0
- intentspec_core/resources/examples/packs/privacy.intent.yaml +20 -0
- intentspec_core/resources/examples/packs/zh-report.intent.yaml +28 -0
- intentspec_core/resources/examples/report_json.intent.yaml +96 -0
- intentspec_core/resources/examples/report_json.output.json +11 -0
- intentspec_core/schemas.py +17 -0
- intentspec_core/spec/__init__.py +2 -0
- intentspec_core/spec/models.py +164 -0
- intentspec_core/spec/parser.py +143 -0
- intentspec_core/targets/__init__.py +2 -0
- intentspec_core/targets/compiler.py +498 -0
- intentspec_core/testing/__init__.py +2 -0
- intentspec_core/testing/tester.py +264 -0
- intentspec_core/validator.py +212 -0
- intentspec_mcp/__init__.py +2 -0
- intentspec_mcp/server.py +383 -0
intentspec/__init__.py
ADDED
intentspec/cli.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""Typer CLI for IntentSpec v0.1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
from intentspec_core import (
|
|
12
|
+
SUPPORTED_IMPORT_TYPES,
|
|
13
|
+
SUPPORTED_TARGETS,
|
|
14
|
+
IntentSpecError,
|
|
15
|
+
IntentSpecTestError,
|
|
16
|
+
IntentSpecValidationError,
|
|
17
|
+
compile_target,
|
|
18
|
+
copy_example,
|
|
19
|
+
generate_json_schema,
|
|
20
|
+
heuristic_draft_payload,
|
|
21
|
+
import_from_artifact,
|
|
22
|
+
inspect_document,
|
|
23
|
+
list_examples,
|
|
24
|
+
load_document,
|
|
25
|
+
load_spec,
|
|
26
|
+
resolve_document_imports,
|
|
27
|
+
resolve_imports,
|
|
28
|
+
run_doctor,
|
|
29
|
+
test_output,
|
|
30
|
+
validate_document,
|
|
31
|
+
validate_spec,
|
|
32
|
+
)
|
|
33
|
+
from intentspec_core.common.utils import dump_json, read_text, write_text
|
|
34
|
+
from intentspec_core.targets.compiler import CompiledArtifact
|
|
35
|
+
from intentspec_core.testing.tester import TestReport
|
|
36
|
+
from intentspec_core.validator import ValidationReport
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
add_completion=False,
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
help="IntentSpec local-first contract compiler for AI task systems.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
REPORT_FORMATS = {"text", "json"}
|
|
45
|
+
DEFAULT_INIT_FILENAME = "intent.yaml"
|
|
46
|
+
DEFAULT_INIT_CONTENT = """version: "0.1"
|
|
47
|
+
kind: "IntentSpec"
|
|
48
|
+
|
|
49
|
+
metadata:
|
|
50
|
+
name: "my_intent"
|
|
51
|
+
title: "Describe the task title"
|
|
52
|
+
description: "Describe what this intent should produce."
|
|
53
|
+
owner: "team-name"
|
|
54
|
+
|
|
55
|
+
task:
|
|
56
|
+
goal: "Describe the main goal for the system."
|
|
57
|
+
audience:
|
|
58
|
+
- "Primary audience"
|
|
59
|
+
priority: "medium"
|
|
60
|
+
|
|
61
|
+
context:
|
|
62
|
+
domain: "General"
|
|
63
|
+
facts: []
|
|
64
|
+
assumptions: []
|
|
65
|
+
glossary: {}
|
|
66
|
+
|
|
67
|
+
inputs: []
|
|
68
|
+
|
|
69
|
+
permissions:
|
|
70
|
+
web: false
|
|
71
|
+
filesystem:
|
|
72
|
+
read: true
|
|
73
|
+
write: false
|
|
74
|
+
network: false
|
|
75
|
+
tools:
|
|
76
|
+
send_email: false
|
|
77
|
+
read_calendar: false
|
|
78
|
+
read_gmail: false
|
|
79
|
+
create_file: false
|
|
80
|
+
delete_file: false
|
|
81
|
+
purchase: false
|
|
82
|
+
|
|
83
|
+
constraints:
|
|
84
|
+
- "Do not fabricate facts."
|
|
85
|
+
|
|
86
|
+
evidence:
|
|
87
|
+
require_sources: false
|
|
88
|
+
mark_uncertainty: true
|
|
89
|
+
distinguish:
|
|
90
|
+
- "fact"
|
|
91
|
+
- "inference"
|
|
92
|
+
|
|
93
|
+
output:
|
|
94
|
+
format: "markdown"
|
|
95
|
+
language: "zh-CN"
|
|
96
|
+
max_words: 500
|
|
97
|
+
sections:
|
|
98
|
+
- "Summary"
|
|
99
|
+
|
|
100
|
+
quality:
|
|
101
|
+
tone:
|
|
102
|
+
- "clear"
|
|
103
|
+
must_include: []
|
|
104
|
+
must_avoid: []
|
|
105
|
+
|
|
106
|
+
human_gates: []
|
|
107
|
+
|
|
108
|
+
tests:
|
|
109
|
+
- name: "Must include all sections"
|
|
110
|
+
assert:
|
|
111
|
+
- type: "required_sections"
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalize_report_format(value: str) -> str:
|
|
116
|
+
normalized = value.lower()
|
|
117
|
+
if normalized not in REPORT_FORMATS:
|
|
118
|
+
raise typer.BadParameter("Use one of: text, json.")
|
|
119
|
+
return normalized
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def validate(
|
|
124
|
+
file: Path,
|
|
125
|
+
report_format: str = typer.Option(
|
|
126
|
+
"text",
|
|
127
|
+
"--format",
|
|
128
|
+
callback=_normalize_report_format,
|
|
129
|
+
help="Output format: text or json.",
|
|
130
|
+
),
|
|
131
|
+
) -> None:
|
|
132
|
+
try:
|
|
133
|
+
report = _validate_document(file)
|
|
134
|
+
except IntentSpecError as exc:
|
|
135
|
+
_emit_error(exc, report_format)
|
|
136
|
+
raise typer.Exit(code=1) from exc
|
|
137
|
+
|
|
138
|
+
_emit_report(report, report_format)
|
|
139
|
+
raise typer.Exit(code=0 if report.ok else 1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command()
|
|
143
|
+
def inspect(
|
|
144
|
+
file: Path,
|
|
145
|
+
report_format: str = typer.Option(
|
|
146
|
+
"text",
|
|
147
|
+
"--format",
|
|
148
|
+
callback=_normalize_report_format,
|
|
149
|
+
help="Output format: text or json.",
|
|
150
|
+
),
|
|
151
|
+
) -> None:
|
|
152
|
+
try:
|
|
153
|
+
document, _ = _load_validated_document(file, emit_warnings=False)
|
|
154
|
+
payload = inspect_document(document)
|
|
155
|
+
except IntentSpecError as exc:
|
|
156
|
+
_emit_error(exc, report_format)
|
|
157
|
+
raise typer.Exit(code=1) from exc
|
|
158
|
+
|
|
159
|
+
if report_format == "json":
|
|
160
|
+
typer.echo(dump_json(payload))
|
|
161
|
+
else:
|
|
162
|
+
typer.echo(_render_iir_text(payload))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command()
|
|
166
|
+
def compile(
|
|
167
|
+
file: Path,
|
|
168
|
+
target: str = typer.Option(
|
|
169
|
+
...,
|
|
170
|
+
"--target",
|
|
171
|
+
help=f"One of: {', '.join(sorted(SUPPORTED_TARGETS))}",
|
|
172
|
+
),
|
|
173
|
+
out: Path | None = typer.Option(None, "--out", help="Optional output file or directory."),
|
|
174
|
+
) -> None:
|
|
175
|
+
try:
|
|
176
|
+
spec, _ = _load_validated_spec(file, emit_warnings=True)
|
|
177
|
+
artifact = compile_target(spec, target)
|
|
178
|
+
except typer.Exit:
|
|
179
|
+
raise
|
|
180
|
+
except IntentSpecError as exc:
|
|
181
|
+
_emit_error(exc, "text")
|
|
182
|
+
raise typer.Exit(code=1) from exc
|
|
183
|
+
|
|
184
|
+
_emit_compiled_artifact(artifact, out)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command("test")
|
|
188
|
+
def test_command(
|
|
189
|
+
file: Path,
|
|
190
|
+
output: Path = typer.Option(..., "--output", help="Output text or JSON file to test."),
|
|
191
|
+
report_format: str = typer.Option(
|
|
192
|
+
"text",
|
|
193
|
+
"--format",
|
|
194
|
+
callback=_normalize_report_format,
|
|
195
|
+
help="Output format: text or json.",
|
|
196
|
+
),
|
|
197
|
+
) -> None:
|
|
198
|
+
try:
|
|
199
|
+
report = _run_test(file, output)
|
|
200
|
+
except IntentSpecError as exc:
|
|
201
|
+
_emit_error(exc, report_format)
|
|
202
|
+
raise typer.Exit(code=1) from exc
|
|
203
|
+
|
|
204
|
+
_emit_report(report, report_format)
|
|
205
|
+
raise typer.Exit(code=0 if report.ok else 2)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def init(
|
|
210
|
+
file: Path = typer.Option(
|
|
211
|
+
Path(DEFAULT_INIT_FILENAME),
|
|
212
|
+
"--file",
|
|
213
|
+
help="Path to the new intent file.",
|
|
214
|
+
),
|
|
215
|
+
) -> None:
|
|
216
|
+
_init_file(file)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.command()
|
|
220
|
+
def schema(
|
|
221
|
+
out: Path | None = typer.Option(None, "--out", help="Optional output file path."),
|
|
222
|
+
) -> None:
|
|
223
|
+
rendered = dump_json(generate_json_schema())
|
|
224
|
+
if out is not None:
|
|
225
|
+
write_text(out, rendered)
|
|
226
|
+
typer.echo(f"Wrote schema to {out}")
|
|
227
|
+
return
|
|
228
|
+
typer.echo(rendered)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
def doctor(
|
|
233
|
+
report_format: str = typer.Option(
|
|
234
|
+
"text",
|
|
235
|
+
"--format",
|
|
236
|
+
callback=_normalize_report_format,
|
|
237
|
+
help="Output format: text or json.",
|
|
238
|
+
),
|
|
239
|
+
) -> None:
|
|
240
|
+
report = run_doctor(Path.cwd())
|
|
241
|
+
_emit_report(report, report_format)
|
|
242
|
+
raise typer.Exit(code=0 if report.ok else 1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command()
|
|
246
|
+
def examples(
|
|
247
|
+
copy: str | None = typer.Option(None, "--copy", help="Copy a packaged example by filename."),
|
|
248
|
+
out: Path | None = typer.Option(None, "--out", help="Destination path for --copy."),
|
|
249
|
+
) -> None:
|
|
250
|
+
if copy is None:
|
|
251
|
+
typer.echo("Available examples:")
|
|
252
|
+
for example in list_examples():
|
|
253
|
+
typer.echo(f"- {example.name}")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
if out is None:
|
|
257
|
+
typer.echo("Error: --out is required when using --copy.", err=True)
|
|
258
|
+
raise typer.Exit(code=1)
|
|
259
|
+
if out.exists():
|
|
260
|
+
typer.echo(f"Error: refusing to overwrite existing path: {out}", err=True)
|
|
261
|
+
raise typer.Exit(code=1)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
destination = copy_example(copy, out)
|
|
265
|
+
except FileNotFoundError as exc:
|
|
266
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
267
|
+
raise typer.Exit(code=1) from exc
|
|
268
|
+
typer.echo(f"Copied example '{copy}' to {destination}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@app.command()
|
|
272
|
+
def workflow(
|
|
273
|
+
workdir: Path = typer.Option(
|
|
274
|
+
Path("."),
|
|
275
|
+
"--workdir",
|
|
276
|
+
help="Working directory for relative paths.",
|
|
277
|
+
),
|
|
278
|
+
intent_file: Path = typer.Option(
|
|
279
|
+
Path(DEFAULT_INIT_FILENAME),
|
|
280
|
+
"--intent-file",
|
|
281
|
+
help="IntentSpec file path.",
|
|
282
|
+
),
|
|
283
|
+
prompt_out: Path = typer.Option(
|
|
284
|
+
Path("task.prompt.md"),
|
|
285
|
+
"--prompt-out",
|
|
286
|
+
help="Prompt output path.",
|
|
287
|
+
),
|
|
288
|
+
structured_out: Path = typer.Option(
|
|
289
|
+
Path("task.openai-structured.json"),
|
|
290
|
+
"--openai-structured-out",
|
|
291
|
+
help="Structured output payload path.",
|
|
292
|
+
),
|
|
293
|
+
agents_out: Path = typer.Option(
|
|
294
|
+
Path("AGENTS.md"),
|
|
295
|
+
"--agents-out",
|
|
296
|
+
help="AGENTS.md output path.",
|
|
297
|
+
),
|
|
298
|
+
mcp_plan_out: Path = typer.Option(
|
|
299
|
+
Path("task.mcp-plan.json"),
|
|
300
|
+
"--mcp-plan-out",
|
|
301
|
+
help="MCP plan output path.",
|
|
302
|
+
),
|
|
303
|
+
output_file: Path = typer.Option(
|
|
304
|
+
None,
|
|
305
|
+
"--output-file",
|
|
306
|
+
help="AI output file path. Defaults to ai_output.md or ai_output.json by format.",
|
|
307
|
+
),
|
|
308
|
+
init: bool = typer.Option(
|
|
309
|
+
False,
|
|
310
|
+
"--init",
|
|
311
|
+
help="Create the intent file before running the workflow.",
|
|
312
|
+
),
|
|
313
|
+
skip_test: bool = typer.Option(
|
|
314
|
+
False,
|
|
315
|
+
"--skip-test",
|
|
316
|
+
help="Skip the final intent test step.",
|
|
317
|
+
),
|
|
318
|
+
) -> None:
|
|
319
|
+
try:
|
|
320
|
+
resolved_workdir = workdir.resolve()
|
|
321
|
+
resolved_intent_file = _resolve_workflow_path(resolved_workdir, intent_file)
|
|
322
|
+
resolved_prompt_out = _resolve_workflow_path(resolved_workdir, prompt_out)
|
|
323
|
+
resolved_structured_out = _resolve_workflow_path(resolved_workdir, structured_out)
|
|
324
|
+
resolved_agents_out = _resolve_workflow_path(resolved_workdir, agents_out)
|
|
325
|
+
resolved_mcp_plan_out = _resolve_workflow_path(resolved_workdir, mcp_plan_out)
|
|
326
|
+
|
|
327
|
+
if init:
|
|
328
|
+
_ensure_intent_file(resolved_intent_file)
|
|
329
|
+
|
|
330
|
+
spec, report = _load_validated_spec(resolved_intent_file, emit_warnings=False)
|
|
331
|
+
resolved_output_file = _resolve_workflow_output_file(
|
|
332
|
+
resolved_workdir,
|
|
333
|
+
output_file,
|
|
334
|
+
spec.output.format,
|
|
335
|
+
)
|
|
336
|
+
typer.echo(report.to_text())
|
|
337
|
+
_emit_compiled_artifact(compile_target(spec, "prompt"), resolved_prompt_out)
|
|
338
|
+
if spec.output.format == "json":
|
|
339
|
+
_emit_compiled_artifact(
|
|
340
|
+
compile_target(spec, "openai-structured"),
|
|
341
|
+
resolved_structured_out,
|
|
342
|
+
)
|
|
343
|
+
_emit_compiled_artifact(compile_target(spec, "agents-md"), resolved_agents_out)
|
|
344
|
+
_emit_compiled_artifact(compile_target(spec, "mcp-plan"), resolved_mcp_plan_out)
|
|
345
|
+
|
|
346
|
+
if skip_test:
|
|
347
|
+
typer.echo("Skipped final intent test because --skip-test was provided.")
|
|
348
|
+
raise typer.Exit(code=0)
|
|
349
|
+
|
|
350
|
+
test_report = _run_test(resolved_intent_file, resolved_output_file)
|
|
351
|
+
typer.echo(test_report.to_text())
|
|
352
|
+
raise typer.Exit(code=0 if test_report.ok else 2)
|
|
353
|
+
except typer.Exit:
|
|
354
|
+
raise
|
|
355
|
+
except IntentSpecError as exc:
|
|
356
|
+
_emit_error(exc, "text")
|
|
357
|
+
raise typer.Exit(code=1) from exc
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@app.command()
|
|
361
|
+
def draft(
|
|
362
|
+
request: str,
|
|
363
|
+
out: Path | None = typer.Option(None, "--out", help="Optional output file path."),
|
|
364
|
+
backend: str = typer.Option(
|
|
365
|
+
"heuristic",
|
|
366
|
+
"--backend",
|
|
367
|
+
help="Draft backend. Only heuristic is supported.",
|
|
368
|
+
),
|
|
369
|
+
) -> None:
|
|
370
|
+
if backend != "heuristic":
|
|
371
|
+
typer.echo("Error: only the heuristic draft backend is supported in v0.1.", err=True)
|
|
372
|
+
raise typer.Exit(code=1)
|
|
373
|
+
payload = heuristic_draft_payload(request)
|
|
374
|
+
rendered = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
|
|
375
|
+
if out is not None:
|
|
376
|
+
if out.exists():
|
|
377
|
+
typer.echo(f"Error: refusing to overwrite existing file: {out}", err=True)
|
|
378
|
+
raise typer.Exit(code=1)
|
|
379
|
+
write_text(out, rendered)
|
|
380
|
+
typer.echo(f"Wrote draft intent to {out}")
|
|
381
|
+
return
|
|
382
|
+
typer.echo(rendered)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _validate_import_type(value: str | None) -> str | None:
|
|
386
|
+
if value is None:
|
|
387
|
+
return None
|
|
388
|
+
if value not in SUPPORTED_IMPORT_TYPES:
|
|
389
|
+
raise typer.BadParameter(
|
|
390
|
+
f"Use one of: {', '.join(sorted(SUPPORTED_IMPORT_TYPES))}."
|
|
391
|
+
)
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.command("import")
|
|
396
|
+
def import_command(
|
|
397
|
+
file: Path,
|
|
398
|
+
source_type: str | None = typer.Option(
|
|
399
|
+
None,
|
|
400
|
+
"--type",
|
|
401
|
+
callback=_validate_import_type,
|
|
402
|
+
help=f"Source artifact type. One of: {', '.join(sorted(SUPPORTED_IMPORT_TYPES))}. "
|
|
403
|
+
"Inferred from file when omitted.",
|
|
404
|
+
),
|
|
405
|
+
out: Path | None = typer.Option(None, "--out", help="Optional output YAML file path."),
|
|
406
|
+
) -> None:
|
|
407
|
+
try:
|
|
408
|
+
payload = import_from_artifact(file, source_type)
|
|
409
|
+
except IntentSpecError as exc:
|
|
410
|
+
_emit_error(exc, "text")
|
|
411
|
+
raise typer.Exit(code=1) from exc
|
|
412
|
+
|
|
413
|
+
rendered = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
|
|
414
|
+
if out is not None:
|
|
415
|
+
if out.exists():
|
|
416
|
+
typer.echo(f"Error: refusing to overwrite existing file: {out}", err=True)
|
|
417
|
+
raise typer.Exit(code=1)
|
|
418
|
+
write_text(out, rendered)
|
|
419
|
+
typer.echo(f"Wrote imported intent to {out}")
|
|
420
|
+
return
|
|
421
|
+
typer.echo(rendered)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def main() -> None:
|
|
425
|
+
app()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _validate_document(file: Path) -> ValidationReport:
|
|
429
|
+
document = resolve_document_imports(load_document(file))
|
|
430
|
+
return validate_document(document)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _load_validated_spec(file: Path, *, emit_warnings: bool) -> tuple[Any, ValidationReport]:
|
|
434
|
+
spec = resolve_imports(load_spec(file))
|
|
435
|
+
report = validate_spec(spec)
|
|
436
|
+
if not report.ok:
|
|
437
|
+
typer.echo(report.to_text(), err=True)
|
|
438
|
+
raise typer.Exit(code=1)
|
|
439
|
+
if emit_warnings and report.warnings:
|
|
440
|
+
typer.echo("Warnings:", err=True)
|
|
441
|
+
for warning in report.warnings:
|
|
442
|
+
typer.echo(f"- {warning}", err=True)
|
|
443
|
+
return spec, report
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _load_validated_document(
|
|
447
|
+
file: Path, *, emit_warnings: bool
|
|
448
|
+
) -> tuple[Any, ValidationReport]:
|
|
449
|
+
document = resolve_document_imports(load_document(file))
|
|
450
|
+
report = validate_document(document)
|
|
451
|
+
if not report.ok:
|
|
452
|
+
typer.echo(report.to_text(), err=True)
|
|
453
|
+
raise typer.Exit(code=1)
|
|
454
|
+
if emit_warnings and report.warnings:
|
|
455
|
+
typer.echo("Warnings:", err=True)
|
|
456
|
+
for warning in report.warnings:
|
|
457
|
+
typer.echo(f"- {warning}", err=True)
|
|
458
|
+
return document, report
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _run_test(file: Path, output: Path) -> TestReport:
|
|
462
|
+
spec = resolve_imports(load_spec(file))
|
|
463
|
+
validation_report = validate_spec(spec)
|
|
464
|
+
_ensure_validation_report_ok(
|
|
465
|
+
validation_report,
|
|
466
|
+
file,
|
|
467
|
+
suggestion=(
|
|
468
|
+
"Run `intent validate <file>` and fix the reported errors before testing "
|
|
469
|
+
"output."
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if not output.exists():
|
|
474
|
+
raise IntentSpecTestError(
|
|
475
|
+
f"Output file not found: {output}",
|
|
476
|
+
suggestion="Create the output file or pass the correct --output path.",
|
|
477
|
+
details={"path": str(output)},
|
|
478
|
+
)
|
|
479
|
+
if not output.is_file():
|
|
480
|
+
raise IntentSpecTestError(
|
|
481
|
+
f"Output path is not a file: {output}",
|
|
482
|
+
suggestion="Pass a readable file path to --output, not a directory.",
|
|
483
|
+
details={"path": str(output)},
|
|
484
|
+
)
|
|
485
|
+
try:
|
|
486
|
+
output_text = read_text(output)
|
|
487
|
+
except OSError as exc:
|
|
488
|
+
raise IntentSpecTestError(
|
|
489
|
+
f"Unable to read output file: {output}",
|
|
490
|
+
suggestion="Verify the output path is readable and points to a text or JSON file.",
|
|
491
|
+
details={"path": str(output)},
|
|
492
|
+
) from exc
|
|
493
|
+
|
|
494
|
+
return test_output(spec, output_text)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _ensure_validation_report_ok(
|
|
498
|
+
report: ValidationReport,
|
|
499
|
+
file: Path,
|
|
500
|
+
*,
|
|
501
|
+
suggestion: str,
|
|
502
|
+
) -> None:
|
|
503
|
+
if report.ok:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
errors = report.errors or ["Unknown validation error."]
|
|
507
|
+
raise IntentSpecValidationError(
|
|
508
|
+
f"IntentSpec validation failed for {file}: {'; '.join(errors)}",
|
|
509
|
+
suggestion=suggestion,
|
|
510
|
+
details={
|
|
511
|
+
"path": str(file),
|
|
512
|
+
"errors": report.errors,
|
|
513
|
+
"warnings": report.warnings,
|
|
514
|
+
},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _emit_report(report: Any, report_format: str) -> None:
|
|
519
|
+
if report_format == "json":
|
|
520
|
+
typer.echo(json.dumps(report.to_dict(), ensure_ascii=False, indent=2))
|
|
521
|
+
return
|
|
522
|
+
typer.echo(report.to_text())
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _emit_error(exc: IntentSpecError, report_format: str) -> None:
|
|
526
|
+
if report_format == "json":
|
|
527
|
+
typer.echo(
|
|
528
|
+
json.dumps(
|
|
529
|
+
{"ok": False, "error": exc.to_dict()},
|
|
530
|
+
ensure_ascii=False,
|
|
531
|
+
indent=2,
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
return
|
|
535
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
536
|
+
if exc.suggestion:
|
|
537
|
+
typer.echo(f"Suggested fix: {exc.suggestion}", err=True)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _emit_compiled_artifact(artifact: CompiledArtifact, out: Path | None) -> None:
|
|
541
|
+
if artifact.kind == "bundle":
|
|
542
|
+
if out is None:
|
|
543
|
+
raise IntentSpecError(
|
|
544
|
+
"Target produces a bundle and requires --out to point to a directory.",
|
|
545
|
+
suggestion="Pass --out <directory> when compiling a bundle target.",
|
|
546
|
+
)
|
|
547
|
+
_write_bundle(out, artifact.files)
|
|
548
|
+
typer.echo(f"Wrote bundle output to {out}")
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
if artifact.content is None:
|
|
552
|
+
raise IntentSpecError("Compiled artifact is missing content.")
|
|
553
|
+
|
|
554
|
+
if out is not None:
|
|
555
|
+
write_text(out, artifact.content)
|
|
556
|
+
typer.echo(f"Wrote compiled output to {out}")
|
|
557
|
+
return
|
|
558
|
+
typer.echo(artifact.content)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _write_bundle(directory: Path, files: dict[str, str]) -> None:
|
|
562
|
+
for relative_path, content in files.items():
|
|
563
|
+
write_text(directory / relative_path, content)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _init_file(file: Path) -> None:
|
|
567
|
+
if file.exists():
|
|
568
|
+
typer.echo(f"Refusing to overwrite existing file: {file}", err=True)
|
|
569
|
+
raise typer.Exit(code=1)
|
|
570
|
+
write_text(file, DEFAULT_INIT_CONTENT)
|
|
571
|
+
typer.echo(f"Created {file}")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _resolve_workflow_path(workdir: Path, path: Path) -> Path:
|
|
575
|
+
if path.is_absolute():
|
|
576
|
+
return path
|
|
577
|
+
return workdir / path
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _resolve_workflow_output_file(
|
|
581
|
+
workdir: Path,
|
|
582
|
+
output_file: Path | None,
|
|
583
|
+
output_format: str,
|
|
584
|
+
) -> Path:
|
|
585
|
+
if output_file is None:
|
|
586
|
+
default_name = "ai_output.json" if output_format == "json" else "ai_output.md"
|
|
587
|
+
return workdir / default_name
|
|
588
|
+
return _resolve_workflow_path(workdir, output_file)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _ensure_intent_file(file: Path) -> None:
|
|
592
|
+
if file.exists():
|
|
593
|
+
typer.echo(f"Using existing intent file: {file}")
|
|
594
|
+
return
|
|
595
|
+
_init_file(file)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _render_iir_text(payload: dict[str, Any]) -> str:
|
|
599
|
+
if payload.get("kind") == "IntentPack":
|
|
600
|
+
lines = [
|
|
601
|
+
f"Source path: {payload['source_path']}",
|
|
602
|
+
f"Pack: {payload['metadata']['name']}",
|
|
603
|
+
"Constraints:",
|
|
604
|
+
*[f"- {item}" for item in payload["merged_constraints"]],
|
|
605
|
+
]
|
|
606
|
+
return "\n".join(lines)
|
|
607
|
+
|
|
608
|
+
lines = [
|
|
609
|
+
f"Source path: {payload['source_path']}",
|
|
610
|
+
f"Goal: {payload['normalized_goal']}",
|
|
611
|
+
"Risk signals:",
|
|
612
|
+
*[f"- {item}" for item in payload["risk_signals"]],
|
|
613
|
+
"Constraints:",
|
|
614
|
+
*[f"- {item}" for item in payload["merged_constraints"]],
|
|
615
|
+
]
|
|
616
|
+
return "\n".join(lines)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
if __name__ == "__main__":
|
|
620
|
+
main()
|