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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
- Respect `NO_COLOR` and `FORCE_COLOR=0`/`false` for plain CLI output. Rich treats any non-empty `FORCE_COLOR` (including `0`) as a TTY, which broke scripted/demo stdout and pytest captures under `FORCE_COLOR=0`.

### Added
- **`cinch import`** — reverse-sync legacy `.cursorrules` / `.cursor/rules`, Claude `SKILL.md` packages, `.github/copilot-instructions.md` + `.github/instructions/*.instructions.md`, and `.clinerules` into canonical Cinch skills under `.cinch/imported` (auto-detect dialect; `--from-harness` when ambiguous; `--dry-run` / `--yes`)
- **Parameterized skills** — declare typed `parameters` in frontmatter (`string`/`int`/`boolean`/`choice`); override via `--param key=value` or `CINCH_PARAM_*`; safe `{{ name }}` / `$name` substitution at wire/preview time (stdlib only, no Jinja2)
- **Continue.dev adapter** — compile skills, agents, and commands into `.continue/prompts/<name>.prompt` with `invokable: true` slash commands; preserve Handlebars (`{{{ input }}}`); merge `customCommands` into an existing `.continue/config.json`
- **Grok adapter** — compile skills, agents, and commands into xAI Grok Build's `.grok/skills/<name>/SKILL.md` layout (passthrough of `paths`/`when-to-use`/`allowed-tools`; unsupported source frontmatter stripped; commands emit `disable-model-invocation: true`)
Expand Down
26 changes: 25 additions & 1 deletion src/cinch/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ def _yaml_quote(val: str) -> str:
return f'"{escaped}"'


def _format_frontmatter(fields: dict[str, str | list[str] | bool | None]) -> str:
def format_frontmatter(fields: dict[str, str | list[str] | bool | None]) -> str:
"""Serialize a flat frontmatter mapping to a ``---`` delimited YAML block."""
lines = ["---"]
for key, val in fields.items():
if val is None:
Expand All @@ -51,6 +52,29 @@ def _format_frontmatter(fields: dict[str, str | list[str] | bool | None]) -> str
return "\n".join(lines)


# Backwards-compatible alias for in-module call sites.
_format_frontmatter = format_frontmatter


def render_cinch_skill(doc: Doc) -> str:
"""Render a Doc as canonical Cinch skill markdown (``SKILL.md`` contents)."""
meta: dict[str, str | list[str] | bool | None] = {
"name": doc.name,
"description": doc.description,
}
if doc.paths:
meta["paths"] = list(doc.paths)
for key, val in doc.extra_meta.items():
if key in meta or key in ("name", "description", "paths", "globs", "applyTo"):
continue
if isinstance(val, (str, bool, list)):
meta[key] = val
elif val is not None:
meta[key] = str(val)
fm = format_frontmatter(meta)
return f"{fm}\n\n{doc.body}\n" if doc.body else f"{fm}\n"


class ClaudeAdapter:
target = "claude"

Expand Down
103 changes: 103 additions & 0 deletions src/cinch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,49 @@ def build_parser() -> argparse.ArgumentParser:
"and hardcoded user paths (stdlib detector; exits 1 on findings)"
),
)

from cinch.importer import IMPORT_DIALECTS

import_cmd = commands.add_parser(
"import",
help="Decompile legacy .cursorrules / Claude skills / Copilot / Cline into Cinch format",
parents=[shared],
description=(
"Reverse-sync legacy harness rules into canonical Cinch skill markdown "
"under --out (default: .cinch/imported). Auto-detects dialect from path "
"markers; pass --from-harness when detection is ambiguous."
),
)
import_cmd.add_argument(
"path",
help=(
"Legacy file or directory "
"(.cursorrules, SKILL.md, .github/instructions, .clinerules, …)"
),
)
import_cmd.add_argument(
"--out",
type=Path,
default=None,
help="Output directory for imported skills (default: .cinch/imported)",
)
import_cmd.add_argument(
"--from-harness",
"--from",
dest="from_harness",
choices=IMPORT_DIALECTS,
help="Source dialect when auto-detect is ambiguous or the path has no markers",
)
import_cmd.add_argument(
"--yes",
action="store_true",
help="Overwrite existing imported skills without prompting",
)
import_cmd.add_argument(
"--dry-run",
action="store_true",
help="Preview imported skills without writing files",
)
return parser


Expand All @@ -254,6 +297,8 @@ def main(argv: list[str] | None = None) -> int:
return _diff(args)
if args.command == "check":
return _check(args)
if args.command == "import":
return _import(args)
return _init(args)
except CinchError as exc:
print(f"cinch: {exc}", file=sys.stderr)
Expand Down Expand Up @@ -956,5 +1001,63 @@ def _check(args: argparse.Namespace) -> int:
return 1 if has_errors else 0


def _import(args: argparse.Namespace) -> int:
from cinch.importer import DEFAULT_OUT, run_import

path = Path(args.path).expanduser()
out = Path(args.out).expanduser() if args.out else Path.cwd() / DEFAULT_OUT
result = run_import(
path,
out=out,
dialect=getattr(args, "from_harness", None),
dry_run=bool(args.dry_run),
overwrite=bool(args.yes),
)

if not console.is_terminal:
print("cinch " + HOOK)
print(f" import {result.dialect}")
print(f" out {result.out}")
if result.written:
print(" skills " + ", ".join(result.written))
else:
print(" skills (none)")
for skip in result.skipped:
print(f" skipped {skip}")
if result.dry_run:
print(" dry-run no files written")
for rel, text in result.preview:
print(f"\n--- [file: {rel}] ---")
print(text, end="" if text.endswith("\n") else "\n")
return 0

body = Text()
body.append("import ", style="dim")
body.append(f"{result.dialect}\n", style="bold")
body.append("out ", style="dim")
body.append(f"{result.out}\n")
if result.written:
body.append("skills ", style="dim")
body.append(", ".join(result.written) + "\n", style="green")
else:
body.append("skills ", style="dim")
body.append("(none)\n", style="dim italic")
for skip in result.skipped:
body.append("skipped ", style="dim")
body.append(f"{skip}\n", style="yellow")
if result.dry_run:
body.append("dry-run ", style="dim")
body.append("no files written\n", style="bold yellow")

console.print(
Panel(body, title="[bold]cinch import[/bold]", subtitle=HOOK, border_style="blue")
)
if result.dry_run:
for rel, text in result.preview:
console.print(f"\n[dim]── file: [bold]{rel}[/bold] ──[/dim]")
console.print(Syntax(text, "markdown", theme="monokai", padding=1))
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading