tapestry-cli 0.1.2__tar.gz → 0.1.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tapestry-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Tapestry command-line interface — onboard projects, inspect state, open the Observatory.
5
5
  Author: Liz Osborn
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tapestry-cli"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Tapestry command-line interface — onboard projects, inspect state, open the Observatory."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -20,6 +20,7 @@ import sys
20
20
  from tapestry_cli import (
21
21
  __version__,
22
22
  init as init_cmd,
23
+ make_plugin as make_plugin_cmd,
23
24
  observatory as observatory_cmd,
24
25
  onboard as onboard_cmd,
25
26
  )
@@ -41,6 +42,11 @@ SUBCOMMANDS: dict[str, dict] = {
41
42
  "add_arguments": init_cmd.add_arguments,
42
43
  "run": init_cmd.run,
43
44
  },
45
+ "make-plugin": {
46
+ "help": "Scaffold a personalized Claude Code plugin to publish to your own marketplace.",
47
+ "add_arguments": make_plugin_cmd.add_arguments,
48
+ "run": make_plugin_cmd.run,
49
+ },
44
50
  }
45
51
 
46
52
 
@@ -0,0 +1,286 @@
1
+ """`tapestry make-plugin <name>` — scaffold a personalized Claude Code plugin.
2
+
3
+ Emits a self-contained plugin + single-plugin marketplace that the operator
4
+ publishes to THEIR OWN GitHub repo and points at THEIR OWN backend. Nothing
5
+ personal to anyone else is baked in: the memory endpoint is env-driven with a
6
+ placeholder fallback, and the author is the operator.
7
+
8
+ The universal `tapestry-discipline` plugin is the always-on baseline (PROBE
9
+ reminders, citation, audit — no backend). This scaffold is the project-specific
10
+ layer: a behavior-only PreToolUse guard to customize for the project's shape,
11
+ plus an optional env-gated memory wiring. See the `plugin-authoring` skill in
12
+ tapestry-patterns for the full author-and-publish workflow.
13
+
14
+ Stdlib-only.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import re
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ # The node launcher is generic (finds Python, runs scripts/<name>.py, passes
25
+ # stdin, propagates exit code). Reused verbatim from the universal plugin.
26
+ _RUN_PYTHON_MJS = r'''#!/usr/bin/env node
27
+ /**
28
+ * run-python.mjs — Node launcher for the Python hook scripts.
29
+ * Claude Code guarantees Node on PATH but not Python; this finds Python and
30
+ * invokes scripts/<name>.py, piping stdin through and propagating exit codes.
31
+ * node "${CLAUDE_PLUGIN_ROOT}/hooks/run-python.mjs" <script_basename>
32
+ */
33
+ import { spawnSync } from "node:child_process";
34
+ import { existsSync } from "node:fs";
35
+ import { join, dirname } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ const __dirname = dirname(fileURLToPath(import.meta.url));
39
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || dirname(__dirname);
40
+ const scriptName = process.argv[2];
41
+ if (!scriptName) { console.error("run-python.mjs: missing script name"); process.exit(0); }
42
+ const pythonScript = join(pluginRoot, "scripts", `${scriptName}.py`);
43
+ if (!existsSync(pythonScript)) { console.error(`run-python.mjs: not found: ${pythonScript}`); process.exit(0); }
44
+
45
+ const isWindows = process.platform === "win32";
46
+ const candidates = isWindows
47
+ ? [["py", ["-3", pythonScript]], ["python", [pythonScript]], ["python3", [pythonScript]]]
48
+ : [["python3", [pythonScript]], ["python", [pythonScript]]];
49
+
50
+ let stdinBuf = Buffer.alloc(0);
51
+ try { const fs = await import("node:fs"); stdinBuf = fs.readFileSync(0); } catch {}
52
+
53
+ let lastExitCode = 0, invoked = false;
54
+ for (const [cmd, args] of candidates) {
55
+ try {
56
+ const result = spawnSync(cmd, args, { input: stdinBuf, stdio: ["pipe", "inherit", "inherit"] });
57
+ if (result.error && result.error.code === "ENOENT") continue;
58
+ invoked = true; lastExitCode = result.status ?? 0; break;
59
+ } catch { continue; }
60
+ }
61
+ if (!invoked) { console.error("run-python.mjs: no Python on PATH; hook skipped, not blocking."); process.exit(0); }
62
+ process.exit(lastExitCode);
63
+ '''
64
+
65
+ _HOOKS_JSON = {
66
+ "hooks": {
67
+ "PreToolUse": [
68
+ {
69
+ "matcher": "Edit|Write|MultiEdit",
70
+ "hooks": [
71
+ {
72
+ "type": "command",
73
+ "command": 'node "${CLAUDE_PLUGIN_ROOT}/hooks/run-python.mjs" pre_tool_use',
74
+ "timeout": 10,
75
+ }
76
+ ],
77
+ }
78
+ ]
79
+ }
80
+ }
81
+
82
+
83
+ def _pre_tool_use_py(name: str) -> str:
84
+ return f'''"""{name} — PreToolUse guard (behavior-only, no backend calls).
85
+
86
+ Customize the checks below for THIS project's shape. Emits a soft reminder via
87
+ additionalContext; never blocks by default (exit 0). Exit 2 to block a tool
88
+ call (Claude Code hook protocol). Keep it behavior-only so it works for any
89
+ operator with no backend configured.
90
+ """
91
+ import json
92
+ import sys
93
+
94
+
95
+ def main() -> int:
96
+ try:
97
+ data = json.load(sys.stdin)
98
+ except Exception: # never raise from a hook
99
+ return 0
100
+
101
+ # TODO: add project-specific guards here. Example checks you might add:
102
+ # - warn when editing a load-bearing file
103
+ # - require a test alongside a source change
104
+ # - block edits to a generated/vendored directory (return exit 2)
105
+ _tool = data.get("tool_name", "")
106
+
107
+ reminder = (
108
+ "[{name}] PROBE before editing: confirm the change matches this "
109
+ "project's conventions; cite file:line for any pattern you copy."
110
+ ).format(name="{name}")
111
+
112
+ print(json.dumps({{
113
+ "hookSpecificOutput": {{
114
+ "hookEventName": "PreToolUse",
115
+ "additionalContext": reminder,
116
+ }}
117
+ }}))
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main())
123
+ '''.replace("{name}", name)
124
+
125
+
126
+ def _plugin_json(name: str, author: str) -> dict:
127
+ return {
128
+ "name": name,
129
+ "description": (
130
+ f"Personalized Tapestry discipline plugin for {name}. Project-specific "
131
+ "guards plus this operator's own backend. The universal "
132
+ "tapestry-discipline plugin is the always-on baseline; this is the "
133
+ "project layer. Generated by `tapestry make-plugin`."
134
+ ),
135
+ "version": "0.1.0",
136
+ "author": {"name": author},
137
+ "license": "Apache-2.0",
138
+ "keywords": ["tapestry", "discipline", "personalized"],
139
+ # Env-driven, placeholder fallback — point at YOUR own memory backend.
140
+ "mcpServers": {
141
+ "loom-memory": {
142
+ "type": "http",
143
+ "url": "${LOOM_MEMORY_MCP_URL:-https://your-memory-host.example.com/mcp/memory/}",
144
+ }
145
+ },
146
+ }
147
+
148
+
149
+ def _marketplace_json(name: str, author: str) -> dict:
150
+ return {
151
+ "$schema": "https://json.schemastore.org/claude-code-marketplace.json",
152
+ "name": name,
153
+ "description": f"{author}'s plugin marketplace.",
154
+ "owner": {"name": author},
155
+ "plugins": [
156
+ {
157
+ "name": name,
158
+ "source": "./",
159
+ "description": "Personalized Tapestry discipline plugin.",
160
+ }
161
+ ],
162
+ }
163
+
164
+
165
+ def _readme(name: str, login: str) -> str:
166
+ repo = f"{login}/{name}" if login else f"<your-github>/{name}"
167
+ return f'''# {name}
168
+
169
+ A personalized Claude Code discipline plugin, generated by `tapestry make-plugin`.
170
+
171
+ The universal `tapestry-discipline` plugin is the always-on baseline. This plugin
172
+ is the project-specific layer: customize `scripts/pre_tool_use.py` with guards for
173
+ this project's shape, and point `mcpServers.loom-memory` at YOUR own backend via
174
+ the `LOOM_MEMORY_MCP_URL` env var (it defaults to a placeholder).
175
+
176
+ ## Test locally
177
+ Enable it in `.claude/settings.json` (or `/plugin marketplace add` from this dir),
178
+ then trigger an edit and confirm the PreToolUse reminder fires. With no backend
179
+ configured it stays behavior-only — that's expected.
180
+
181
+ ## Publish to your own marketplace
182
+ ```bash
183
+ gh repo create {repo} --public --source . --push
184
+ ```
185
+ Then in Claude Code:
186
+ ```text
187
+ /plugin marketplace add {repo}
188
+ /plugin install {name}@{name}
189
+ ```
190
+
191
+ Set your backend (optional, for memory):
192
+ ```bash
193
+ export LOOM_MEMORY_MCP_URL=https://<your-memory-host>/mcp/memory/
194
+ ```
195
+ '''
196
+
197
+
198
+ def _slug(s: str) -> str:
199
+ s = re.sub(r"[^a-zA-Z0-9._-]+", "-", s.strip()).strip("-").lower()
200
+ return s or "my-plugin"
201
+
202
+
203
+ def _git_author() -> str:
204
+ try:
205
+ out = subprocess.run(
206
+ ["git", "config", "user.name"],
207
+ capture_output=True, text=True, timeout=3,
208
+ )
209
+ if out.returncode == 0 and out.stdout.strip():
210
+ return out.stdout.strip()
211
+ except (subprocess.SubprocessError, OSError):
212
+ pass
213
+ return "Your Name"
214
+
215
+
216
+ def _gh_login() -> str:
217
+ try:
218
+ out = subprocess.run(
219
+ ["gh", "api", "user", "-q", ".login"],
220
+ capture_output=True, text=True, timeout=5,
221
+ )
222
+ if out.returncode == 0 and out.stdout.strip():
223
+ return out.stdout.strip()
224
+ except (subprocess.SubprocessError, OSError):
225
+ pass
226
+ return ""
227
+
228
+
229
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
230
+ parser.add_argument("name", help="Plugin name (kebab-case, e.g. my-project-guard).")
231
+ parser.add_argument(
232
+ "--dir", default=".",
233
+ help="Parent directory to create the plugin in (default: current dir).",
234
+ )
235
+ parser.add_argument(
236
+ "--author", default=None,
237
+ help="Author name (default: git config user.name).",
238
+ )
239
+ parser.add_argument(
240
+ "--dry-run", dest="dry_run", action="store_true",
241
+ help="Print the files that would be created without writing them.",
242
+ )
243
+
244
+
245
+ def run(args: argparse.Namespace) -> int:
246
+ name = _slug(args.name)
247
+ author = args.author or _git_author()
248
+ login = _gh_login()
249
+ root = Path(args.dir).resolve() / name
250
+
251
+ files = {
252
+ ".claude-plugin/plugin.json": json.dumps(_plugin_json(name, author), indent=2) + "\n",
253
+ ".claude-plugin/marketplace.json": json.dumps(_marketplace_json(name, author), indent=2) + "\n",
254
+ "hooks/hooks.json": json.dumps(_HOOKS_JSON, indent=2) + "\n",
255
+ "hooks/run-python.mjs": _RUN_PYTHON_MJS,
256
+ "scripts/pre_tool_use.py": _pre_tool_use_py(name),
257
+ "README.md": _readme(name, login),
258
+ }
259
+
260
+ if args.dry_run:
261
+ print(f"Would create plugin '{name}' (author: {author}) at {root}:")
262
+ for rel in files:
263
+ print(f" {root / rel}")
264
+ return 0
265
+
266
+ if root.exists():
267
+ print(f"Refusing to overwrite existing directory: {root}")
268
+ return 1
269
+
270
+ for rel, content in files.items():
271
+ dest = root / rel
272
+ dest.parent.mkdir(parents=True, exist_ok=True)
273
+ dest.write_text(content, encoding="utf-8")
274
+
275
+ print(f"Created plugin '{name}' (author: {author}) at {root}")
276
+ print(" .claude-plugin/plugin.json — env-driven memory URL (set LOOM_MEMORY_MCP_URL)")
277
+ print(" .claude-plugin/marketplace.json")
278
+ print(" hooks/hooks.json + hooks/run-python.mjs")
279
+ print(" scripts/pre_tool_use.py — customize guards for this project")
280
+ print(" README.md — how to test + publish")
281
+ print()
282
+ print("Next: customize scripts/pre_tool_use.py, then publish to your own marketplace:")
283
+ repo = f"{login}/{name}" if login else "<your-github>/" + name
284
+ print(f" gh repo create {repo} --public --source {root} --push")
285
+ print(f" /plugin marketplace add {repo}")
286
+ return 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tapestry-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Tapestry command-line interface — onboard projects, inspect state, open the Observatory.
5
5
  Author: Liz Osborn
6
6
  License: MIT
@@ -4,6 +4,7 @@ tapestry_cli/__init__.py
4
4
  tapestry_cli/__main__.py
5
5
  tapestry_cli/cli.py
6
6
  tapestry_cli/init.py
7
+ tapestry_cli/make_plugin.py
7
8
  tapestry_cli/observatory.py
8
9
  tapestry_cli/onboard.py
9
10
  tapestry_cli.egg-info/PKG-INFO
File without changes
File without changes