a3ip 1.0.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.
a3ip/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ A3IP — AI Infrastructure Installation Package
3
+ *Pronounced "ay-trip"*
4
+
5
+ CLI package for creating, validating, and bundling A3IP packages.
6
+ """
7
+
8
+ __version__ = "1.0.0"
9
+ __spec_version__ = "1.5"
a3ip/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m a3ip` invocation."""
2
+ from a3ip.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
a3ip/bundle.py ADDED
@@ -0,0 +1,185 @@
1
+ """
2
+ A3IP bundle — generates a .a3ip.bundle file from a package directory.
3
+ """
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ _BINARY_EXTENSIONS = {
12
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
13
+ ".pdf", ".zip", ".tar", ".gz", ".whl", ".exe", ".dll",
14
+ ".ttf", ".woff", ".woff2", ".eot",
15
+ }
16
+ _TEXT_EXTENSIONS = {
17
+ ".md", ".yaml", ".yml", ".json", ".py", ".ps1", ".sh",
18
+ ".txt", ".html", ".js", ".css", ".ts", ".tsx", ".jsx",
19
+ ".xml", ".toml", ".ini", ".cfg", ".env", ".bat", ".cmd",
20
+ }
21
+
22
+
23
+ def _is_binary(path: Path) -> bool:
24
+ if path.suffix.lower() in _BINARY_EXTENSIONS:
25
+ return True
26
+ if path.suffix.lower() in _TEXT_EXTENSIONS:
27
+ return False
28
+ try:
29
+ path.read_text(encoding="utf-8")
30
+ return False
31
+ except Exception:
32
+ return True
33
+
34
+
35
+ def _get_package_name(pkg_dir: Path) -> str:
36
+ name = pkg_dir.name
37
+ if name.endswith(".a3ip"):
38
+ name = name[:-5]
39
+ return name
40
+
41
+
42
+ def _get_package_version(pkg_dir: Path) -> str:
43
+ manifest = pkg_dir / "manifest.yaml"
44
+ if not manifest.exists():
45
+ return "1.0.0"
46
+ for line in manifest.read_text(encoding="utf-8").splitlines():
47
+ line = line.strip()
48
+ if line.startswith("version:"):
49
+ return line.split(":", 1)[1].strip().strip('"').strip("'")
50
+ return "1.0.0"
51
+
52
+
53
+ def _collect_files(pkg_dir: Path) -> list[Path]:
54
+ files = []
55
+ for fpath in sorted(pkg_dir.rglob("*")):
56
+ if not fpath.is_file():
57
+ continue
58
+ parts = fpath.relative_to(pkg_dir).parts
59
+ if any(p.startswith(".") for p in parts):
60
+ continue
61
+ if "__pycache__" in parts:
62
+ continue
63
+ files.append(fpath)
64
+ return files
65
+
66
+
67
+ def _embed_file(lines: list, rel_path: str, fpath: Path) -> None:
68
+ lines.append("=== FILE: " + rel_path + " ===")
69
+ if _is_binary(fpath):
70
+ lines.append("# encoding: base64")
71
+ lines.append(base64.b64encode(fpath.read_bytes()).decode("ascii"))
72
+ else:
73
+ lines.append(fpath.read_text(encoding="utf-8", errors="replace"))
74
+ lines.append("=== END FILE ===")
75
+ lines.append("")
76
+
77
+
78
+ def _hash_file(fpath: Path) -> str:
79
+ h = hashlib.sha256()
80
+ h.update(fpath.read_bytes())
81
+ return "sha256:" + h.hexdigest()
82
+
83
+
84
+ def _write_source_manifest(pkg_dir: Path, pkg_files: list, name: str, version: str, now: str) -> None:
85
+ files_hashes = {
86
+ str(f.relative_to(pkg_dir)).replace("\\", "/"): _hash_file(f)
87
+ for f in pkg_files
88
+ }
89
+ source = {"package": name, "version": version, "bundled_at": now, "files": files_hashes}
90
+ (pkg_dir / ".a3ip-source.json").write_text(json.dumps(source, indent=2), encoding="utf-8")
91
+
92
+
93
+ def build_bundle(pkg_dir: Path, output_path: Path, spec_path: Path | None = None) -> dict:
94
+ """Build a .a3ip.bundle file. Returns stats dict."""
95
+ name = _get_package_name(pkg_dir)
96
+ version = _get_package_version(pkg_dir)
97
+ pkg_files = _collect_files(pkg_dir)
98
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
99
+ spec_embedded = spec_path is not None and spec_path.exists()
100
+ total_files = len(pkg_files) + (1 if spec_embedded else 0)
101
+
102
+ lines = [
103
+ "---",
104
+ 'a3ip-bundle: "1.1"',
105
+ "package: " + name,
106
+ 'version: "' + version + '"',
107
+ "generated: " + now,
108
+ "files: " + str(total_files),
109
+ "spec_embedded: " + ("true" if spec_embedded else "false"),
110
+ "---",
111
+ "",
112
+ "# ================================================================",
113
+ "# A3IP BUNDLE — AI INSTALLATION INSTRUCTIONS",
114
+ "# ================================================================",
115
+ "#",
116
+ "# This file is an A3IP (AI Infrastructure Installation Package) bundle.",
117
+ "# It contains a complete AI workflow you can install into your workspace.",
118
+ "#",
119
+ ]
120
+
121
+ if spec_embedded:
122
+ lines += [
123
+ "# IF YOU ARE NOT FAMILIAR WITH A3IP:",
124
+ "# 1. Read the embedded spec first (A3IP-SPEC.md, the first FILE block below).",
125
+ "# 2. Read manifest.yaml to understand what this package contains.",
126
+ "# 3. Follow INSTALL.md to install it.",
127
+ "#",
128
+ "# IF YOU KNOW A3IP: Read manifest.yaml, then follow INSTALL.md.",
129
+ ]
130
+ else:
131
+ lines += [
132
+ "# Read manifest.yaml to understand what this package contains.",
133
+ "# Follow INSTALL.md to install it.",
134
+ "#",
135
+ "# Note: This bundle does not include the A3IP spec.",
136
+ "# If you are unfamiliar with A3IP, ask for the spec before proceeding.",
137
+ ]
138
+
139
+ lines += ["#", "# ================================================================", ""]
140
+
141
+ if spec_embedded:
142
+ lines.append("=== FILE: A3IP-SPEC.md ===")
143
+ lines.append(spec_path.read_text(encoding="utf-8", errors="replace"))
144
+ lines.append("=== END FILE ===")
145
+ lines.append("")
146
+
147
+ for fpath in pkg_files:
148
+ rel = str(fpath.relative_to(pkg_dir)).replace("\\", "/")
149
+ _embed_file(lines, rel, fpath)
150
+
151
+ output_path.write_text("\n".join(lines), encoding="utf-8")
152
+ _write_source_manifest(pkg_dir, pkg_files, name, version, now)
153
+
154
+ return {"file_count": len(pkg_files), "spec_embedded": spec_embedded, "output": output_path}
155
+
156
+
157
+ def bundle(pkg_dir_str: str, output_path_str: str | None = None, spec_path_str: str | None = None) -> dict:
158
+ """Public API: bundle a package. Returns stats dict or raises on error."""
159
+ pkg_dir = Path(pkg_dir_str)
160
+ if not pkg_dir.is_dir():
161
+ raise ValueError("Not a directory: " + pkg_dir_str)
162
+
163
+ spec_path = Path(spec_path_str) if spec_path_str else None
164
+ if spec_path and not spec_path.exists():
165
+ print("Warning: spec file '" + spec_path_str + "' not found — bundling without it.")
166
+ spec_path = None
167
+
168
+ name = _get_package_name(pkg_dir)
169
+ output_path = Path(output_path_str) if output_path_str else pkg_dir.parent / (name + ".a3ip.bundle")
170
+
171
+ mode = "with spec" if spec_path else "without spec"
172
+ print("Building bundle (" + mode + "): " + str(pkg_dir) + " → " + str(output_path))
173
+
174
+ result = build_bundle(pkg_dir, output_path, spec_path)
175
+ size_kb = output_path.stat().st_size / 1024
176
+
177
+ print("Bundle created: " + str(output_path))
178
+ print(" Package files: " + str(result["file_count"]))
179
+ if result["spec_embedded"]:
180
+ print(" Spec embedded: yes (" + spec_path.name + ")")
181
+ else:
182
+ print(" Spec embedded: no (use --spec to include for external distribution)")
183
+ print(" Total size: " + f"{size_kb:.1f} KB")
184
+
185
+ return result
a3ip/cli.py ADDED
@@ -0,0 +1,231 @@
1
+ """
2
+ a3ip -- CLI for the A3IP package format.
3
+ *Pronounced "ay-trip"*
4
+
5
+ Commands:
6
+ a3ip new <name> Scaffold a new package that passes validate immediately
7
+ a3ip validate <package_dir> Run 9 normative checks; exit 0 if clean
8
+ a3ip bundle <package_dir> Build a .a3ip.bundle file ready for distribution
9
+ a3ip export --format <fmt> Export to another format (cowork-plugin, apm) [planned v0.2]
10
+
11
+ See https://a3ip.dev for the full specification.
12
+ """
13
+
14
+ import argparse
15
+ import sys
16
+
17
+ from a3ip import __version__, __spec_version__
18
+
19
+
20
+ def _cmd_new(args: argparse.Namespace) -> int:
21
+ from a3ip.new import scaffold
22
+ try:
23
+ pkg_dir = scaffold(args.name, args.output_dir)
24
+ except FileExistsError as e:
25
+ print("Error: " + str(e), file=sys.stderr)
26
+ return 1
27
+ except ValueError as e:
28
+ print("Error: " + str(e), file=sys.stderr)
29
+ return 1
30
+
31
+ print("Created package: " + str(pkg_dir))
32
+ print()
33
+ print(" " + str(pkg_dir / "manifest.yaml"))
34
+ print(" " + str(pkg_dir / "CONFIGURE.md"))
35
+ print(" " + str(pkg_dir / "INSTALL.md"))
36
+ print(" " + str(pkg_dir / "README.md"))
37
+ print(" " + str(pkg_dir / "components" / "skills" / (args.name + ".md")))
38
+ print()
39
+ print("Next steps:")
40
+ print(" 1. Edit manifest.yaml -- fill in description, permissions, components.")
41
+ print(" 2. Edit INSTALL.md -- describe the install steps.")
42
+ print(" 3. Run: a3ip validate " + args.name + "/")
43
+ print(" 4. Run: a3ip bundle " + args.name + "/")
44
+ return 0
45
+
46
+
47
+ def _cmd_validate(args: argparse.Namespace) -> int:
48
+ from a3ip.validate import validate
49
+ report = validate(args.package_dir)
50
+ if args.json:
51
+ import json
52
+ print(json.dumps(report, indent=2))
53
+ return 0 if report["ok"] else 1
54
+
55
+
56
+ def _cmd_bundle(args: argparse.Namespace) -> int:
57
+ from a3ip.bundle import bundle
58
+ try:
59
+ bundle(
60
+ pkg_dir_str=args.package_dir,
61
+ output_path_str=args.output,
62
+ spec_path_str=args.spec,
63
+ )
64
+ except (ValueError, OSError) as e:
65
+ print("Error: " + str(e), file=sys.stderr)
66
+ return 1
67
+ return 0
68
+
69
+
70
+ _EXPORT_FORMATS = {
71
+ "cowork-plugin": (
72
+ "Target: Anthropic Cowork Plugin (.plugin bundle)\n"
73
+ "\n"
74
+ "A3IP packages map cleanly to Cowork plugins: skills become skill files,\n"
75
+ "permissions become connector declarations, configuration becomes plugin settings.\n"
76
+ "A3IP acts as the portability and security layer around the plugin ecosystem --\n"
77
+ "packages authored once can target Cowork, Codex, Cursor, and others.\n"
78
+ "\n"
79
+ "Planned for a3ip v0.2. Track: https://github.com/a3ip-standard/cli/issues"
80
+ ),
81
+ "apm": (
82
+ "Target: Microsoft APM (Agent Package Manifest)\n"
83
+ "\n"
84
+ "A3IP is a superset of APM -- every A3IP package can emit a valid APM manifest.\n"
85
+ "The converter preserves components, dependencies, and context declarations.\n"
86
+ "\n"
87
+ "Planned for a3ip v0.2. Track: https://github.com/a3ip-standard/cli/issues"
88
+ ),
89
+ }
90
+
91
+
92
+ def _cmd_export(args: argparse.Namespace) -> int:
93
+ fmt = args.format
94
+ details = _EXPORT_FORMATS[fmt]
95
+ print("a3ip export --format " + fmt)
96
+ print()
97
+ print(" Status: planned for a3ip v0.2")
98
+ print()
99
+ for line in details.splitlines():
100
+ print((" " + line) if line else "")
101
+ print()
102
+ print(" Until this converter ships, install the package natively:")
103
+ print(" Drop the .a3ip.bundle into any A3IP-compatible AI and ask it to install.")
104
+ return 0
105
+
106
+
107
+ def _build_parser() -> argparse.ArgumentParser:
108
+ parser = argparse.ArgumentParser(
109
+ prog="a3ip",
110
+ description=(
111
+ "A3IP -- AI Infrastructure Installation Package (pronounced 'ay-trip')\n"
112
+ "Package, validate, and bundle portable AI agent workflows.\n\n"
113
+ "Spec: https://a3ip.dev\n"
114
+ "Docs: https://github.com/a3ip-standard/spec"
115
+ ),
116
+ formatter_class=argparse.RawDescriptionHelpFormatter,
117
+ )
118
+ parser.add_argument(
119
+ "--version", action="version",
120
+ version="a3ip " + __version__ + " (spec " + __spec_version__ + ")"
121
+ )
122
+
123
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
124
+ sub.required = True
125
+
126
+ # -- new ------------------------------------------------------------------
127
+ p_new = sub.add_parser(
128
+ "new",
129
+ help="Scaffold a new package that passes validate on first run",
130
+ description=(
131
+ "Create a new A3IP package directory with all required files.\n"
132
+ "The scaffolded package passes `a3ip validate` immediately."
133
+ ),
134
+ )
135
+ p_new.add_argument("name", help="Package name (lowercase, hyphens, e.g. my-workflow)")
136
+ p_new.add_argument(
137
+ "--output-dir", metavar="DIR", dest="output_dir", default=None,
138
+ help="Parent directory to create the package in (default: current directory)",
139
+ )
140
+ p_new.set_defaults(func=_cmd_new)
141
+
142
+ # -- validate -------------------------------------------------------------
143
+ p_val = sub.add_parser(
144
+ "validate",
145
+ help="Run 9 normative checks on a package directory",
146
+ description=(
147
+ "Validate an A3IP package against the spec.\n\n"
148
+ "Runs 9 checks:\n"
149
+ " 1. Config coverage -- {{config.*}} refs match manifest declarations\n"
150
+ " 2. Script existence -- declared script files exist on disk\n"
151
+ " 3. Script config reads -- scripts only read declared config keys\n"
152
+ " 4. Cross-platform -- Windows scripts have a cross-platform fallback\n"
153
+ " 5. Auth flows -- auth scripts are referenced in INSTALL.md\n"
154
+ " 6. Changelog present -- CHANGELOG.md required for version > 1.0.0\n"
155
+ " 7. Refresh scripts -- refresh_script keys declared in manifest\n"
156
+ " 8. Trust->permissions -- elevated trust requires a permissions block\n"
157
+ " 9. Trust->plan section -- write/shell trust requires ## Plan in INSTALL.md\n\n"
158
+ "Exit code: 0 if all checks pass, 1 if any errors found.\n"
159
+ "Warnings do not affect the exit code."
160
+ ),
161
+ formatter_class=argparse.RawDescriptionHelpFormatter,
162
+ )
163
+ p_val.add_argument("package_dir", help="Path to the package directory to validate")
164
+ p_val.add_argument(
165
+ "--json", action="store_true",
166
+ help="Print a JSON report in addition to the human-readable output",
167
+ )
168
+ p_val.set_defaults(func=_cmd_validate)
169
+
170
+ # -- bundle ---------------------------------------------------------------
171
+ p_bun = sub.add_parser(
172
+ "bundle",
173
+ help="Build a .a3ip.bundle file ready for distribution",
174
+ description=(
175
+ "Bundle a package directory into a single .a3ip.bundle file.\n\n"
176
+ "The bundle contains every file in the package in a plain-text format\n"
177
+ "that any A3IP-compatible AI can read and install.\n\n"
178
+ "Binary files are base64-encoded. Hidden files and __pycache__ are excluded."
179
+ ),
180
+ formatter_class=argparse.RawDescriptionHelpFormatter,
181
+ )
182
+ p_bun.add_argument("package_dir", help="Path to the package directory to bundle")
183
+ p_bun.add_argument(
184
+ "--output", "-o", metavar="PATH",
185
+ help="Output path for the bundle (default: <package_dir>/../<name>.a3ip.bundle)",
186
+ )
187
+ p_bun.add_argument(
188
+ "--spec", metavar="PATH",
189
+ help=(
190
+ "Embed the A3IP spec file into the bundle. "
191
+ "Use this when distributing to recipients who may not know A3IP."
192
+ ),
193
+ )
194
+ p_bun.set_defaults(func=_cmd_bundle)
195
+
196
+ # -- export ---------------------------------------------------------------
197
+ p_exp = sub.add_parser(
198
+ "export",
199
+ help="Export to another format: cowork-plugin, apm [planned v0.2]",
200
+ description=(
201
+ "Convert an A3IP package to another agent package format.\n\n"
202
+ "A3IP is designed as an interoperability layer -- packages can be exported\n"
203
+ "to platform-native formats without losing permissions or component structure.\n\n"
204
+ "Available formats:\n"
205
+ " cowork-plugin Anthropic Cowork Plugin bundle\n"
206
+ " apm Microsoft Agent Package Manifest\n\n"
207
+ "These commands are documented now so tooling and CI scripts can reference them.\n"
208
+ "They will print a roadmap notice until the converters ship in v0.2."
209
+ ),
210
+ formatter_class=argparse.RawDescriptionHelpFormatter,
211
+ )
212
+ p_exp.add_argument("package_dir", help="Path to the package directory to export")
213
+ p_exp.add_argument(
214
+ "--format", "-f", required=True,
215
+ choices=list(_EXPORT_FORMATS.keys()),
216
+ metavar="FORMAT",
217
+ help="Target format: " + ", ".join(_EXPORT_FORMATS.keys()),
218
+ )
219
+ p_exp.set_defaults(func=_cmd_export)
220
+
221
+ return parser
222
+
223
+
224
+ def main() -> None:
225
+ parser = _build_parser()
226
+ args = parser.parse_args()
227
+ sys.exit(args.func(args))
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
a3ip/new.py ADDED
@@ -0,0 +1,199 @@
1
+ """
2
+ A3IP new — scaffold a minimal valid A3IP package.
3
+
4
+ Creates a directory at <name>/ containing all required files.
5
+ The scaffolded package passes `a3ip validate` on the first run.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ _MANIFEST_TEMPLATE = """\
12
+ $schema: https://a3ip.dev/schema/v1.5/manifest.json
13
+
14
+ name: {name}
15
+ version: "1.0.0"
16
+ description: "Describe what this workflow does."
17
+ min_a3ip_spec: "1.5"
18
+ trust_level: read-only
19
+
20
+ # Declare every external resource this package uses.
21
+ # Remove sections that don't apply.
22
+ #
23
+ # permissions:
24
+ # filesystem:
25
+ # - path: "./output/"
26
+ # access: write
27
+ # reason: "Stores generated files."
28
+ # network:
29
+ # - domain: "api.example.com"
30
+ # reason: "Fetches data from the API."
31
+ # mcp:
32
+ # - name: example-mcp
33
+ # reason: "Reads and writes to the connected service."
34
+
35
+ components:
36
+ skills:
37
+ - path: components/skills/{name}.md
38
+ # artifacts:
39
+ # - path: components/artifacts/output.md
40
+ # prompts:
41
+ # - path: components/prompts/main.md
42
+ # scripts:
43
+ # - key: run
44
+ # implementations:
45
+ # - file: scripts/run.py
46
+ # platform: any
47
+ # trust_level: read-only
48
+
49
+ # configuration:
50
+ # - key: api_token
51
+ # label: "API Token"
52
+ # type: string
53
+ # sensitive: true
54
+ # storage: env
55
+ """
56
+
57
+ _CONFIGURE_TEMPLATE = """\
58
+ # CONFIGURE.md — {name}
59
+ # Ask the user these questions before starting the install.
60
+ # Answers fill in {{{{config.*}}}} placeholders in INSTALL.md.
61
+ # Remove this file entirely if the package needs no configuration.
62
+
63
+ ## Questions
64
+
65
+ **1. (Example) Which workspace should the workflow use?**
66
+ - Prompt: "Enter the path to your workspace directory:"
67
+ - Key: `workspace_dir`
68
+ - Type: directory_path
69
+ - Default: `./workspace`
70
+ """
71
+
72
+ _INSTALL_TEMPLATE = """\
73
+ # INSTALL.md — {name}
74
+
75
+ ## Overview
76
+
77
+ Brief description of what gets installed and what the user will be able to do.
78
+
79
+ ## Pre-flight checklist
80
+
81
+ - [ ] (List any requirements the user must have ready before starting)
82
+
83
+ ## Steps
84
+
85
+ ### Step 1: (First action)
86
+
87
+ Describe what to do here. Reference config values like {{{{config.workspace_dir}}}}.
88
+
89
+ - [ ] Task A
90
+ - [ ] Task B
91
+
92
+ ### Step 2: Verify
93
+
94
+ - [ ] Confirm the workflow is working as expected.
95
+
96
+ ## Done
97
+
98
+ The `{name}` workflow is now installed.
99
+ """
100
+
101
+ _SKILL_TEMPLATE = """\
102
+ # {name}
103
+
104
+ ## Overview
105
+
106
+ Describe what this skill does and when to invoke it.
107
+
108
+ ## Usage
109
+
110
+ Explain how to use the skill, what inputs it needs, and what output to expect.
111
+
112
+ ## Steps
113
+
114
+ 1. (First step)
115
+ 2. (Second step)
116
+ 3. Confirm the result.
117
+
118
+ ## Notes
119
+
120
+ Any caveats, limitations, or tips.
121
+ """
122
+
123
+ _README_TEMPLATE = """\
124
+ # {name}
125
+
126
+ > One-line description of what this workflow does.
127
+
128
+ ## What it does
129
+
130
+ Describe the workflow in 2–3 sentences. What problem does it solve?
131
+
132
+ ## Requirements
133
+
134
+ - (List any tools, accounts, or credentials needed)
135
+
136
+ ## Quick start
137
+
138
+ ```
139
+ a3ip bundle {name}/
140
+ # Drop the resulting .a3ip.bundle file into a conversation with an A3IP-compatible AI.
141
+ ```
142
+
143
+ ## Configuration
144
+
145
+ | Key | Description | Required |
146
+ |-----|-------------|----------|
147
+ | (example) `api_token` | API token for the connected service | Yes |
148
+
149
+ ## License
150
+
151
+ [Apache 2.0](LICENSE) — or replace with your chosen license.
152
+ """
153
+
154
+
155
+ def scaffold(name: str, output_dir: str | None = None) -> Path:
156
+ """
157
+ Create a new A3IP package directory at output_dir/name (or ./name).
158
+ Returns the path to the created directory.
159
+ Raises FileExistsError if the directory already exists.
160
+ """
161
+ base = Path(output_dir) if output_dir else Path(".")
162
+ pkg_dir = base / name
163
+
164
+ if pkg_dir.exists():
165
+ raise FileExistsError("Directory already exists: " + str(pkg_dir))
166
+
167
+ # Validate name (must match manifest.yaml name pattern)
168
+ import re
169
+ if not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name):
170
+ raise ValueError(
171
+ "Package name must be lowercase alphanumeric with hyphens "
172
+ "(e.g. 'my-workflow'), got: " + repr(name)
173
+ )
174
+
175
+ # Create directory tree
176
+ (pkg_dir / "components" / "skills").mkdir(parents=True)
177
+ (pkg_dir / "scripts").mkdir()
178
+
179
+ # Write files
180
+ (pkg_dir / "manifest.yaml").write_text(
181
+ _MANIFEST_TEMPLATE.format(name=name), encoding="utf-8"
182
+ )
183
+ (pkg_dir / "CONFIGURE.md").write_text(
184
+ _CONFIGURE_TEMPLATE.format(name=name), encoding="utf-8"
185
+ )
186
+ (pkg_dir / "INSTALL.md").write_text(
187
+ _INSTALL_TEMPLATE.format(name=name), encoding="utf-8"
188
+ )
189
+ (pkg_dir / "README.md").write_text(
190
+ _README_TEMPLATE.format(name=name), encoding="utf-8"
191
+ )
192
+ (pkg_dir / "components" / "skills" / (name + ".md")).write_text(
193
+ _SKILL_TEMPLATE.format(name=name), encoding="utf-8"
194
+ )
195
+
196
+ # Placeholder .gitkeep so scripts/ is committed empty
197
+ (pkg_dir / "scripts" / ".gitkeep").write_text("", encoding="utf-8")
198
+
199
+ return pkg_dir