tapestry-cli 0.1.2__tar.gz → 0.1.4__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.
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/PKG-INFO +1 -1
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/pyproject.toml +1 -1
- tapestry_cli-0.1.4/tapestry_cli/__init__.py +17 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli/cli.py +6 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli/init.py +5 -0
- tapestry_cli-0.1.4/tapestry_cli/make_plugin.py +286 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli.egg-info/PKG-INFO +1 -1
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli.egg-info/SOURCES.txt +1 -0
- tapestry_cli-0.1.2/tapestry_cli/__init__.py +0 -7
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/README.md +0 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/setup.cfg +0 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli/__main__.py +0 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli/observatory.py +0 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli/onboard.py +0 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli.egg-info/dependency_links.txt +0 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli.egg-info/entry_points.txt +0 -0
- {tapestry_cli-0.1.2 → tapestry_cli-0.1.4}/tapestry_cli.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tapestry-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
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"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""tapestry-cli — command-line interface for Tapestry.
|
|
2
|
+
|
|
3
|
+
Stdlib-only. Cross-platform. Subcommands:
|
|
4
|
+
tapestry init — onboard the current directory as a Tapestry consuming project
|
|
5
|
+
tapestry version — print version + diagnostic info
|
|
6
|
+
|
|
7
|
+
(`loom` remains a deprecated command alias; see [project.scripts] in pyproject.)
|
|
8
|
+
"""
|
|
9
|
+
from importlib.metadata import PackageNotFoundError
|
|
10
|
+
from importlib.metadata import version as _pkg_version
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
# Authoritative source: the installed package's own metadata, so this can
|
|
14
|
+
# never drift from pyproject again (the 0.1.0-vs-0.1.3 bug it replaces).
|
|
15
|
+
__version__ = _pkg_version("tapestry-cli")
|
|
16
|
+
except PackageNotFoundError: # running from a source checkout, not pip-installed
|
|
17
|
+
__version__ = "0.1.4"
|
|
@@ -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
|
|
|
@@ -237,9 +237,14 @@ def _write_mcp_config(project_dir: Path) -> None:
|
|
|
237
237
|
provides memory access.
|
|
238
238
|
"""
|
|
239
239
|
mcp_path = project_dir / ".mcp.json"
|
|
240
|
+
# Mode-agnostic auth header: references the shared-secret env var by name.
|
|
241
|
+
# When TAPESTRY_MEMORY_API_KEY is unset the client sends "Bearer " (empty),
|
|
242
|
+
# which the memory server treats as anonymous — so this config is valid both
|
|
243
|
+
# before the credential exists and after, and needs no change at flip time.
|
|
240
244
|
loom_memory_entry = {
|
|
241
245
|
"type": "http",
|
|
242
246
|
"url": LOOM_MEMORY_MCP_URL,
|
|
247
|
+
"headers": {"Authorization": "Bearer ${TAPESTRY_MEMORY_API_KEY}"},
|
|
243
248
|
}
|
|
244
249
|
|
|
245
250
|
if mcp_path.exists():
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|