tapestry-cli 0.1.0__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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: tapestry-cli
3
+ Version: 0.1.0
4
+ Summary: Tapestry command-line interface — onboard projects, inspect state, open the Observatory.
5
+ Author-email: Liz Osborn <lizocontactinfo@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Lizo-RoadTown/tapestry
8
+ Project-URL: Repository, https://github.com/Lizo-RoadTown/tapestry
9
+ Project-URL: Documentation, https://tapestry-khaki.vercel.app/
10
+ Keywords: tapestry,agents,coordination,observability,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # tapestry-cli
24
+
25
+ The command-line interface for [Tapestry](https://github.com/Lizo-RoadTown/tapestry) — project intelligence for AI-native teams.
26
+
27
+ Wire a project into the platform, then open the live console. Stdlib-only, no third-party dependencies, no model downloads — it works the moment `pip install` completes, on Windows, macOS, and Linux.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install tapestry-cli
33
+ ```
34
+
35
+ (or `pip install tapestry-cli`)
36
+
37
+ ## Commands
38
+
39
+ ```text
40
+ tapestry onboard Onboard the current directory and write
41
+ .claude/settings.json with the Tapestry plugins enabled
42
+ (the recommended quickstart path).
43
+ tapestry observatory Open the Observatory console in a browser.
44
+ tapestry init Register the project and write .env / .mcp.json /
45
+ .project-intelligence/ (granular path; does not touch
46
+ .claude/settings.json).
47
+ tapestry version Print version and platform info.
48
+ ```
49
+
50
+ ## Quickstart
51
+
52
+ ```bash
53
+ pipx install tapestry-cli
54
+ tapestry onboard
55
+ tapestry observatory
56
+ ```
57
+
58
+ The agents pick up memory, telemetry, and the discipline rules from there. See the [setup guide](https://tapestry-khaki.vercel.app/how-to/set-up-a-new-project/) for the full walkthrough.
59
+
60
+ ## Configuration
61
+
62
+ The CLI is URL-env-driven — no hardcoded hostnames. It reads:
63
+
64
+ - `LOOM_MEMORY_MCP_URL` / `LOOM_MEMORY_URL` — the memory MCP endpoint
65
+ - `LOOM_PROJECT_ID` — the project identifier
66
+ - `TAPESTRY_OBSERVATORY_URL` — override the Observatory URL opened by `tapestry observatory`
67
+
68
+ ## Notes
69
+
70
+ `tapestry` is the primary entry point; `loom` is kept as a deprecated alias during the transition. The Python package is named `tapestry_cli`.
71
+
72
+ ---
73
+
74
+ *Provenance: lifted verbatim from `the-loom/tapestry-cli/` during the Tapestry monorepo migration (Step 5a, 2026-06-21) — the cross-platform replacement for the old PowerShell `new-loom-project.ps1` scaffolder.*
@@ -0,0 +1,52 @@
1
+ # tapestry-cli
2
+
3
+ The command-line interface for [Tapestry](https://github.com/Lizo-RoadTown/tapestry) — project intelligence for AI-native teams.
4
+
5
+ Wire a project into the platform, then open the live console. Stdlib-only, no third-party dependencies, no model downloads — it works the moment `pip install` completes, on Windows, macOS, and Linux.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pipx install tapestry-cli
11
+ ```
12
+
13
+ (or `pip install tapestry-cli`)
14
+
15
+ ## Commands
16
+
17
+ ```text
18
+ tapestry onboard Onboard the current directory and write
19
+ .claude/settings.json with the Tapestry plugins enabled
20
+ (the recommended quickstart path).
21
+ tapestry observatory Open the Observatory console in a browser.
22
+ tapestry init Register the project and write .env / .mcp.json /
23
+ .project-intelligence/ (granular path; does not touch
24
+ .claude/settings.json).
25
+ tapestry version Print version and platform info.
26
+ ```
27
+
28
+ ## Quickstart
29
+
30
+ ```bash
31
+ pipx install tapestry-cli
32
+ tapestry onboard
33
+ tapestry observatory
34
+ ```
35
+
36
+ The agents pick up memory, telemetry, and the discipline rules from there. See the [setup guide](https://tapestry-khaki.vercel.app/how-to/set-up-a-new-project/) for the full walkthrough.
37
+
38
+ ## Configuration
39
+
40
+ The CLI is URL-env-driven — no hardcoded hostnames. It reads:
41
+
42
+ - `LOOM_MEMORY_MCP_URL` / `LOOM_MEMORY_URL` — the memory MCP endpoint
43
+ - `LOOM_PROJECT_ID` — the project identifier
44
+ - `TAPESTRY_OBSERVATORY_URL` — override the Observatory URL opened by `tapestry observatory`
45
+
46
+ ## Notes
47
+
48
+ `tapestry` is the primary entry point; `loom` is kept as a deprecated alias during the transition. The Python package is named `tapestry_cli`.
49
+
50
+ ---
51
+
52
+ *Provenance: lifted verbatim from `the-loom/tapestry-cli/` during the Tapestry monorepo migration (Step 5a, 2026-06-21) — the cross-platform replacement for the old PowerShell `new-loom-project.ps1` scaffolder.*
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tapestry-cli"
7
+ version = "0.1.0"
8
+ description = "Tapestry command-line interface — onboard projects, inspect state, open the Observatory."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Liz Osborn", email = "lizocontactinfo@gmail.com"},
14
+ ]
15
+ keywords = ["tapestry", "agents", "coordination", "observability", "cli"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ ]
27
+ # Stdlib-only at runtime by design. Cross-platform Python (Windows / macOS / Linux),
28
+ # no compiled deps, no model downloads — `tapestry init` works the moment pip install completes.
29
+ dependencies = []
30
+
31
+ [project.scripts]
32
+ tapestry = "tapestry_cli.cli:main"
33
+ loom = "tapestry_cli.cli:main" # deprecated alias
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/Lizo-RoadTown/tapestry"
37
+ Repository = "https://github.com/Lizo-RoadTown/tapestry"
38
+ Documentation = "https://tapestry-khaki.vercel.app/"
39
+
40
+ [tool.setuptools]
41
+ packages = ["tapestry_cli"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """tapestry-cli — command-line interface for the-loom.
2
+
3
+ Stdlib-only. Cross-platform. Subcommands:
4
+ loom init — onboard the current directory as a loom consuming project
5
+ loom version — print version + diagnostic info
6
+ """
7
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Allow `python -m tapestry_cli` invocation."""
2
+ import sys
3
+
4
+ from tapestry_cli.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ """Top-level CLI dispatcher for `tapestry`.
2
+
3
+ Subcommands:
4
+ tapestry onboard — `init` + writes .claude/settings.json with tapestry plugins
5
+ enabled (the 4-step quickstart path)
6
+ tapestry observatory — open the Observatory console in a browser
7
+ tapestry init — register the project, write .env / .mcp.json / .project-intelligence/
8
+ (the granular path — does NOT touch .claude/settings.json)
9
+ tapestry version — print version + diagnostic info
10
+
11
+ Add a new subcommand by:
12
+ 1. Creating tapestry_cli/<name>.py with add_arguments(parser) + run(args) -> int
13
+ 2. Importing + registering it in SUBCOMMANDS below
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+
20
+ from tapestry_cli import (
21
+ __version__,
22
+ init as init_cmd,
23
+ observatory as observatory_cmd,
24
+ onboard as onboard_cmd,
25
+ )
26
+
27
+
28
+ SUBCOMMANDS: dict[str, dict] = {
29
+ "onboard": {
30
+ "help": "Onboard the current directory + write .claude/settings.json (recommended).",
31
+ "add_arguments": onboard_cmd.add_arguments,
32
+ "run": onboard_cmd.run,
33
+ },
34
+ "observatory": {
35
+ "help": "Open the Observatory console in a browser.",
36
+ "add_arguments": observatory_cmd.add_arguments,
37
+ "run": observatory_cmd.run,
38
+ },
39
+ "init": {
40
+ "help": "Onboard the current directory as a Tapestry consuming project (no settings.json).",
41
+ "add_arguments": init_cmd.add_arguments,
42
+ "run": init_cmd.run,
43
+ },
44
+ }
45
+
46
+
47
+ def _print_version() -> int:
48
+ import platform
49
+ print(f"tapestry-cli {__version__}")
50
+ print(f" python: {platform.python_version()} ({platform.system()} {platform.release()})")
51
+ return 0
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = argparse.ArgumentParser(
56
+ prog="tapestry",
57
+ description="Tapestry command-line interface — onboard projects, inspect state, open the Observatory.",
58
+ )
59
+ subs = parser.add_subparsers(dest="command", metavar="<command>")
60
+ subs.add_parser("version", help="Print version and platform info.")
61
+ for name, spec in SUBCOMMANDS.items():
62
+ sub = subs.add_parser(name, help=spec["help"])
63
+ spec["add_arguments"](sub)
64
+
65
+ args = parser.parse_args(argv)
66
+
67
+ if args.command == "version":
68
+ return _print_version()
69
+ if args.command in SUBCOMMANDS:
70
+ return SUBCOMMANDS[args.command]["run"](args)
71
+
72
+ parser.print_help()
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ sys.exit(main())
@@ -0,0 +1,449 @@
1
+ """loom init — onboard the current directory as a loom consuming project.
2
+
3
+ The implementation behind `loom init` (and the legacy `scripts/loom_init.py`
4
+ shim). Stdlib-only so it runs equally from PowerShell / bash / zsh on
5
+ Windows / macOS / Linux without extra installs.
6
+
7
+ What it does (mirrors `docs/howto/onboard-a-project.md` Part 1, steps 2-5):
8
+
9
+ 1. Pre-check: confirm you're in a directory that looks like a project
10
+ (has .git, or has files, or you pass --force).
11
+ 2. Pre-check: confirm the slug isn't already registered for your
12
+ tenant (GET /projects/by-slug/<slug>). Idempotent on rerun.
13
+ 3. POST to https://loom-project-registry.onrender.com/projects to
14
+ register the project. Self-host mode: no Bearer token needed;
15
+ server falls back to SELF_HOST_TENANT_ID.
16
+ 4. Create .env in the current dir with OTel credentials copied from
17
+ the-loom's .env (so hook events flow to Grafana tagged with this
18
+ new project_id) + LOOM_PROJECT_ID=<slug>.
19
+ 5. Create .project-intelligence/ folder per the platform-data-model.
20
+ 6. Print confirmation + next-steps.
21
+
22
+ What it does NOT do:
23
+ - Does NOT create a GitHub repo (use `gh repo create` or the
24
+ PowerShell scaffolder for that).
25
+ - Does NOT install the tapestry-discipline Claude Code plugin.
26
+ - Does NOT install skills (Phase 5 SDK install-path future work).
27
+ - Does NOT touch .gitignore (warns if .env not gitignored).
28
+
29
+ Dual-mode:
30
+ - Self-host (default): no --token, server falls back to SELF_HOST_TENANT_ID.
31
+ - Hosted-multitenant: pass --token <jwt>; sent as Bearer to the Registry.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import os
38
+ import socket
39
+ import sys
40
+ import time
41
+ import urllib.error
42
+ import urllib.parse
43
+ import urllib.request
44
+ from pathlib import Path
45
+ from typing import Any, Optional
46
+
47
+
48
+ DEFAULT_REGISTRY_URL = "https://loom-project-registry.onrender.com"
49
+
50
+
51
+ def _read_loom_env(loom_repo: Path) -> dict[str, str]:
52
+ """Read the-loom's .env to pull OTel credentials for propagation."""
53
+ env_path = loom_repo / ".env"
54
+ out: dict[str, str] = {}
55
+ if not env_path.is_file():
56
+ return out
57
+ try:
58
+ for line in env_path.read_text(encoding="utf-8").splitlines():
59
+ line = line.strip()
60
+ if not line or line.startswith("#") or "=" not in line:
61
+ continue
62
+ key, _, value = line.partition("=")
63
+ key = key.strip()
64
+ value = value.strip()
65
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
66
+ value = value[1:-1]
67
+ if key:
68
+ out[key] = value
69
+ except OSError:
70
+ pass
71
+ return out
72
+
73
+
74
+ def _gitignore_has_env(project_dir: Path) -> bool:
75
+ gi = project_dir / ".gitignore"
76
+ if not gi.is_file():
77
+ return False
78
+ try:
79
+ for line in gi.read_text(encoding="utf-8").splitlines():
80
+ if line.strip() == ".env":
81
+ return True
82
+ except OSError:
83
+ pass
84
+ return False
85
+
86
+
87
+ def _warm_registry(registry_url: str, max_wait: float = 90.0) -> None:
88
+ """Render free-tier services cold-start. Wait for /health up to max_wait."""
89
+ url = registry_url.rstrip("/") + "/health"
90
+ deadline = time.time() + max_wait
91
+ attempt = 0
92
+ while time.time() < deadline:
93
+ attempt += 1
94
+ try:
95
+ with urllib.request.urlopen(
96
+ urllib.request.Request(url=url, method="GET"), timeout=15
97
+ ) as resp:
98
+ if resp.status == 200:
99
+ if attempt > 1:
100
+ print(f" Registry warmed up (attempt {attempt}).")
101
+ return
102
+ except (urllib.error.URLError, urllib.error.HTTPError,
103
+ TimeoutError, ConnectionError, OSError):
104
+ pass
105
+ if attempt == 1:
106
+ print(f" Registry cold-starting (Render free tier); waiting up to {int(max_wait)}s...")
107
+ time.sleep(5)
108
+ print(f" WARN: Registry /health didn't respond within {int(max_wait)}s; proceeding anyway.")
109
+
110
+
111
+ def _check_registry(
112
+ registry_url: str, slug: str, token: Optional[str], timeout: float = 60.0,
113
+ ) -> Optional[dict[str, Any]]:
114
+ """GET /projects/by-slug/<slug>. Returns row, None on 404, raises on other errors."""
115
+ url = registry_url.rstrip("/") + f"/projects/by-slug/{urllib.parse.quote(slug)}"
116
+ headers = {}
117
+ if token:
118
+ headers["Authorization"] = f"Bearer {token}"
119
+ req = urllib.request.Request(url=url, headers=headers, method="GET")
120
+ try:
121
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
122
+ if resp.status == 200:
123
+ return json.loads(resp.read().decode("utf-8"))
124
+ return None
125
+ except urllib.error.HTTPError as e:
126
+ if e.code == 404:
127
+ return None
128
+ body = e.read().decode("utf-8", errors="replace")[:300]
129
+ raise RuntimeError(f"Registry GET /by-slug/{slug} returned HTTP {e.code}: {body}")
130
+
131
+
132
+ def _register_project(
133
+ registry_url: str,
134
+ slug: str,
135
+ name: str,
136
+ description: str,
137
+ kind: str,
138
+ token: Optional[str],
139
+ timeout: float = 30.0,
140
+ ) -> dict[str, Any]:
141
+ """POST /projects. Returns the new row. Raises on non-201."""
142
+ body = json.dumps({
143
+ "slug": slug, "name": name, "description": description, "kind": kind,
144
+ }).encode("utf-8")
145
+ url = registry_url.rstrip("/") + "/projects"
146
+ headers = {"Content-Type": "application/json"}
147
+ if token:
148
+ headers["Authorization"] = f"Bearer {token}"
149
+ req = urllib.request.Request(url=url, data=body, headers=headers, method="POST")
150
+ try:
151
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
152
+ if resp.status != 201:
153
+ raise RuntimeError(f"Registry POST /projects returned HTTP {resp.status}")
154
+ return json.loads(resp.read().decode("utf-8"))
155
+ except urllib.error.HTTPError as e:
156
+ err_body = e.read().decode("utf-8", errors="replace")[:300]
157
+ raise RuntimeError(f"Registry POST /projects returned HTTP {e.code}: {err_body}")
158
+
159
+
160
+ def _write_env_file(project_dir: Path, slug: str, loom_env: dict[str, str]) -> None:
161
+ """Create .env with OTel propagation + LOOM_PROJECT_ID. Does NOT overwrite."""
162
+ env_path = project_dir / ".env"
163
+ if env_path.exists():
164
+ print(f" WARN: {env_path} already exists; not overwriting.")
165
+ print(f" Add LOOM_PROJECT_ID={slug} manually if missing.")
166
+ return
167
+
168
+ lines = [
169
+ "# .env for this consuming project of the-loom.",
170
+ "# Generated by `loom init` — review before using.",
171
+ "# Gitignore me. (See .gitignore — loom warns if not present.)",
172
+ "",
173
+ f"LOOM_PROJECT_ID={slug}",
174
+ "",
175
+ "# OTel credentials propagated from the-loom/.env so this project's",
176
+ "# hook events flow to the same Grafana Cloud stack, tagged with the",
177
+ "# project_id above.",
178
+ ]
179
+ for key in (
180
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
181
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
182
+ "OTEL_EXPORTER_OTLP_HEADERS",
183
+ "OTEL_RESOURCE_ATTRIBUTES",
184
+ "OTEL_SERVICE_NAME",
185
+ ):
186
+ value = loom_env.get(key)
187
+ if value:
188
+ lines.append(f"{key}={value}")
189
+ lines.append("")
190
+ env_path.write_text("\n".join(lines), encoding="utf-8")
191
+ print(f" wrote {env_path}")
192
+
193
+
194
+ # Constants for the loom-memory MCP server entry — this is the concrete rule
195
+ # every consuming project must satisfy. See skills_private/concrete-rule/SKILL.md
196
+ # and docs/CORE_DIRECTIVES.md. Format: Claude Code's .mcp.json schema.
197
+ #
198
+ # Env-override precedence for Tapestry migration (PR-prep-2b 2026-06-19):
199
+ # 1. TAPESTRY_MEMORY_MCP_URL: Tapestry-aware full URL; highest precedence
200
+ # 2. LOOM_MEMORY_MCP_URL: pre-Tapestry full URL
201
+ # 3. TAPESTRY_MEMORY_URL: Tapestry-aware bare base; /mcp/memory/ composed
202
+ # 4. LOOM_MEMORY_URL: pre-Tapestry bare base; /mcp/memory/ composed
203
+ # 5. Hardcoded default — the-loom's Render deployment
204
+ LOOM_MEMORY_MCP_URL = os.environ.get(
205
+ "TAPESTRY_MEMORY_MCP_URL",
206
+ os.environ.get(
207
+ "LOOM_MEMORY_MCP_URL",
208
+ f"{os.environ.get('TAPESTRY_MEMORY_URL', os.environ.get('LOOM_MEMORY_URL', 'https://loom-agent-context.onrender.com')).rstrip('/')}/mcp/memory/",
209
+ ),
210
+ )
211
+ LOOM_MEMORY_SERVER_NAME = "loom-memory"
212
+
213
+
214
+ def _write_mcp_config(project_dir: Path) -> None:
215
+ """Ensure `.mcp.json` exists in project_dir with loom-memory wired in.
216
+
217
+ This is **Layer 5** of the concrete-rule defense-in-depth pattern (see
218
+ skills_private/concrete-rule/SKILL.md). Every consuming project MUST
219
+ have loom-memory in its `.mcp.json` so Claude Code sessions can call
220
+ memory_recall / memory_write / memory_read tools regardless of which
221
+ plugins are enabled.
222
+
223
+ Idempotent: if `.mcp.json` exists, merges the loom-memory entry into
224
+ its mcpServers block (preserves all existing servers). If it doesn't
225
+ exist, creates a minimal one with just loom-memory.
226
+
227
+ The plugin's plugin.json (integrations/claude-code/tapestry-discipline/
228
+ .claude-plugin/plugin.json v0.1.8+) ALSO registers loom-memory at the
229
+ user level — Layer 3 of the concrete rule. Both layers exist so that
230
+ if the plugin is disabled or fails, the project-level config still
231
+ provides memory access.
232
+ """
233
+ mcp_path = project_dir / ".mcp.json"
234
+ loom_memory_entry = {
235
+ "type": "http",
236
+ "url": LOOM_MEMORY_MCP_URL,
237
+ }
238
+
239
+ if mcp_path.exists():
240
+ try:
241
+ existing = json.loads(mcp_path.read_text(encoding="utf-8"))
242
+ except (json.JSONDecodeError, OSError) as e:
243
+ print(f" WARN: {mcp_path} exists but couldn't be parsed ({e}).")
244
+ print(f" Skipping merge. Add loom-memory manually:")
245
+ print(f" {{\"loom-memory\": {{\"type\": \"http\", \"url\": \"{LOOM_MEMORY_MCP_URL}\"}}}}")
246
+ return
247
+
248
+ servers = existing.setdefault("mcpServers", {})
249
+ if LOOM_MEMORY_SERVER_NAME in servers:
250
+ existing_url = servers[LOOM_MEMORY_SERVER_NAME].get("url", "")
251
+ if existing_url == LOOM_MEMORY_MCP_URL:
252
+ print(f" loom-memory already wired in {mcp_path}")
253
+ return
254
+ print(f" WARN: {mcp_path} has loom-memory pointing at a different URL:")
255
+ print(f" existing: {existing_url}")
256
+ print(f" expected: {LOOM_MEMORY_MCP_URL}")
257
+ print(f" Leaving as-is. Review and reconcile if needed.")
258
+ return
259
+ servers[LOOM_MEMORY_SERVER_NAME] = loom_memory_entry
260
+ mcp_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
261
+ print(f" merged loom-memory into {mcp_path}")
262
+ return
263
+
264
+ # Create fresh
265
+ config = {
266
+ "mcpServers": {
267
+ LOOM_MEMORY_SERVER_NAME: loom_memory_entry,
268
+ }
269
+ }
270
+ mcp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
271
+ print(f" wrote {mcp_path}")
272
+
273
+
274
+ def _write_project_intelligence(
275
+ project_dir: Path,
276
+ slug: str,
277
+ project_uuid: str,
278
+ project_name: str,
279
+ ) -> None:
280
+ """Create .project-intelligence/ per the platform-data-model. Idempotent."""
281
+ pi = project_dir / ".project-intelligence"
282
+ pi.mkdir(exist_ok=True)
283
+
284
+ agent_profile_path = pi / "agent-profile.json"
285
+ if not agent_profile_path.exists():
286
+ agent_profile_path.write_text(json.dumps({
287
+ "configured_agents": [
288
+ {
289
+ "kind": "claude-code",
290
+ "version": "unknown",
291
+ "capabilities": ["hooks", "mcp", "skills", "plugins"],
292
+ }
293
+ ],
294
+ "generated_by": "tapestry_cli.init",
295
+ }, indent=2), encoding="utf-8")
296
+ print(f" wrote {agent_profile_path}")
297
+
298
+ project_context_path = pi / "project-context.json"
299
+ if not project_context_path.exists():
300
+ project_context_path.write_text(json.dumps({
301
+ "project_id": project_uuid,
302
+ "slug": slug,
303
+ "name": project_name,
304
+ "hostname": socket.gethostname(),
305
+ "registered_via": "tapestry_cli.init",
306
+ }, indent=2), encoding="utf-8")
307
+ print(f" wrote {project_context_path}")
308
+
309
+ observatory_config_path = pi / "observatory-config.json"
310
+ if not observatory_config_path.exists():
311
+ observatory_config_path.write_text(json.dumps({
312
+ "telemetry_destinations": [
313
+ {
314
+ "kind": "grafana-cloud-otlp",
315
+ "configured_via": "OTEL_EXPORTER_OTLP_* env vars in .env",
316
+ }
317
+ ],
318
+ "sampling_rules": "default-all",
319
+ }, indent=2), encoding="utf-8")
320
+ print(f" wrote {observatory_config_path}")
321
+
322
+ for sub in ("local-skills", "workflow-candidates", "lessons-learned", "promotion-candidates"):
323
+ d = pi / sub
324
+ d.mkdir(exist_ok=True)
325
+ readme = d / "README.md"
326
+ if not readme.exists():
327
+ readme.write_text(
328
+ f"# {sub}\n\nLocal {sub.replace('-', ' ')} for this project. "
329
+ f"Populated by the agency optimizer during use. See "
330
+ f"docs/proposals/2026-05-25-platform-data-model.md in the-loom "
331
+ f"for the directory contract.\n",
332
+ encoding="utf-8",
333
+ )
334
+
335
+ print(f" initialized {pi}/")
336
+
337
+
338
+ def add_arguments(p: argparse.ArgumentParser) -> None:
339
+ """Attach the init subcommand's args to a parser. Used by cli.py dispatch."""
340
+ p.add_argument("--slug", required=True,
341
+ help="Project slug (kebab-case, lowercase). Used as LOOM_PROJECT_ID.")
342
+ p.add_argument("--name", default=None,
343
+ help="Human-readable project name. Defaults to slug.")
344
+ p.add_argument("--description", default="",
345
+ help="One-sentence project description.")
346
+ p.add_argument("--kind", default="dev", choices=["dev", "archived", "paused"],
347
+ help="Project lifecycle state (default: dev).")
348
+ p.add_argument("--loom-repo", default=None,
349
+ help="Path to the-loom repo (defaults to LOOM_REPO env var, "
350
+ "then $HOME/the-loom or %%USERPROFILE%%/the-loom).")
351
+ p.add_argument("--registry-url", default=DEFAULT_REGISTRY_URL,
352
+ help=f"Project Registry base URL (default: {DEFAULT_REGISTRY_URL}).")
353
+ p.add_argument("--token", default=None,
354
+ help="Optional Bearer token (hosted-multitenant mode).")
355
+ p.add_argument("--force", action="store_true",
356
+ help="Skip the 'looks like a project' pre-check.")
357
+
358
+
359
+ def run(args: argparse.Namespace) -> int:
360
+ """Execute the init flow. Returns POSIX exit code."""
361
+ project_dir = Path.cwd()
362
+ name = args.name or args.slug
363
+
364
+ # Locate the-loom repo
365
+ loom_repo_str = args.loom_repo or os.environ.get("LOOM_REPO") or str(Path.home() / "the-loom")
366
+ loom_repo = Path(loom_repo_str).expanduser().resolve()
367
+ if not (loom_repo / "render.yaml").is_file():
368
+ print(f"error: --loom-repo path doesn't look like the-loom (no render.yaml at {loom_repo}).", file=sys.stderr)
369
+ print(f" Pass --loom-repo /path/to/the-loom explicitly, or set LOOM_REPO env var.", file=sys.stderr)
370
+ return 1
371
+
372
+ print(f"==> loom init")
373
+ print(f" project dir: {project_dir}")
374
+ print(f" slug: {args.slug}")
375
+ print(f" name: {name}")
376
+ print(f" description: {args.description or '(none)'}")
377
+ print(f" kind: {args.kind}")
378
+ print(f" loom repo: {loom_repo}")
379
+ print(f" registry: {args.registry_url}")
380
+ print(f" auth: {'Bearer token (hosted)' if args.token else 'self-host fallback (no token)'}")
381
+ print()
382
+
383
+ if not args.force:
384
+ has_git = (project_dir / ".git").exists()
385
+ has_files = any(project_dir.iterdir())
386
+ if not has_git and not has_files:
387
+ print(f"error: {project_dir} doesn't look like a project (no .git, no files).", file=sys.stderr)
388
+ print(f" Pass --force to override.", file=sys.stderr)
389
+ return 1
390
+
391
+ print(f"--> [1/4] Check Project Registry for existing slug '{args.slug}'...")
392
+ _warm_registry(args.registry_url)
393
+
394
+ try:
395
+ existing = _check_registry(args.registry_url, args.slug, args.token)
396
+ except RuntimeError as e:
397
+ print(f"error: registry check failed: {e}", file=sys.stderr)
398
+ return 1
399
+ if existing:
400
+ print(f" IDEMPOTENT: project '{args.slug}' already registered.")
401
+ print(f" UUID: {existing.get('id')}")
402
+ print(f" created_at: {existing.get('created_at')}")
403
+ print(f" Skipping Registry POST; proceeding with local file setup.")
404
+ project_uuid = existing.get("id")
405
+ else:
406
+ print(f"--> [2/4] Register '{args.slug}' with the Project Registry...")
407
+ try:
408
+ row = _register_project(
409
+ args.registry_url, args.slug, name, args.description, args.kind, args.token,
410
+ )
411
+ except RuntimeError as e:
412
+ print(f"error: registry registration failed: {e}", file=sys.stderr)
413
+ return 1
414
+ project_uuid = row.get("id")
415
+ print(f" registered. UUID: {project_uuid}")
416
+
417
+ loom_env = _read_loom_env(loom_repo)
418
+ if not loom_env.get("OTEL_EXPORTER_OTLP_HEADERS"):
419
+ print(f" WARN: {loom_repo}/.env didn't have OTEL_EXPORTER_OTLP_HEADERS;")
420
+ print(f" the generated .env won't have telemetry credentials. Fix manually.")
421
+
422
+ print(f"--> [3/5] Create .env in {project_dir}...")
423
+ _write_env_file(project_dir, args.slug, loom_env)
424
+
425
+ if not _gitignore_has_env(project_dir):
426
+ print(f" WARN: .gitignore does NOT contain '.env'. Add it to prevent")
427
+ print(f" committing OTel credentials.")
428
+
429
+ print(f"--> [4/5] Wire loom-memory MCP server (concrete-rule Layer 5)...")
430
+ _write_mcp_config(project_dir)
431
+
432
+ print(f"--> [5/5] Initialize .project-intelligence/...")
433
+ _write_project_intelligence(project_dir, args.slug, project_uuid or "unknown", name)
434
+
435
+ print()
436
+ print(f"==> Done.")
437
+ print(f" Project '{args.slug}' is now registered with the-loom.")
438
+ print(f" UUID: {project_uuid}")
439
+ print()
440
+ print(f"Next steps:")
441
+ print(f" 1. If you don't already have the tapestry-discipline plugin installed,")
442
+ print(f" see docs/howto/onboard-a-project.md Part 2.")
443
+ print(f" 2. Start a Claude Code session in this directory. The SessionStart")
444
+ print(f" hook (v0.1.7+) will auto-recall relevant memories for this project.")
445
+ print(f" 3. Hook events from this project will flow to Grafana tagged with")
446
+ print(f" project_id={args.slug}.")
447
+ print(f" 4. If .env doesn't exist or is missing OTel creds, copy from")
448
+ print(f" {loom_repo}/.env manually.")
449
+ return 0
@@ -0,0 +1,40 @@
1
+ """`tapestry observatory` — open the Observatory console.
2
+
3
+ The Observatory is the platform's coordination console (one deployed
4
+ instance). This opens it in a browser. Override the URL with
5
+ `--url` or the `TAPESTRY_OBSERVATORY_URL` environment variable.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import webbrowser
12
+
13
+ DEFAULT_URL = "https://tapestry-khaki.vercel.app/observatory"
14
+
15
+
16
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
17
+ parser.add_argument(
18
+ "--url",
19
+ default=os.environ.get("TAPESTRY_OBSERVATORY_URL", DEFAULT_URL),
20
+ help="Console URL (default: the deployed console; override with TAPESTRY_OBSERVATORY_URL).",
21
+ )
22
+ parser.add_argument(
23
+ "--print",
24
+ dest="print_only",
25
+ action="store_true",
26
+ help="Print the URL instead of opening a browser.",
27
+ )
28
+
29
+
30
+ def run(args: argparse.Namespace) -> int:
31
+ url = args.url
32
+ print(f"Observatory console: {url}")
33
+ if args.print_only:
34
+ return 0
35
+ try:
36
+ webbrowser.open(url)
37
+ print("Opened in your default browser.")
38
+ except Exception:
39
+ print("(Couldn't open a browser automatically — open the URL above.)")
40
+ return 0
@@ -0,0 +1,108 @@
1
+ """`loom onboard` — `init` + writes `.claude/settings.json` with the tapestry plugins enabled.
2
+
3
+ Wraps `loom init` so a single command does everything `loom init` does PLUS:
4
+
5
+ - writes `.claude/settings.json` with `tapestry-discipline@tapestry` and
6
+ `tapestry-patterns@tapestry` enabled (idempotent merge if the file
7
+ already exists)
8
+ - prints a 2-step epilogue covering the operator actions that have to
9
+ happen inside Claude Code (plugin install + IDE reload)
10
+
11
+ Argument surface is identical to `loom init` — see `init.add_arguments`.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ from pathlib import Path
18
+
19
+ from tapestry_cli import init as init_cmd
20
+
21
+
22
+ # The two plugins the consolidated `tapestry` marketplace ships.
23
+ # These names are the install identifiers (per
24
+ # tapestry/.claude-plugin/marketplace.json), distinct from the runtime
25
+ # emission strings the hook scripts still produce ([loom-discipline]) —
26
+ # see feedback_plugin_install_name_vs_runtime_emission_string_2026_06_22.
27
+ TAPESTRY_PLUGINS = (
28
+ "tapestry-discipline@tapestry",
29
+ "tapestry-patterns@tapestry",
30
+ )
31
+
32
+
33
+ def _write_claude_settings(project_dir: Path) -> None:
34
+ """Ensure `.claude/settings.json` has both tapestry plugins enabled.
35
+
36
+ Idempotent: merges into existing `enabledPlugins` if the file is
37
+ present; creates a minimal one if absent. Other settings (permissions,
38
+ etc.) are preserved.
39
+ """
40
+ settings_dir = project_dir / ".claude"
41
+ settings_dir.mkdir(exist_ok=True)
42
+ settings_path = settings_dir / "settings.json"
43
+
44
+ if settings_path.exists():
45
+ try:
46
+ existing = json.loads(settings_path.read_text(encoding="utf-8"))
47
+ except (json.JSONDecodeError, OSError) as e:
48
+ print(f" WARN: {settings_path} exists but couldn't be parsed ({e}).")
49
+ print(f" Skipping merge. Add the plugins manually:")
50
+ for plugin in TAPESTRY_PLUGINS:
51
+ print(f' "{plugin}": true')
52
+ return
53
+
54
+ enabled = existing.setdefault("enabledPlugins", {})
55
+ changed = False
56
+ for plugin in TAPESTRY_PLUGINS:
57
+ if enabled.get(plugin) is not True:
58
+ enabled[plugin] = True
59
+ changed = True
60
+ print(f" enabled {plugin} in {settings_path}")
61
+ if not changed:
62
+ print(f" both tapestry plugins already enabled in {settings_path}")
63
+ return
64
+ settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
65
+ return
66
+
67
+ # Create fresh
68
+ config = {"enabledPlugins": {plugin: True for plugin in TAPESTRY_PLUGINS}}
69
+ settings_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
70
+ print(f" wrote {settings_path}")
71
+
72
+
73
+ def _print_operator_epilogue() -> None:
74
+ """Print the 2 manual steps that can't be automated from CLI."""
75
+ print()
76
+ print("==> Two more steps (operator):")
77
+ print()
78
+ print(" 1. In Claude Code chat, register the tapestry marketplace + install the plugins:")
79
+ print()
80
+ print(" /plugin marketplace add Lizo-RoadTown/tapestry")
81
+ print(" /plugin install tapestry-discipline@tapestry")
82
+ print(" /plugin install tapestry-patterns@tapestry")
83
+ print()
84
+ print(" 2. Reload the IDE so the new hooks bind:")
85
+ print()
86
+ print(" VS Code: Cmd+Shift+P → 'Developer: Reload Window'")
87
+ print(" Claude Code CLI: exit + restart `claude` in this project")
88
+ print()
89
+ print("That's it. The SessionStart hook will auto-recall memories on next session start.")
90
+
91
+
92
+ def add_arguments(p: argparse.ArgumentParser) -> None:
93
+ """Same argument surface as `loom init` — delegated to it."""
94
+ init_cmd.add_arguments(p)
95
+
96
+
97
+ def run(args: argparse.Namespace) -> int:
98
+ """Run `loom init`, then write `.claude/settings.json`, then print operator steps."""
99
+ rc = init_cmd.run(args)
100
+ if rc != 0:
101
+ return rc
102
+
103
+ print()
104
+ print(f"--> [extra] Write .claude/settings.json with tapestry plugins enabled...")
105
+ _write_claude_settings(Path.cwd())
106
+
107
+ _print_operator_epilogue()
108
+ return 0
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: tapestry-cli
3
+ Version: 0.1.0
4
+ Summary: Tapestry command-line interface — onboard projects, inspect state, open the Observatory.
5
+ Author-email: Liz Osborn <lizocontactinfo@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Lizo-RoadTown/tapestry
8
+ Project-URL: Repository, https://github.com/Lizo-RoadTown/tapestry
9
+ Project-URL: Documentation, https://tapestry-khaki.vercel.app/
10
+ Keywords: tapestry,agents,coordination,observability,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # tapestry-cli
24
+
25
+ The command-line interface for [Tapestry](https://github.com/Lizo-RoadTown/tapestry) — project intelligence for AI-native teams.
26
+
27
+ Wire a project into the platform, then open the live console. Stdlib-only, no third-party dependencies, no model downloads — it works the moment `pip install` completes, on Windows, macOS, and Linux.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install tapestry-cli
33
+ ```
34
+
35
+ (or `pip install tapestry-cli`)
36
+
37
+ ## Commands
38
+
39
+ ```text
40
+ tapestry onboard Onboard the current directory and write
41
+ .claude/settings.json with the Tapestry plugins enabled
42
+ (the recommended quickstart path).
43
+ tapestry observatory Open the Observatory console in a browser.
44
+ tapestry init Register the project and write .env / .mcp.json /
45
+ .project-intelligence/ (granular path; does not touch
46
+ .claude/settings.json).
47
+ tapestry version Print version and platform info.
48
+ ```
49
+
50
+ ## Quickstart
51
+
52
+ ```bash
53
+ pipx install tapestry-cli
54
+ tapestry onboard
55
+ tapestry observatory
56
+ ```
57
+
58
+ The agents pick up memory, telemetry, and the discipline rules from there. See the [setup guide](https://tapestry-khaki.vercel.app/how-to/set-up-a-new-project/) for the full walkthrough.
59
+
60
+ ## Configuration
61
+
62
+ The CLI is URL-env-driven — no hardcoded hostnames. It reads:
63
+
64
+ - `LOOM_MEMORY_MCP_URL` / `LOOM_MEMORY_URL` — the memory MCP endpoint
65
+ - `LOOM_PROJECT_ID` — the project identifier
66
+ - `TAPESTRY_OBSERVATORY_URL` — override the Observatory URL opened by `tapestry observatory`
67
+
68
+ ## Notes
69
+
70
+ `tapestry` is the primary entry point; `loom` is kept as a deprecated alias during the transition. The Python package is named `tapestry_cli`.
71
+
72
+ ---
73
+
74
+ *Provenance: lifted verbatim from `the-loom/tapestry-cli/` during the Tapestry monorepo migration (Step 5a, 2026-06-21) — the cross-platform replacement for the old PowerShell `new-loom-project.ps1` scaffolder.*
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ tapestry_cli/__init__.py
4
+ tapestry_cli/__main__.py
5
+ tapestry_cli/cli.py
6
+ tapestry_cli/init.py
7
+ tapestry_cli/observatory.py
8
+ tapestry_cli/onboard.py
9
+ tapestry_cli.egg-info/PKG-INFO
10
+ tapestry_cli.egg-info/SOURCES.txt
11
+ tapestry_cli.egg-info/dependency_links.txt
12
+ tapestry_cli.egg-info/entry_points.txt
13
+ tapestry_cli.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ loom = tapestry_cli.cli:main
3
+ tapestry = tapestry_cli.cli:main
@@ -0,0 +1 @@
1
+ tapestry_cli