Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Options:
--example Create example specs
--blank Create empty directory structure only
--dry-run Show what would be created
--force Regenerate SKILL.md if it was modified
--format [table|json] Output format (default: table)
```

Expand Down
6 changes: 6 additions & 0 deletions src/specleft/commands/contracts/payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ def build_contract_payload() -> dict[str, object]:
"skill_file_integrity_check": True,
"skill_file_commands_are_simple": True,
},
"security": {
"cli_rejects_shell_metacharacters": True,
"init_refuses_symlinks": True,
"no_network_access": True,
"no_telemetry": True,
},
},
"docs": {
"agent_contract": CONTRACT_DOC_PATH,
Expand Down
23 changes: 23 additions & 0 deletions src/specleft/commands/contracts/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def print_contract_table(payload: Mapping[str, object]) -> None:
determinism = cast(dict[str, Any], guarantees.get("determinism", {}))
cli_api = cast(dict[str, Any], guarantees.get("cli_api", {}))
skill_security = cast(dict[str, Any], guarantees.get("skill_security", {}))
security = cast(dict[str, Any], guarantees.get("security", {}))
click.echo("SpecLeft Agent Contract")
click.echo("─" * 40)
click.echo(f"Contract version: {payload.get('contract_version')}")
Expand Down Expand Up @@ -97,6 +98,28 @@ def print_contract_table(payload: Mapping[str, object]) -> None:
else " - Skill command simplicity guarantee missing"
)
click.echo("")
click.echo("Security:")
click.echo(
" - CLI rejects shell metacharacters"
if security.get("cli_rejects_shell_metacharacters")
else " - CLI shell metacharacter rejection missing"
)
click.echo(
" - Init refuses symlink paths"
if security.get("init_refuses_symlinks")
else " - Init symlink safety guarantee missing"
)
click.echo(
" - No network access"
if security.get("no_network_access")
else " - Network isolation guarantee missing"
)
click.echo(
" - No telemetry"
if security.get("no_telemetry")
else " - Telemetry isolation guarantee missing"
)
click.echo("")
click.echo(f"For full details, see: {CONTRACT_DOC_PATH}")
click.echo("─" * 40)

Expand Down
2 changes: 2 additions & 0 deletions src/specleft/commands/enforce.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import click
import yaml
from specleft.commands.input_validation import validate_id_parameter_multiple
from specleft.commands.output import json_dumps, resolve_output_format
from specleft.specleft_signing.schema import PolicyType, SignedPolicy
from specleft.specleft_signing.verify import VerifyFailure, VerifyResult, verify_policy
Expand Down Expand Up @@ -230,6 +231,7 @@ def _augment_violations_with_fix_commands(
"--ignore-feature-id",
"ignored",
multiple=True,
callback=validate_id_parameter_multiple,
help="Exclude feature from evaluation (Enforce only, repeatable).",
)
@click.option(
Expand Down
148 changes: 138 additions & 10 deletions src/specleft/commands/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

import click

from specleft.commands.input_validation import (
validate_id_parameter,
validate_text_parameter,
)
from specleft.commands.output import json_dumps, resolve_output_format
from specleft.commands.test import generate_test_stub
from specleft.schema import (
Expand Down Expand Up @@ -754,9 +758,15 @@ def features_stats(
@click.option(
"--id",
"feature_id",
callback=validate_id_parameter,
help="Feature ID (optional; defaults to a slug from the title).",
)
@click.option("--title", "title", help="Feature title (required).")
@click.option(
"--title",
"title",
callback=validate_text_parameter,
help="Feature title (required).",
)
@click.option(
"--priority",
"priority",
Expand All @@ -765,7 +775,12 @@ def features_stats(
show_default=True,
help="Feature priority.",
)
@click.option("--description", "description", help="Feature description.")
@click.option(
"--description",
"description",
callback=validate_text_parameter,
help="Feature description.",
)
@click.option(
"--dir",
"features_dir",
Expand Down Expand Up @@ -799,22 +814,41 @@ def features_add(

if interactive:
title_input = click.prompt("Feature title", type=str).strip()
title = title_input
default_feature_id = generate_feature_id(title_input)
feature_id_input = click.prompt(
"Feature ID",
default=default_feature_id,
show_default=True,
)
feature_id = feature_id_input.strip()
feature_id_value = feature_id_input.strip()
priority = click.prompt(
"Priority",
type=click.Choice([p.value for p in Priority], case_sensitive=False),
default=priority,
show_default=True,
)
description = click.prompt("Description", default="", show_default=False)
description = description.strip() if description else None
try:
title = validate_text_parameter(None, None, title_input)
feature_id = validate_id_parameter(None, None, feature_id_value)
description = validate_text_parameter(
None,
None,
description.strip() if description else None,
)
except click.BadParameter as exc:
payload = {
"success": False,
"action": "add",
"error": str(exc),
}
_print_feature_add_result(
result=payload,
format_type=selected_format,
dry_run=dry_run,
pretty=pretty,
)
sys.exit(1)

if not title:
click.secho(
Expand All @@ -837,6 +871,27 @@ def features_add(
print_support_footer()
sys.exit(1)

try:
feature_id = validate_id_parameter(None, None, feature_id)
title = validate_text_parameter(None, None, title)
description = validate_text_parameter(None, None, description)
except click.BadParameter as exc:
payload = {
"success": False,
"action": "add",
"error": str(exc),
}
_print_feature_add_result(
result=payload,
format_type=selected_format,
dry_run=dry_run,
pretty=pretty,
)
sys.exit(1)

assert feature_id is not None
assert title is not None

try:
validate_feature_id(feature_id)
except ValueError as exc:
Expand Down Expand Up @@ -895,10 +950,21 @@ def features_add(
@click.option(
"--feature",
"feature_id",
callback=validate_id_parameter,
help="Feature ID to append scenario to.",
)
@click.option("--title", "title", help="Scenario title.")
@click.option("--id", "scenario_id", help="Scenario ID (optional).")
@click.option(
"--title",
"title",
callback=validate_text_parameter,
help="Scenario title.",
)
@click.option(
"--id",
"scenario_id",
callback=validate_id_parameter,
help="Scenario ID (optional).",
)
@click.option(
"--step",
"steps",
Expand Down Expand Up @@ -968,16 +1034,15 @@ def features_add_scenario(
_ensure_interactive(interactive)

if interactive:
feature_id = click.prompt("Feature ID", type=str).strip()
feature_input = click.prompt("Feature ID", type=str).strip()
title_input = click.prompt("Scenario title", type=str).strip()
title = title_input
default_scenario_id = generate_scenario_id(title_input)
scenario_id_input = click.prompt(
"Scenario ID",
default=default_scenario_id,
show_default=True,
)
scenario_id = scenario_id_input.strip() or None
scenario_id_value = scenario_id_input.strip() or None
priority = click.prompt(
"Priority",
type=click.Choice([p.value for p in Priority], case_sensitive=False),
Expand All @@ -994,6 +1059,26 @@ def features_add_scenario(
break
steps_list.append(step)
steps = tuple(steps_list)
try:
feature_id = validate_id_parameter(None, None, feature_input)
title = validate_text_parameter(None, None, title_input)
scenario_id = validate_id_parameter(None, None, scenario_id_value)
except click.BadParameter as exc:
payload = {
"success": False,
"action": "add_scenario",
"feature_id": feature_input,
"scenario_id": scenario_id_value,
"error": str(exc),
}
_print_scenario_add_result(
result=payload,
format_type=selected_format,
dry_run=dry_run,
warnings=[],
pretty=pretty,
)
sys.exit(1)

if not feature_id or not title:
click.secho(
Expand All @@ -1006,6 +1091,28 @@ def features_add_scenario(

assert title is not None

try:
feature_id = validate_id_parameter(None, None, feature_id)
title = validate_text_parameter(None, None, title)
except click.BadParameter as exc:
payload = {
"success": False,
"action": "add_scenario",
"feature_id": feature_id,
"error": str(exc),
}
_print_scenario_add_result(
result=payload,
format_type=selected_format,
dry_run=dry_run,
warnings=[],
pretty=pretty,
)
sys.exit(1)

assert feature_id is not None
assert title is not None

try:
validate_feature_id(feature_id)
except ValueError as exc:
Expand All @@ -1028,6 +1135,27 @@ def features_add_scenario(
warnings = validate_step_keywords(steps_list) if steps_list else []
scenario_id = scenario_id or generate_scenario_id(title)

try:
scenario_id = validate_id_parameter(None, None, scenario_id)
except click.BadParameter as exc:
payload = {
"success": False,
"action": "add_scenario",
"feature_id": feature_id,
"scenario_id": scenario_id,
"error": str(exc),
}
_print_scenario_add_result(
result=payload,
format_type=selected_format,
dry_run=dry_run,
warnings=warnings,
pretty=pretty,
)
sys.exit(1)

assert scenario_id is not None

try:
validate_scenario_id(scenario_id)
except ValueError as exc:
Expand Down
16 changes: 14 additions & 2 deletions src/specleft/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ def _apply_init_plan(
@click.option("--example", is_flag=True, help="Create example feature specs.")
@click.option("--blank", is_flag=True, help="Create empty directory structure only.")
@click.option("--dry-run", is_flag=True, help="Show what would be created.")
@click.option(
"--force",
is_flag=True,
help="Regenerate SKILL.md even if it was modified.",
)
@click.option(
"--format",
"format_type",
Expand All @@ -191,7 +196,12 @@ def _apply_init_plan(
)
@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.")
def init(
example: bool, blank: bool, dry_run: bool, format_type: str | None, pretty: bool
example: bool,
blank: bool,
dry_run: bool,
force: bool,
format_type: str | None,
pretty: bool,
) -> None:
"""Initialize SpecLeft project directories and example specs."""
selected_format = resolve_output_format(format_type)
Expand Down Expand Up @@ -261,14 +271,16 @@ def init(
)
click.echo("")
created = _apply_init_plan(directories, files)
skill_sync = sync_skill_files(overwrite_existing=False)
skill_sync = sync_skill_files(overwrite_existing=force)

if selected_format == "json":
json_payload: dict[str, object] = {
"success": True,
"health": {"ok": True},
"skill_file": str(SKILL_FILE_PATH),
"skill_file_hash": skill_sync.skill_file_hash,
"skill_file_regenerated": skill_sync.skill_file_regenerated,
"warnings": skill_sync.warnings,
}
click.echo(json_dumps(json_payload, pretty=pretty))
return
Expand Down
Loading
Loading