Skip to content

API reference

Command-line interface

Command-line interface for packaged research-foundry templates.

Examples:

>>> callable(main)
True

main(argv=None)

Run the research-foundry command-line interface.

Parameters:

Name Type Description Default
argv Sequence[str] | None

Arguments without the executable name, or None for :data:sys.argv.

None

Returns:

Type Description
int

The process exit status.

Examples:

>>> callable(main)
True
Source code in src/research_foundry/cli.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def main(  # pylint: disable=too-many-return-statements
    argv: Sequence[str] | None = None,
) -> int:
    """Run the research-foundry command-line interface.

    Args:
        argv: Arguments without the executable name, or ``None`` for
            :data:`sys.argv`.

    Returns:
        The process exit status.

    Examples:
        >>> callable(main)
        True
    """
    args = _parser().parse_args(argv)
    try:
        if args.command == "templates":
            for item in list_templates():
                print(f"{item.name}\t{item.title}\t{item.description}")
            return 0
        if args.command == "questions":
            questions = describe_questions(str(args.template))
            if args.as_json:
                print(
                    json.dumps([question.as_dict() for question in questions])
                )
            else:
                for question in questions:
                    choices = ", ".join(question.choices) or "free text"
                    print(
                        f"{question.name}: {question.prompt} "
                        f"[default: {question.default}; choices: {choices}]"
                    )
            return 0
        if args.command == "new":
            project = create_project(
                str(args.template),
                _answers(args.answer),
                args.output_dir,
            )
            print(project)
            return 0
        if args.command == "check":
            return 0 if check_project(args.path) else 1
        if args.command == "update":
            return 0 if update_project(args.path, yes=args.yes) else 1
        if args.command == "install-skills":
            modes = [
                flag
                for flag in ("check", "adopt", "force")
                if getattr(args, flag)
            ]
            return install_skills_main([f"--{modes[0]}"] if modes else [])
        if args.command == "mcp":
            # Import lazily so non-server commands have no MCP startup cost.
            # pylint: disable-next=import-outside-toplevel
            from research_foundry.server import run

            run()
            return 0
    except (FoundryError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return 2
    return 2

Templates

Read and render the templates shipped by research-foundry.

Examples:

>>> {item.name for item in list_templates()} == set(TEMPLATE_NAMES)
True

FoundryError

Bases: RuntimeError

A requested operation was refused with a corrective message.

Examples:

>>> str(FoundryError("Choose a known template."))
'Choose a known template.'
Source code in src/research_foundry/templates.py
33
34
35
36
37
38
39
class FoundryError(RuntimeError):
    """A requested operation was refused with a corrective message.

    Examples:
        >>> str(FoundryError("Choose a known template."))
        'Choose a known template.'
    """

HookRefusal

Bases: FoundryError

A template hook refused the selected answers.

Examples:

>>> str(HookRefusal("Choose a compatible component set."))
'Choose a compatible component set.'
Source code in src/research_foundry/templates.py
42
43
44
45
46
47
48
class HookRefusal(FoundryError):
    """A template hook refused the selected answers.

    Examples:
        >>> str(HookRefusal("Choose a compatible component set."))
        'Choose a compatible component set.'
    """

Question dataclass

One answer accepted by a template.

Parameters:

Name Type Description Default
name str

The context key.

required
prompt str

The text shown to a person.

required
default str

The non-interactive default.

required
choices tuple[str, ...]

Allowed values, or an empty tuple for free text.

required

Examples:

>>> Question("venue", "Venue", "iclr", ("iclr",)).default
'iclr'
Source code in src/research_foundry/templates.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
@dataclass(frozen=True)
class Question:
    """One answer accepted by a template.

    Args:
        name: The context key.
        prompt: The text shown to a person.
        default: The non-interactive default.
        choices: Allowed values, or an empty tuple for free text.

    Examples:
        >>> Question("venue", "Venue", "iclr", ("iclr",)).default
        'iclr'
    """

    name: str
    prompt: str
    default: str
    choices: tuple[str, ...]

    def as_dict(self) -> dict[str, str | list[str]]:
        """Return a JSON-compatible representation.

        Returns:
            The question fields with choices represented as a list.

        Examples:
            >>> Question("x", "X", "a", ("a",)).as_dict()["choices"]
            ['a']
        """
        return {
            "name": self.name,
            "prompt": self.prompt,
            "default": self.default,
            "choices": list(self.choices),
        }

as_dict()

Return a JSON-compatible representation.

Returns:

Type Description
dict[str, str | list[str]]

The question fields with choices represented as a list.

Examples:

>>> Question("x", "X", "a", ("a",)).as_dict()["choices"]
['a']
Source code in src/research_foundry/templates.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def as_dict(self) -> dict[str, str | list[str]]:
    """Return a JSON-compatible representation.

    Returns:
        The question fields with choices represented as a list.

    Examples:
        >>> Question("x", "X", "a", ("a",)).as_dict()["choices"]
        ['a']
    """
    return {
        "name": self.name,
        "prompt": self.prompt,
        "default": self.default,
        "choices": list(self.choices),
    }

Template dataclass

One public project template.

Parameters:

Name Type Description Default
name str

The command-line template name.

required
title str

The human-readable title.

required
description str

A short explanation of the generated project.

required

Examples:

>>> Template("paper", "Paper", "A paper.").name
'paper'
Source code in src/research_foundry/templates.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
@dataclass(frozen=True)
class Template:
    """One public project template.

    Args:
        name: The command-line template name.
        title: The human-readable title.
        description: A short explanation of the generated project.

    Examples:
        >>> Template("paper", "Paper", "A paper.").name
        'paper'
    """

    name: str
    title: str
    description: str

    def as_dict(self) -> dict[str, str]:
        """Return a JSON-compatible representation.

        Returns:
            The template's three public fields.

        Examples:
            >>> Template("paper", "Paper", "A paper.").as_dict()["title"]
            'Paper'
        """
        return asdict(self)

as_dict()

Return a JSON-compatible representation.

Returns:

Type Description
dict[str, str]

The template's three public fields.

Examples:

>>> Template("paper", "Paper", "A paper.").as_dict()["title"]
'Paper'
Source code in src/research_foundry/templates.py
102
103
104
105
106
107
108
109
110
111
112
def as_dict(self) -> dict[str, str]:
    """Return a JSON-compatible representation.

    Returns:
        The template's three public fields.

    Examples:
        >>> Template("paper", "Paper", "A paper.").as_dict()["title"]
        'Paper'
    """
    return asdict(self)

asked(template, root=None)

Return the public answer names accepted by a template.

Parameters:

Name Type Description Default
template str

A name from :func:list_templates.

required
root Path | None

An alternate template tree, mainly for isolated tests.

None

Returns:

Type Description
set[str]

The template's non-private context keys.

Raises:

Type Description
FoundryError

If the template is unknown or malformed.

Examples:

>>> "project_name" in asked("workspace")
True
Source code in src/research_foundry/templates.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def asked(template: str, root: Path | None = None) -> set[str]:
    """Return the public answer names accepted by a template.

    Args:
        template: A name from :func:`list_templates`.
        root: An alternate template tree, mainly for isolated tests.

    Returns:
        The template's non-private context keys.

    Raises:
        FoundryError: If the template is unknown or malformed.

    Examples:
        >>> "project_name" in asked("workspace")
        True
    """
    return {question.name for question in describe_questions(template, root)}

asked_from_json(text)

Return public answer names from Cookiecutter context JSON.

Parameters:

Name Type Description Default
text str

The complete cookiecutter.json text.

required

Returns:

Type Description
set[str]

Every non-private top-level key.

Raises:

Type Description
FoundryError

If the text is not a JSON object.

Examples:

>>> asked_from_json('{"name": "Example", "_private": "x"}')
{'name'}
Source code in src/research_foundry/templates.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def asked_from_json(text: str) -> set[str]:
    """Return public answer names from Cookiecutter context JSON.

    Args:
        text: The complete ``cookiecutter.json`` text.

    Returns:
        Every non-private top-level key.

    Raises:
        FoundryError: If the text is not a JSON object.

    Examples:
        >>> asked_from_json('{"name": "Example", "_private": "x"}')
        {'name'}
    """
    loaded: object = json.loads(text)
    if not isinstance(loaded, dict):
        raise FoundryError(
            "The template context is not a JSON object; repair "
            "cookiecutter.json."
        )
    return {str(name) for name in loaded if not str(name).startswith("_")}

create_project(template, answers, output_dir, *, source=None, write_cruft=True)

Render one project offline from the packaged templates.

Parameters:

Name Type Description Default
template str

A name from :func:list_templates.

required
answers Mapping[str, str]

Explicit Cookiecutter context overrides.

required
output_dir Path

Parent directory for the generated project.

required
source Path | None

An alternate template tree, mainly for isolated tests.

None
write_cruft bool

Whether to record update metadata.

True

Returns:

Type Description
Path

The generated project directory.

Raises:

Type Description
FoundryError

If the template or an answer is unknown.

HookRefusal

If the template's hook refuses the answer combination.

Examples:

>>> import tempfile
>>> with tempfile.TemporaryDirectory() as directory:
...     made = create_project("workspace", {}, Path(directory),
...                           write_cruft=False)
...     (made / "README.md").is_file()
True
Source code in src/research_foundry/templates.py
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
def create_project(
    template: str,
    answers: Mapping[str, str],
    output_dir: Path,
    *,
    source: Path | None = None,
    write_cruft: bool = True,
) -> Path:
    """Render one project offline from the packaged templates.

    Args:
        template: A name from :func:`list_templates`.
        answers: Explicit Cookiecutter context overrides.
        output_dir: Parent directory for the generated project.
        source: An alternate template tree, mainly for isolated tests.
        write_cruft: Whether to record update metadata.

    Returns:
        The generated project directory.

    Raises:
        FoundryError: If the template or an answer is unknown.
        HookRefusal: If the template's hook refuses the answer combination.

    Examples:
        >>> import tempfile
        >>> with tempfile.TemporaryDirectory() as directory:
        ...     made = create_project("workspace", {}, Path(directory),
        ...                           write_cruft=False)
        ...     (made / "README.md").is_file()
        True
    """
    root = source or template_root()
    validate_answers(template, answers, root)
    output_dir.mkdir(parents=True, exist_ok=True)
    before = set(output_dir.iterdir())
    with prepared_templates(root) as work:
        captured = work / "hook-stderr.txt"
        try:
            config = write_cookiecutter_config(work)
            with _captured_stderr(captured):
                rendered = cookiecutter(
                    str(work),
                    directory=template,
                    no_input=True,
                    output_dir=str(output_dir),
                    extra_context=dict(answers),
                    config_file=str(config),
                )
        except FailedHookException as error:
            for path in set(output_dir.iterdir()) - before:
                if path.is_dir():
                    shutil.rmtree(path)
                else:
                    path.unlink()
            raise HookRefusal(hook_reason(captured.read_text())) from error
        project = Path(rendered)
        if write_cruft:
            _write_cruft(project, template, answers, work)
    return project

describe_questions(template, root=None)

Describe every public answer a template accepts.

Parameters:

Name Type Description Default
template str

A name from :func:list_templates.

required
root Path | None

An alternate template tree, mainly for isolated tests.

None

Returns:

Type Description
list[Question]

Questions in Cookiecutter context order.

Raises:

Type Description
FoundryError

If the template is unknown or its context is malformed.

Examples:

>>> describe_questions("paper")[0].name
'project_name'
Source code in src/research_foundry/templates.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def describe_questions(
    template: str, root: Path | None = None
) -> list[Question]:
    """Describe every public answer a template accepts.

    Args:
        template: A name from :func:`list_templates`.
        root: An alternate template tree, mainly for isolated tests.

    Returns:
        Questions in Cookiecutter context order.

    Raises:
        FoundryError: If the template is unknown or its context is malformed.

    Examples:
        >>> describe_questions("paper")[0].name
        'project_name'
    """
    source = root or template_root()
    _known_template(template, source)
    context = _mapping(source / template / "cookiecutter.json")
    generated = generate_context(
        context_file=str(source / template / "cookiecutter.json")
    )
    # Cookiecutter's public prompt resolver computes dependent Jinja defaults.
    resolved: dict[str, object] = dict(
        prompt_for_config(generated, no_input=True)
    )
    raw_prompts = context.get("__prompts__", {})
    prompts = raw_prompts if isinstance(raw_prompts, dict) else {}
    result: list[Question] = []
    for name, value in context.items():
        if name.startswith("_"):
            continue
        choices = (
            tuple(str(item) for item in value)
            if isinstance(value, list)
            else ()
        )
        default = str(resolved[name])
        prompt = prompts.get(name, name)
        result.append(Question(name, str(prompt), default, choices))
    return result

hook_reason(text)

Extract a generation hook's own refusal reason.

Parameters:

Name Type Description Default
text str

Cookiecutter's captured standard error.

required

Returns:

Type Description
str

Hook output before Cookiecutter's wrapper message.

Examples:

>>> hook_reason("choose another value\nStopping generation!")
'choose another value'
Source code in src/research_foundry/templates.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
def hook_reason(text: str) -> str:
    r"""Extract a generation hook's own refusal reason.

    Args:
        text: Cookiecutter's captured standard error.

    Returns:
        Hook output before Cookiecutter's wrapper message.

    Examples:
        >>> hook_reason("choose another value\nStopping generation!")
        'choose another value'
    """
    said: list[str] = []
    for line in text.splitlines():
        if line.startswith("Stopping generation"):
            break
        if line.strip():
            said.append(line.strip())
    return " ".join(said) or "refused by a generation hook"

list_templates(root=None)

List the templates in the root catalog.

Parameters:

Name Type Description Default
root Path | None

An alternate template tree, mainly for isolated tests.

None

Returns:

Type Description
list[Template]

Templates in catalog order.

Raises:

Type Description
FoundryError

If the installed catalog is malformed.

Examples:

>>> [item.name for item in list_templates()]
['methodology', 'workspace', 'paper', 'software']
Source code in src/research_foundry/templates.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def list_templates(root: Path | None = None) -> list[Template]:
    """List the templates in the root catalog.

    Args:
        root: An alternate template tree, mainly for isolated tests.

    Returns:
        Templates in catalog order.

    Raises:
        FoundryError: If the installed catalog is malformed.

    Examples:
        >>> [item.name for item in list_templates()]
        ['methodology', 'workspace', 'paper', 'software']
    """
    source = root or template_root()
    catalog = _mapping(source / "cookiecutter.json")
    entries = catalog.get("templates")
    if not isinstance(entries, dict):
        raise FoundryError(
            "The template catalog has no templates mapping; reinstall the "
            "package."
        )
    result: list[Template] = []
    for name, value in entries.items():
        if not isinstance(name, str) or not isinstance(value, dict):
            raise FoundryError(
                "The template catalog is malformed; reinstall the package."
            )
        title = value.get("title")
        description = value.get("description")
        if not isinstance(title, str) or not isinstance(description, str):
            raise FoundryError(
                f"Template {name!r} lacks a title or description; fix the "
                "catalog."
            )
        result.append(Template(name, title, description))
    return result

plan_project(template, answers, *, source=None)

Validate a project by rendering it and return its file list.

Parameters:

Name Type Description Default
template str

A name from :func:list_templates.

required
answers Mapping[str, str]

Explicit Cookiecutter context overrides.

required
source Path | None

An alternate template tree, mainly for isolated tests.

None

Returns:

Type Description
list[str]

Sorted file paths relative to the generated project.

Raises:

Type Description
FoundryError

If the template or an answer is unknown.

HookRefusal

If the template's hook refuses the answer combination.

Examples:

>>> "README.md" in plan_project("workspace", {})
True
Source code in src/research_foundry/templates.py
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
def plan_project(
    template: str,
    answers: Mapping[str, str],
    *,
    source: Path | None = None,
) -> list[str]:
    """Validate a project by rendering it and return its file list.

    Args:
        template: A name from :func:`list_templates`.
        answers: Explicit Cookiecutter context overrides.
        source: An alternate template tree, mainly for isolated tests.

    Returns:
        Sorted file paths relative to the generated project.

    Raises:
        FoundryError: If the template or an answer is unknown.
        HookRefusal: If the template's hook refuses the answer combination.

    Examples:
        >>> "README.md" in plan_project("workspace", {})
        True
    """
    with tempfile.TemporaryDirectory(prefix="research-foundry-plan-") as temp:
        project = create_project(
            template,
            answers,
            Path(temp),
            source=source,
            write_cruft=False,
        )
        return sorted(
            str(path.relative_to(project))
            for path in project.rglob("*")
            if path.is_file()
        )

prepared_templates(root=None)

Yield a work copy with each template's shared include restored.

Parameters:

Name Type Description Default
root Path | None

The installed or source template tree.

None

Yields:

Type Description
Path

A temporary Cookiecutter repository layout.

Raises:

Type Description
FoundryError

If the template catalog is malformed.

Examples:

>>> with prepared_templates() as work:
...     all((work / name / "templates").exists()
...         for name in TEMPLATE_NAMES)
True
Source code in src/research_foundry/templates.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
@contextmanager
def prepared_templates(root: Path | None = None) -> Iterator[Path]:
    """Yield a work copy with each template's shared include restored.

    Args:
        root: The installed or source template tree.

    Yields:
        A temporary Cookiecutter repository layout.

    Raises:
        FoundryError: If the template catalog is malformed.

    Examples:
        >>> with prepared_templates() as work:
        ...     all((work / name / "templates").exists()
        ...         for name in TEMPLATE_NAMES)
        True
    """
    source = root or template_root()
    names = [item.name for item in list_templates(source)]
    with tempfile.TemporaryDirectory(prefix="research-foundry-") as temporary:
        work = Path(temporary)
        shutil.copy2(source / "cookiecutter.json", work / "cookiecutter.json")
        shutil.copytree(source / "_shared", work / "_shared")
        for name in names:
            target = work / name
            shutil.copytree(source / name, target, symlinks=True)
            include = target / "templates"
            if not include.exists():
                try:
                    include.symlink_to("../_shared", target_is_directory=True)
                except OSError:
                    shutil.copytree(work / "_shared", include)
        yield work

release_commit(repository)

Resolve this release's tag to a full SHA when reachable.

Parameters:

Name Type Description Default
repository str

A Git URL or local repository path.

required

Returns:

Type Description
str

The full tag SHA, or v<version> when Git cannot reach it.

Examples:

>>> release_commit("/path/that/does/not/exist") == f"v{__version__}"
True
Source code in src/research_foundry/templates.py
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def release_commit(repository: str) -> str:
    """Resolve this release's tag to a full SHA when reachable.

    Args:
        repository: A Git URL or local repository path.

    Returns:
        The full tag SHA, or ``v<version>`` when Git cannot reach it.

    Examples:
        >>> release_commit("/path/that/does/not/exist") == f"v{__version__}"
        True
    """
    tag = f"v{__version__}"
    try:
        completed = subprocess.run(
            [
                "git",
                "ls-remote",
                "--tags",
                repository,
                f"refs/tags/{tag}",
                f"refs/tags/{tag}^{{}}",
            ],
            env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
            check=False,
            capture_output=True,
            text=True,
            timeout=5,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return tag
    if completed.returncode != 0:
        return tag
    lines = [line.split()[0] for line in completed.stdout.splitlines() if line]
    return lines[-1] if lines else tag

repository_url(environ=None)

Return the update repository, honoring the test/user override.

Parameters:

Name Type Description Default
environ Mapping[str, str] | None

An alternate environment mapping.

None

Returns:

Type Description
str

The configured repository URL or the public repository.

Examples:

>>> repository_url({REPOSITORY_ENV: "/tmp/foundry"})
'/tmp/foundry'
Source code in src/research_foundry/templates.py
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
def repository_url(environ: Mapping[str, str] | None = None) -> str:
    """Return the update repository, honoring the test/user override.

    Args:
        environ: An alternate environment mapping.

    Returns:
        The configured repository URL or the public repository.

    Examples:
        >>> repository_url({REPOSITORY_ENV: "/tmp/foundry"})
        '/tmp/foundry'
    """
    environment = os.environ if environ is None else environ
    return environment.get(REPOSITORY_ENV, PUBLIC_REPOSITORY)

template_root()

Return the installed template tree or source-checkout fallback.

Returns:

Type Description
Path

A directory containing the root cookiecutter.json.

Raises:

Type Description
FoundryError

If neither packaged nor source templates exist.

Examples:

>>> (template_root() / "cookiecutter.json").is_file()
True
Source code in src/research_foundry/templates.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def template_root() -> Path:
    """Return the installed template tree or source-checkout fallback.

    Returns:
        A directory containing the root ``cookiecutter.json``.

    Raises:
        FoundryError: If neither packaged nor source templates exist.

    Examples:
        >>> (template_root() / "cookiecutter.json").is_file()
        True
    """
    packaged = Path(__file__).resolve().parent / "templates"
    if (packaged / "cookiecutter.json").is_file():
        return packaged
    checkout = Path(__file__).resolve().parents[2]
    if (checkout / "cookiecutter.json").is_file():
        return checkout
    raise FoundryError(
        "The packaged templates are missing; reinstall research-foundry."
    )

validate_answers(template, answers, root=None)

Refuse unknown templates and answers before generation writes files.

Parameters:

Name Type Description Default
template str

A name from :func:list_templates.

required
answers Mapping[str, str]

Explicit Cookiecutter context overrides.

required
root Path | None

An alternate template tree, mainly for isolated tests.

None

Raises:

Type Description
FoundryError

If the template or any answer is unknown.

Examples:

>>> validate_answers("workspace", {"project_name": "Example"})
Source code in src/research_foundry/templates.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
def validate_answers(
    template: str,
    answers: Mapping[str, str],
    root: Path | None = None,
) -> None:
    """Refuse unknown templates and answers before generation writes files.

    Args:
        template: A name from :func:`list_templates`.
        answers: Explicit Cookiecutter context overrides.
        root: An alternate template tree, mainly for isolated tests.

    Raises:
        FoundryError: If the template or any answer is unknown.

    Examples:
        >>> validate_answers("workspace", {"project_name": "Example"})
    """
    accepted = asked(template, root)
    unknown = sorted(set(answers) - accepted)
    if unknown:
        names = ", ".join(unknown)
        raise FoundryError(
            f"Template {template!r} does not ask for {names}. "
            "Remove that --answer or run 'research-foundry questions "
            f"{template}'."
        )

write_cookiecutter_config(workdir)

Write a Cookiecutter config whose mutable paths stay inside workdir.

Parameters:

Name Type Description Default
workdir Path

The temporary directory containing this Cookiecutter run.

required

Returns:

Type Description
Path

The config file path for Cookiecutter or cruft.

Examples:

>>> import tempfile
>>> with tempfile.TemporaryDirectory() as work:
...     config = write_cookiecutter_config(Path(work))
...     settings = json.loads(config.read_text())
...     Path(settings["replay_dir"]).parent == Path(work).resolve()
True
Source code in src/research_foundry/templates.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def write_cookiecutter_config(workdir: Path) -> Path:
    """Write a Cookiecutter config whose mutable paths stay inside ``workdir``.

    Args:
        workdir: The temporary directory containing this Cookiecutter run.

    Returns:
        The config file path for Cookiecutter or cruft.

    Examples:
        >>> import tempfile
        >>> with tempfile.TemporaryDirectory() as work:
        ...     config = write_cookiecutter_config(Path(work))
        ...     settings = json.loads(config.read_text())
        ...     Path(settings["replay_dir"]).parent == Path(work).resolve()
        True
    """
    root = workdir.resolve()
    root.mkdir(parents=True, exist_ok=True)
    config = root / "cookiecutter-config.json"
    config.write_text(
        json.dumps(
            {
                "default_context": {},
                "cookiecutters_dir": str(root / "cookiecutters"),
                "replay_dir": str(root / "replay"),
            }
        ),
        encoding="utf-8",
    )
    return config

Cruft updates

Check and update projects created by research-foundry.

Examples:

>>> callable(check_project) and callable(update_project)
True

check_project(path)

Return whether a project matches the latest template release.

Parameters:

Name Type Description Default
path Path

The generated project's root directory.

required

Returns:

Type Description
bool

True when the project is current, otherwise False.

Raises:

Type Description
FoundryError

If path is not a generated project.

Examples:

>>> callable(check_project)
True
Source code in src/research_foundry/cruft.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def check_project(path: Path) -> bool:
    """Return whether a project matches the latest template release.

    Args:
        path: The generated project's root directory.

    Returns:
        ``True`` when the project is current, otherwise ``False``.

    Raises:
        FoundryError: If ``path`` is not a generated project.

    Examples:
        >>> callable(check_project)
        True
    """
    _require_project(path)
    return bool(check(project_dir=path))

update_project(path, *, yes=False)

Apply the latest template release to a generated project.

Parameters:

Name Type Description Default
path Path

The generated project's root directory.

required
yes bool

Apply without cruft's confirmation prompt.

False

Returns:

Type Description
bool

Cruft's success result.

Raises:

Type Description
FoundryError

If path is not a generated project.

Examples:

>>> callable(update_project)
True
Source code in src/research_foundry/cruft.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def update_project(path: Path, *, yes: bool = False) -> bool:
    """Apply the latest template release to a generated project.

    Args:
        path: The generated project's root directory.
        yes: Apply without cruft's confirmation prompt.

    Returns:
        Cruft's success result.

    Raises:
        FoundryError: If ``path`` is not a generated project.

    Examples:
        >>> callable(update_project)
        True
    """
    _require_project(path)
    return bool(update(project_dir=path, skip_apply_ask=yes))

Model Context Protocol server

Model Context Protocol server for research-foundry.

Examples:

>>> server.name
'research-foundry'

ToolResult

Bases: TypedDict

A JSON-compatible success or refusal from an MCP tool.

Examples:

>>> ToolResult(ok=False, error="Fix the request.")["ok"]
False
Source code in src/research_foundry/server.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class ToolResult(TypedDict, total=False):
    """A JSON-compatible success or refusal from an MCP tool.

    Examples:
        >>> ToolResult(ok=False, error="Fix the request.")["ok"]
        False
    """

    ok: bool
    error: str
    questions: list[dict[str, str | list[str]]]
    files: list[str]
    path: str
    up_to_date: bool
    updated: bool

check_project(path)

Check whether a generated project is behind its template.

Parameters:

Name Type Description Default
path str

The generated project's root directory.

required

Returns:

Type Description
ToolResult

The current/behind result or a corrective refusal.

Examples:

>>> check_project("/path/that/does/not/exist")["ok"]
False
Source code in src/research_foundry/server.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
@server.tool()
def check_project(path: str) -> ToolResult:
    """Check whether a generated project is behind its template.

    Args:
        path: The generated project's root directory.

    Returns:
        The current/behind result or a corrective refusal.

    Examples:
        >>> check_project("/path/that/does/not/exist")["ok"]
        False
    """
    project = Path(path)
    if not (project / ".cruft.json").is_file():
        return {
            "ok": False,
            "error": (
                f"{project} has no .cruft.json; run this command in a project "
                "created by research-foundry."
            ),
        }
    try:
        current = _check_project(project)
    except (OSError, RuntimeError, ValueError) as error:
        return {
            "ok": False,
            "error": (
                f"The project could not be checked: {error}; repair "
                ".cruft.json and retry."
            ),
        }
    return {"ok": True, "up_to_date": current}

create_project(template, answers, output_dir, confirm)

Create a project after explicit confirmation.

Parameters:

Name Type Description Default
template str

A template name returned by :func:list_templates.

required
answers dict[str, str]

Explicit Cookiecutter context overrides.

required
output_dir str

Parent directory for the generated project.

required
confirm bool

Must be True before anything is written.

required

Returns:

Type Description
ToolResult

The created path or a corrective refusal.

Examples:

>>> create_project("workspace", {}, "/tmp/example", False)["ok"]
False
Source code in src/research_foundry/server.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
@server.tool()
def create_project(
    template: str,
    answers: dict[str, str],
    output_dir: str,
    confirm: bool,
) -> ToolResult:
    """Create a project after explicit confirmation.

    Args:
        template: A template name returned by :func:`list_templates`.
        answers: Explicit Cookiecutter context overrides.
        output_dir: Parent directory for the generated project.
        confirm: Must be ``True`` before anything is written.

    Returns:
        The created path or a corrective refusal.

    Examples:
        >>> create_project("workspace", {}, "/tmp/example", False)["ok"]
        False
    """
    if not confirm:
        return {
            "ok": False,
            "error": (
                "Project creation is not confirmed; review the plan and call "
                "create_project again with confirm=true."
            ),
        }
    try:
        project = _create_project(template, answers, Path(output_dir))
    except (FoundryError, HookRefusal) as error:
        return {"ok": False, "error": str(error)}
    return {"ok": True, "path": str(project)}

describe_questions(template)

Describe a template's public answers, defaults, and choices.

Parameters:

Name Type Description Default
template str

A template name returned by :func:list_templates.

required

Returns:

Type Description
ToolResult

Questions on success, or a corrective refusal.

Examples:

>>> describe_questions("paper")["ok"]
True
Source code in src/research_foundry/server.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@server.tool()
def describe_questions(template: str) -> ToolResult:
    """Describe a template's public answers, defaults, and choices.

    Args:
        template: A template name returned by :func:`list_templates`.

    Returns:
        Questions on success, or a corrective refusal.

    Examples:
        >>> describe_questions("paper")["ok"]
        True
    """
    try:
        questions = [item.as_dict() for item in _describe_questions(template)]
    except FoundryError as error:
        return {"ok": False, "error": str(error)}
    return {"ok": True, "questions": questions}

list_templates()

List available templates with their titles and descriptions.

Returns:

Type Description
list[dict[str, str]]

JSON-compatible template descriptions in catalog order.

Examples:

>>> list_templates()[0]["name"]
'methodology'
Source code in src/research_foundry/server.py
51
52
53
54
55
56
57
58
59
60
61
62
@server.tool()
def list_templates() -> list[dict[str, str]]:
    """List available templates with their titles and descriptions.

    Returns:
        JSON-compatible template descriptions in catalog order.

    Examples:
        >>> list_templates()[0]["name"]
        'methodology'
    """
    return [item.as_dict() for item in _list_templates()]

plan_project(template, answers)

Validate answers by rendering temporarily, then list the files.

Parameters:

Name Type Description Default
template str

A template name returned by :func:list_templates.

required
answers dict[str, str]

Explicit Cookiecutter context overrides.

required

Returns:

Type Description
ToolResult

The generated file list or the hook's corrective refusal reason.

Examples:

>>> plan_project("workspace", {})["ok"]
True
Source code in src/research_foundry/server.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
@server.tool()
def plan_project(template: str, answers: dict[str, str]) -> ToolResult:
    """Validate answers by rendering temporarily, then list the files.

    Args:
        template: A template name returned by :func:`list_templates`.
        answers: Explicit Cookiecutter context overrides.

    Returns:
        The generated file list or the hook's corrective refusal reason.

    Examples:
        >>> plan_project("workspace", {})["ok"]
        True
    """
    try:
        files = _plan_project(template, answers)
    except (FoundryError, HookRefusal) as error:
        return {"ok": False, "error": str(error)}
    return {"ok": True, "files": files}

run()

Run the server over standard input and output.

Examples:

>>> callable(run)
True
Source code in src/research_foundry/server.py
226
227
228
229
230
231
232
233
def run() -> None:
    """Run the server over standard input and output.

    Examples:
        >>> callable(run)
        True
    """
    server.run(transport="stdio")

update_project(path, confirm)

Update a generated project after explicit confirmation.

Parameters:

Name Type Description Default
path str

The generated project's root directory.

required
confirm bool

Must be True before cruft applies changes.

required

Returns:

Type Description
ToolResult

The update result or a corrective refusal.

Examples:

>>> update_project("/tmp/example", False)["ok"]
False
Source code in src/research_foundry/server.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
@server.tool()
def update_project(path: str, confirm: bool) -> ToolResult:
    """Update a generated project after explicit confirmation.

    Args:
        path: The generated project's root directory.
        confirm: Must be ``True`` before cruft applies changes.

    Returns:
        The update result or a corrective refusal.

    Examples:
        >>> update_project("/tmp/example", False)["ok"]
        False
    """
    if not confirm:
        return {
            "ok": False,
            "error": (
                "Project update is not confirmed; review the pending update "
                "and call update_project again with confirm=true."
            ),
        }
    project = Path(path)
    if not (project / ".cruft.json").is_file():
        return {
            "ok": False,
            "error": (
                f"{project} has no .cruft.json; choose a project created by "
                "research-foundry."
            ),
        }
    try:
        updated = _update_project(project, yes=True)
    except (OSError, RuntimeError, ValueError) as error:
        return {
            "ok": False,
            "error": (
                f"The project could not be updated: {error}; repair "
                ".cruft.json and retry."
            ),
        }
    return {"ok": updated, "updated": updated}

Skills

Install research-foundry's maintained agent skills for one user.

Examples:

>>> Report().ok
True

Report dataclass

Describe the files written, adopted, pending, or refused.

Examples:

>>> Report(missing=["release/SKILL.md"]).ok
False
Source code in src/research_foundry/skills.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@dataclass
class Report:
    """Describe the files written, adopted, pending, or refused.

    Examples:
        >>> Report(missing=["release/SKILL.md"]).ok
        False
    """

    written: list[str] = field(default_factory=list)
    adopted: list[str] = field(default_factory=list)
    missing: list[str] = field(default_factory=list)
    stale: list[str] = field(default_factory=list)
    drifted: list[str] = field(default_factory=list)
    foreign: list[str] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        """Return whether nothing was refused or left to do.

        Returns:
            ``True`` only when the destination matches the source.

        Examples:
            >>> Report().ok
            True
        """
        return not (self.drifted or self.foreign or self.missing or self.stale)

ok property

Return whether nothing was refused or left to do.

Returns:

Type Description
bool

True only when the destination matches the source.

Examples:

>>> Report().ok
True

destination(environ=None)

Return where skills are installed for this user.

Parameters:

Name Type Description Default
environ Mapping[str, str] | None

An alternate environment mapping.

None

Returns:

Type Description
Path

The user's configured skills directory.

Examples:

>>> destination({"HOME": "/home/example"})
PosixPath('/home/example/.claude/skills')
Source code in src/research_foundry/skills.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def destination(environ: Mapping[str, str] | None = None) -> Path:
    """Return where skills are installed for this user.

    Args:
        environ: An alternate environment mapping.

    Returns:
        The user's configured skills directory.

    Examples:
        >>> destination({"HOME": "/home/example"})
        PosixPath('/home/example/.claude/skills')
    """
    environment = os.environ if environ is None else environ
    base = environment.get("CLAUDE_CONFIG_DIR") or str(
        Path(environment["HOME"]) / ".claude"
    )
    return Path(base) / "skills"

main(argv=None)

Run the skill installer.

Parameters:

Name Type Description Default
argv Sequence[str] | None

Arguments without the executable name.

None

Returns:

Type Description
int

Zero on success, one when check finds drift or installation refuses.

Examples:

>>> callable(main)
True
Source code in src/research_foundry/skills.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def main(argv: Sequence[str] | None = None) -> int:
    """Run the skill installer.

    Args:
        argv: Arguments without the executable name.

    Returns:
        Zero on success, one when check finds drift or installation refuses.

    Examples:
        >>> callable(main)
        True
    """
    parser = argparse.ArgumentParser(
        description="Install research-foundry's maintained skills."
    )
    group = parser.add_mutually_exclusive_group()
    for mode in MODES[1:]:
        group.add_argument(f"--{mode}", action="store_true")
    args = parser.parse_args(argv)
    mode = next((item for item in MODES[1:] if getattr(args, item)), "install")
    dest = destination()
    report = run(mode, SKILLS, dest)
    print(f"skills -> {dest} ({mode})")
    _print(report, dest)
    if mode == "check":
        return 0 if report.ok else 1
    return 0 if not (report.drifted or report.foreign) else 1

run(mode, source=SKILLS, dest=None)

Install, check, adopt, or force the skills into a destination.

Parameters:

Name Type Description Default
mode str

One of :data:MODES.

required
source Path

The maintained skill tree.

SKILLS
dest Path | None

The user's skills directory; :func:destination by default.

None

Returns:

Type Description
Report

What was written, adopted, pending, or refused.

Raises:

Type Description
ValueError

If mode is unknown or the manifest is malformed.

Examples:

>>> import tempfile
>>> with tempfile.TemporaryDirectory() as temporary:
...     root = Path(temporary)
...     source = root / "source"
...     (source / "demo").mkdir(parents=True)
...     _ = (source / "demo" / "SKILL.md").write_text("demo\n")
...     run("install", source, root / "dest").ok
True
Source code in src/research_foundry/skills.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def run(mode: str, source: Path = SKILLS, dest: Path | None = None) -> Report:
    r"""Install, check, adopt, or force the skills into a destination.

    Args:
        mode: One of :data:`MODES`.
        source: The maintained skill tree.
        dest: The user's skills directory; :func:`destination` by default.

    Returns:
        What was written, adopted, pending, or refused.

    Raises:
        ValueError: If ``mode`` is unknown or the manifest is malformed.

    Examples:
        >>> import tempfile
        >>> with tempfile.TemporaryDirectory() as temporary:
        ...     root = Path(temporary)
        ...     source = root / "source"
        ...     (source / "demo").mkdir(parents=True)
        ...     _ = (source / "demo" / "SKILL.md").write_text("demo\n")
        ...     run("install", source, root / "dest").ok
        True
    """
    if mode not in MODES:
        raise ValueError(
            f"Unknown install mode {mode!r}; choose one of {', '.join(MODES)}."
        )
    target_root = dest or destination()
    manifest = _load(target_root)
    report = Report()
    for rel in _sources(source):
        state = _plan(rel, source, target_root, manifest)
        target = target_root / rel
        if state == "same":
            manifest[rel] = _sha(target)
            continue
        if state == "foreign":
            report.foreign.append(rel)
            continue
        if state == "drift" and mode == "adopt":
            shutil.copyfile(target, source / rel)
            manifest[rel] = _sha(target)
            report.adopted.append(rel)
            continue
        if state == "drift" and mode != "force":
            report.drifted.append(rel)
            continue
        if mode in ("check", "adopt"):
            (report.missing if state == "new" else report.stale).append(rel)
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(source / rel, target)
        manifest[rel] = _sha(target)
        report.written.append(rel)
    if mode != "check":
        target_root.mkdir(parents=True, exist_ok=True)
        (target_root / MANIFEST).write_text(
            json.dumps(manifest, indent=2, sort_keys=True) + "\n",
            encoding="utf-8",
        )
    return report

skills_root()

Return the installed skills tree or source-checkout fallback.

Returns:

Type Description
Path

A directory containing the maintained skill folders.

Raises:

Type Description
RuntimeError

If neither packaged nor source skills exist.

Examples:

>>> (skills_root() / "release" / "SKILL.md").is_file()
True
Source code in src/research_foundry/skills.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def skills_root() -> Path:
    """Return the installed skills tree or source-checkout fallback.

    Returns:
        A directory containing the maintained skill folders.

    Raises:
        RuntimeError: If neither packaged nor source skills exist.

    Examples:
        >>> (skills_root() / "release" / "SKILL.md").is_file()
        True
    """
    packaged = Path(__file__).resolve().parent / "skills"
    if packaged.is_dir():
        return packaged
    checkout = Path(__file__).resolve().parents[2] / "skills"
    if checkout.is_dir():
        return checkout
    raise RuntimeError(
        "The packaged skills are missing; reinstall research-foundry."
    )

Constants

Constants shared by every research-foundry interface.

Examples:

>>> PUBLIC_REPOSITORY.startswith("https://github.com/")
True