tapestry-cli 0.1.4__tar.gz → 0.1.5__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.4 → tapestry_cli-0.1.5}/PKG-INFO +1 -1
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/pyproject.toml +1 -1
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli/init.py +308 -95
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli.egg-info/PKG-INFO +1 -1
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli.egg-info/SOURCES.txt +2 -1
- tapestry_cli-0.1.5/tests/test_init_seed.py +130 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/README.md +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/setup.cfg +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli/__init__.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli/__main__.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli/cli.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli/make_plugin.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli/observatory.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli/onboard.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli.egg-info/dependency_links.txt +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/tapestry_cli.egg-info/entry_points.txt +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.5}/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.5"
|
|
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"
|
|
@@ -1,31 +1,32 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""tapestry init — onboard the current directory as a Tapestry consuming project.
|
|
2
2
|
|
|
3
|
-
The implementation behind `
|
|
4
|
-
|
|
5
|
-
Windows / macOS / Linux without extra installs.
|
|
3
|
+
The implementation behind `tapestry init` (the `loom` alias still dispatches
|
|
4
|
+
here). Stdlib-only so it runs equally from PowerShell / bash / zsh on
|
|
5
|
+
Windows / macOS / Linux without extra installs. No dependency on any other
|
|
6
|
+
repo (the old the-loom coupling was removed).
|
|
6
7
|
|
|
7
|
-
What it does
|
|
8
|
+
What it does:
|
|
8
9
|
|
|
9
10
|
1. Pre-check: confirm you're in a directory that looks like a project
|
|
10
11
|
(has .git, or has files, or you pass --force).
|
|
11
|
-
2.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
4.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
2. Register the project with the Project Registry (GET by-slug is
|
|
13
|
+
idempotent; POST /projects otherwise). Set --registry-url or
|
|
14
|
+
TAPESTRY_REGISTRY_URL. Self-host: no Bearer token; the server falls
|
|
15
|
+
back to SELF_HOST_TENANT_ID.
|
|
16
|
+
3. Create .env with LOOM_PROJECT_ID=<slug> (the master scope gate for the
|
|
17
|
+
tapestry-discipline hooks) + OTel vars sourced from the environment.
|
|
18
|
+
4. Wire the loom-memory MCP server into .mcp.json (with the auth header).
|
|
19
|
+
5. Initialize .project-intelligence/ (project-context.json carries the
|
|
20
|
+
registry UUID the observer needs).
|
|
21
|
+
6. Seed the project skeleton: CLAUDE.md (inlines CORE DIRECTIVE 1),
|
|
22
|
+
.gitignore, docs/ (architecture-snapshots/, architecture/, adr/), and
|
|
23
|
+
skills/. All writes are idempotent — nothing existing is overwritten.
|
|
22
24
|
|
|
23
25
|
What it does NOT do:
|
|
24
|
-
- Does NOT create a GitHub repo (use `gh repo create`
|
|
25
|
-
|
|
26
|
-
- Does NOT
|
|
27
|
-
|
|
28
|
-
- Does NOT touch .gitignore (warns if .env not gitignored).
|
|
26
|
+
- Does NOT create a GitHub repo (use `gh repo create`).
|
|
27
|
+
- Does NOT install the Claude Code plugins (see the printed next-steps).
|
|
28
|
+
- Does NOT seed architecture-snapshot scripts — the tapestry-discipline
|
|
29
|
+
hook runs the canonical tapestry-patterns scripts directly.
|
|
29
30
|
|
|
30
31
|
Dual-mode:
|
|
31
32
|
- Self-host (default): no --token, server falls back to SELF_HOST_TENANT_ID.
|
|
@@ -53,27 +54,17 @@ DEFAULT_REGISTRY_URL = os.environ.get(
|
|
|
53
54
|
)
|
|
54
55
|
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
key, _, value = line.partition("=")
|
|
68
|
-
key = key.strip()
|
|
69
|
-
value = value.strip()
|
|
70
|
-
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
71
|
-
value = value[1:-1]
|
|
72
|
-
if key:
|
|
73
|
-
out[key] = value
|
|
74
|
-
except OSError:
|
|
75
|
-
pass
|
|
76
|
-
return out
|
|
57
|
+
# OpenTelemetry vars propagated into a new project's .env. Sourced from the
|
|
58
|
+
# current process environment (never from a the-loom checkout — that dependency
|
|
59
|
+
# was removed). Only ENDPOINT + HEADERS actually gate telemetry export; the rest
|
|
60
|
+
# are metadata.
|
|
61
|
+
_OTEL_KEYS = (
|
|
62
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
63
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
64
|
+
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
65
|
+
"OTEL_RESOURCE_ATTRIBUTES",
|
|
66
|
+
"OTEL_SERVICE_NAME",
|
|
67
|
+
)
|
|
77
68
|
|
|
78
69
|
|
|
79
70
|
def _gitignore_has_env(project_dir: Path) -> bool:
|
|
@@ -162,8 +153,15 @@ def _register_project(
|
|
|
162
153
|
raise RuntimeError(f"Registry POST /projects returned HTTP {e.code}: {err_body}")
|
|
163
154
|
|
|
164
155
|
|
|
165
|
-
def _write_env_file(project_dir: Path, slug: str
|
|
166
|
-
"""Create .env with OTel propagation
|
|
156
|
+
def _write_env_file(project_dir: Path, slug: str) -> None:
|
|
157
|
+
"""Create .env with LOOM_PROJECT_ID + OTel propagation. Does NOT overwrite.
|
|
158
|
+
|
|
159
|
+
LOOM_PROJECT_ID is the master scope gate — the tapestry-discipline hooks
|
|
160
|
+
only fire for a project when it is set. OTel values are sourced from the
|
|
161
|
+
current process environment when present; otherwise commented placeholders
|
|
162
|
+
are written so the operator can fill them in. No dependency on a the-loom
|
|
163
|
+
checkout (removed).
|
|
164
|
+
"""
|
|
167
165
|
env_path = project_dir / ".env"
|
|
168
166
|
if env_path.exists():
|
|
169
167
|
print(f" WARN: {env_path} already exists; not overwriting.")
|
|
@@ -171,29 +169,30 @@ def _write_env_file(project_dir: Path, slug: str, loom_env: dict[str, str]) -> N
|
|
|
171
169
|
return
|
|
172
170
|
|
|
173
171
|
lines = [
|
|
174
|
-
"# .env for this consuming project
|
|
175
|
-
"# Generated by `
|
|
176
|
-
"# Gitignore me. (See .gitignore — loom warns if not present.)",
|
|
172
|
+
"# .env for this Tapestry consuming project.",
|
|
173
|
+
"# Generated by `tapestry init` — review before using. Gitignore me.",
|
|
177
174
|
"",
|
|
178
175
|
f"LOOM_PROJECT_ID={slug}",
|
|
179
176
|
"",
|
|
180
|
-
"#
|
|
181
|
-
"# hook events flow to the same
|
|
182
|
-
"# project_id above.",
|
|
177
|
+
"# OpenTelemetry export — sourced from your environment at init time so",
|
|
178
|
+
"# this project's hook events flow to the same stack, tagged with the",
|
|
179
|
+
"# project_id above. Unset vars are written as commented placeholders.",
|
|
183
180
|
]
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
188
|
-
"OTEL_RESOURCE_ATTRIBUTES",
|
|
189
|
-
"OTEL_SERVICE_NAME",
|
|
190
|
-
):
|
|
191
|
-
value = loom_env.get(key)
|
|
181
|
+
found_any = False
|
|
182
|
+
for key in _OTEL_KEYS:
|
|
183
|
+
value = os.environ.get(key)
|
|
192
184
|
if value:
|
|
193
185
|
lines.append(f"{key}={value}")
|
|
186
|
+
found_any = True
|
|
187
|
+
else:
|
|
188
|
+
lines.append(f"# {key}=")
|
|
194
189
|
lines.append("")
|
|
195
190
|
env_path.write_text("\n".join(lines), encoding="utf-8")
|
|
196
191
|
print(f" wrote {env_path}")
|
|
192
|
+
if not found_any:
|
|
193
|
+
print(" NOTE: no OTEL_* vars found in the environment; wrote commented")
|
|
194
|
+
print(" placeholders. Set them in .env (or export them) to enable")
|
|
195
|
+
print(" telemetry.")
|
|
197
196
|
|
|
198
197
|
|
|
199
198
|
# Constants for the loom-memory MCP server entry — this is the concrete rule
|
|
@@ -346,6 +345,224 @@ def _write_project_intelligence(
|
|
|
346
345
|
print(f" initialized {pi}/")
|
|
347
346
|
|
|
348
347
|
|
|
348
|
+
_CLAUDE_MD_TEMPLATE = """# Working in {name}
|
|
349
|
+
|
|
350
|
+
Project context for Claude Code. Loaded into every conversation. Keep it tight.
|
|
351
|
+
|
|
352
|
+
<!-- Seeded by `tapestry init`. Edit freely — a starting skeleton, not a contract. -->
|
|
353
|
+
|
|
354
|
+
## What this project is
|
|
355
|
+
|
|
356
|
+
{description}
|
|
357
|
+
|
|
358
|
+
<!-- One or two plain sentences: what this repo is for and what "done" looks
|
|
359
|
+
like. Describe what *is*, not what it *isn't*. -->
|
|
360
|
+
|
|
361
|
+
## Tapestry integration (seeded via `tapestry init`)
|
|
362
|
+
|
|
363
|
+
- **Project slug / `LOOM_PROJECT_ID`:** `{slug}` (in gitignored `.env`) — this is
|
|
364
|
+
the scope gate that turns the tapestry-discipline hooks on for this repo, and
|
|
365
|
+
it tags memory + telemetry to this project.
|
|
366
|
+
- **loom-memory MCP** is wired in `.mcp.json`.
|
|
367
|
+
- The `tapestry-discipline` + `tapestry-patterns` plugins apply here when enabled.
|
|
368
|
+
|
|
369
|
+
### CORE DIRECTIVE 1 — loom-memory access is mandatory
|
|
370
|
+
|
|
371
|
+
Every session MUST have the `loom-memory` MCP server reachable (`memory_read`,
|
|
372
|
+
`memory_write`, `memory_recall`, `memory_search`, `memory_list`, `memory_delete`).
|
|
373
|
+
If it is unavailable, or SessionStart reports a concrete-rule violation, **halt
|
|
374
|
+
substantive work and report to the operator** — do not proceed silently on
|
|
375
|
+
in-session context alone.
|
|
376
|
+
|
|
377
|
+
## How to work here
|
|
378
|
+
|
|
379
|
+
- **PROBE before asserting.** Read the actual file/source before claiming how
|
|
380
|
+
something works; cite `file:line`.
|
|
381
|
+
- **Recall memory** at the start of substantive work; **write memory** when the
|
|
382
|
+
operator teaches you something durable or corrects course.
|
|
383
|
+
- **Your own skills live in `skills/`** — see `skills/README.md`. Capture a
|
|
384
|
+
repeated workflow there instead of re-deriving it.
|
|
385
|
+
- **Decisions worth preserving** go in `docs/adr/`; architecture notes in
|
|
386
|
+
`docs/architecture/`.
|
|
387
|
+
|
|
388
|
+
## Tone — no marketing voice
|
|
389
|
+
|
|
390
|
+
Describe what *is*, not what it *isn't*. Plain, direct, descriptive. Applies to
|
|
391
|
+
docs, commit messages, PR bodies, and error messages.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
_GITIGNORE_LINES = [
|
|
395
|
+
"# Secrets / env — never commit. init writes .env with telemetry credentials.",
|
|
396
|
+
".env",
|
|
397
|
+
".env.local",
|
|
398
|
+
"*.env",
|
|
399
|
+
"!.env.example",
|
|
400
|
+
"",
|
|
401
|
+
"# Python",
|
|
402
|
+
"__pycache__/",
|
|
403
|
+
"*.py[cod]",
|
|
404
|
+
".pytest_cache/",
|
|
405
|
+
".venv/",
|
|
406
|
+
"",
|
|
407
|
+
"# OS / editor junk",
|
|
408
|
+
".DS_Store",
|
|
409
|
+
"Thumbs.db",
|
|
410
|
+
"desktop.ini",
|
|
411
|
+
"*~",
|
|
412
|
+
"*.swp",
|
|
413
|
+
"",
|
|
414
|
+
"# Node (if this project grows a JS toolchain)",
|
|
415
|
+
"node_modules/",
|
|
416
|
+
"",
|
|
417
|
+
"# Per-machine Claude Code state",
|
|
418
|
+
".claude/logs/",
|
|
419
|
+
".claude/settings.local.json",
|
|
420
|
+
"",
|
|
421
|
+
"# MCP config — references a secret env var by name; per-machine, not committed",
|
|
422
|
+
".mcp.json",
|
|
423
|
+
"",
|
|
424
|
+
"# Architecture snapshots — auto-written by the tapestry-discipline SessionStart",
|
|
425
|
+
"# hook every session. Local feedback, not repo history. .gitkeep preserves the dir.",
|
|
426
|
+
"docs/architecture-snapshots/*",
|
|
427
|
+
"!docs/architecture-snapshots/.gitkeep",
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
_DOCS_ARCHITECTURE_README = """# Architecture
|
|
431
|
+
|
|
432
|
+
Human-authored notes on how this project is put together — the shape of the
|
|
433
|
+
system, the main components, and how they fit. Start with a single overview file
|
|
434
|
+
and add focused notes as the design settles. Describe what *is*; keep it current.
|
|
435
|
+
|
|
436
|
+
Machine-generated architecture *snapshots* live in `../architecture-snapshots/`
|
|
437
|
+
(written by the tapestry-discipline hook each session) — those are feedback, not
|
|
438
|
+
authored docs. This directory is for the durable, human-authored picture.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
_DOCS_ADR_README = """# Decision records (ADRs)
|
|
442
|
+
|
|
443
|
+
One file per decision: `NNNN-short-slug.md`, sequential numbering.
|
|
444
|
+
|
|
445
|
+
## When to record a decision
|
|
446
|
+
|
|
447
|
+
- A choice that affects multiple parts of the project
|
|
448
|
+
- A choice that reverses an earlier one
|
|
449
|
+
- A naming or boundary decision worth preserving for a future agent reading this repo
|
|
450
|
+
|
|
451
|
+
## Template
|
|
452
|
+
|
|
453
|
+
# NNNN — <short-slug>
|
|
454
|
+
|
|
455
|
+
**Date:** YYYY-MM-DD
|
|
456
|
+
**Status:** Proposed / Accepted / Superseded by NNNN
|
|
457
|
+
**Decision by:** <who approved + when>
|
|
458
|
+
|
|
459
|
+
## Context
|
|
460
|
+
Why this decision is being made; the current situation and the pressure.
|
|
461
|
+
|
|
462
|
+
## Decision
|
|
463
|
+
The decision itself, plainly stated.
|
|
464
|
+
|
|
465
|
+
## Consequences
|
|
466
|
+
What this makes easier, what it makes harder, what it locks in or rules out.
|
|
467
|
+
|
|
468
|
+
## Related
|
|
469
|
+
Linked memory writes (loom-memory keys), PRs, prior decisions.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
_SKILLS_README = """# skills/
|
|
473
|
+
|
|
474
|
+
Your own skills, scoped to this project. A skill is a short, reusable procedure
|
|
475
|
+
you (or Claude) can invoke by name instead of re-deriving it each time.
|
|
476
|
+
|
|
477
|
+
## Layout
|
|
478
|
+
|
|
479
|
+
skills/
|
|
480
|
+
<skill-name>/
|
|
481
|
+
SKILL.md # frontmatter (name, description) + the procedure
|
|
482
|
+
|
|
483
|
+
One directory per skill. Keep each SKILL.md tight and action-oriented.
|
|
484
|
+
|
|
485
|
+
## Where skills live — three homes, don't confuse them
|
|
486
|
+
|
|
487
|
+
- **`skills/` (here)** — skills specific to *this* project, authored by hand.
|
|
488
|
+
- **`.project-intelligence/local-skills/`** — skill *candidates* the agency
|
|
489
|
+
optimizer emits automatically during use. Raw material, not curated.
|
|
490
|
+
- **`tapestry-patterns` plugin** — canonical patterns reused across *all*
|
|
491
|
+
projects, one name / one home. If a skill here proves useful everywhere,
|
|
492
|
+
promote it to the plugin rather than copying it into other repos.
|
|
493
|
+
|
|
494
|
+
## The skills-audit / upskilling loop
|
|
495
|
+
|
|
496
|
+
1. **Notice friction** — a workflow you repeat, or a correction made twice.
|
|
497
|
+
2. **Capture it** as a skill here, or as a candidate in `.project-intelligence/`.
|
|
498
|
+
3. **Audit at session end** — the upskilling report surfaces promotion candidates.
|
|
499
|
+
4. **Promote** the winners to the `tapestry-patterns` plugin; retire the ones
|
|
500
|
+
that stopped earning use.
|
|
501
|
+
|
|
502
|
+
This is how a project gets sharper over time instead of re-deriving the same
|
|
503
|
+
procedures every session.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _write_if_absent(path: Path, content: str) -> None:
|
|
508
|
+
"""Write content to path only if it doesn't already exist (idempotent)."""
|
|
509
|
+
if path.exists():
|
|
510
|
+
return
|
|
511
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
512
|
+
path.write_text(content, encoding="utf-8")
|
|
513
|
+
print(f" wrote {path}")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _write_project_skeleton(
|
|
517
|
+
project_dir: Path, slug: str, name: str, description: str
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Seed the full project skeleton: CLAUDE.md, .gitignore, docs/ tree, skills/.
|
|
520
|
+
|
|
521
|
+
Every write is idempotent (never clobbers an existing file). None of these
|
|
522
|
+
contain secrets. See docs/plans/2026-08-20-seed-and-hook-fix-plan.md.
|
|
523
|
+
"""
|
|
524
|
+
# CLAUDE.md — inlines CORE DIRECTIVE 1 (a fresh repo has no CORE_DIRECTIVES.md).
|
|
525
|
+
_write_if_absent(
|
|
526
|
+
project_dir / "CLAUDE.md",
|
|
527
|
+
_CLAUDE_MD_TEMPLATE.format(
|
|
528
|
+
slug=slug, name=name, description=description or "_(describe this project)_"
|
|
529
|
+
),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# .gitignore — create if absent, else append any missing lines (no dupes).
|
|
533
|
+
gi_path = project_dir / ".gitignore"
|
|
534
|
+
if not gi_path.exists():
|
|
535
|
+
gi_path.write_text("\n".join(_GITIGNORE_LINES) + "\n", encoding="utf-8")
|
|
536
|
+
print(f" wrote {gi_path}")
|
|
537
|
+
else:
|
|
538
|
+
existing = gi_path.read_text(encoding="utf-8")
|
|
539
|
+
existing_set = {ln.strip() for ln in existing.splitlines()}
|
|
540
|
+
to_add = [
|
|
541
|
+
ln for ln in _GITIGNORE_LINES
|
|
542
|
+
if ln and not ln.startswith("#") and ln not in existing_set
|
|
543
|
+
]
|
|
544
|
+
if to_add:
|
|
545
|
+
sep = "" if existing.endswith("\n") else "\n"
|
|
546
|
+
gi_path.write_text(
|
|
547
|
+
existing + sep + "\n# Added by `tapestry init`\n"
|
|
548
|
+
+ "\n".join(to_add) + "\n",
|
|
549
|
+
encoding="utf-8",
|
|
550
|
+
)
|
|
551
|
+
print(f" appended {len(to_add)} lines to {gi_path}")
|
|
552
|
+
|
|
553
|
+
# docs/ tree.
|
|
554
|
+
_write_if_absent(
|
|
555
|
+
project_dir / "docs" / "architecture-snapshots" / ".gitkeep", ""
|
|
556
|
+
)
|
|
557
|
+
_write_if_absent(
|
|
558
|
+
project_dir / "docs" / "architecture" / "README.md", _DOCS_ARCHITECTURE_README
|
|
559
|
+
)
|
|
560
|
+
_write_if_absent(project_dir / "docs" / "adr" / "README.md", _DOCS_ADR_README)
|
|
561
|
+
|
|
562
|
+
# skills/ home (new convention — a place for this project's own skills).
|
|
563
|
+
_write_if_absent(project_dir / "skills" / "README.md", _SKILLS_README)
|
|
564
|
+
|
|
565
|
+
|
|
349
566
|
def add_arguments(p: argparse.ArgumentParser) -> None:
|
|
350
567
|
"""Attach the init subcommand's args to a parser. Used by cli.py dispatch."""
|
|
351
568
|
p.add_argument("--slug", required=True,
|
|
@@ -356,9 +573,6 @@ def add_arguments(p: argparse.ArgumentParser) -> None:
|
|
|
356
573
|
help="One-sentence project description.")
|
|
357
574
|
p.add_argument("--kind", default="dev", choices=["dev", "archived", "paused"],
|
|
358
575
|
help="Project lifecycle state (default: dev).")
|
|
359
|
-
p.add_argument("--loom-repo", default=None,
|
|
360
|
-
help="Path to the-loom repo (defaults to LOOM_REPO env var, "
|
|
361
|
-
"then $HOME/the-loom or %%USERPROFILE%%/the-loom).")
|
|
362
576
|
p.add_argument("--registry-url", default=DEFAULT_REGISTRY_URL,
|
|
363
577
|
help=f"Project Registry base URL (default: {DEFAULT_REGISTRY_URL}).")
|
|
364
578
|
p.add_argument("--token", default=None,
|
|
@@ -372,21 +586,12 @@ def run(args: argparse.Namespace) -> int:
|
|
|
372
586
|
project_dir = Path.cwd()
|
|
373
587
|
name = args.name or args.slug
|
|
374
588
|
|
|
375
|
-
|
|
376
|
-
loom_repo_str = args.loom_repo or os.environ.get("LOOM_REPO") or str(Path.home() / "the-loom")
|
|
377
|
-
loom_repo = Path(loom_repo_str).expanduser().resolve()
|
|
378
|
-
if not (loom_repo / "render.yaml").is_file():
|
|
379
|
-
print(f"error: --loom-repo path doesn't look like the-loom (no render.yaml at {loom_repo}).", file=sys.stderr)
|
|
380
|
-
print(f" Pass --loom-repo /path/to/the-loom explicitly, or set LOOM_REPO env var.", file=sys.stderr)
|
|
381
|
-
return 1
|
|
382
|
-
|
|
383
|
-
print(f"==> loom init")
|
|
589
|
+
print(f"==> tapestry init")
|
|
384
590
|
print(f" project dir: {project_dir}")
|
|
385
591
|
print(f" slug: {args.slug}")
|
|
386
592
|
print(f" name: {name}")
|
|
387
593
|
print(f" description: {args.description or '(none)'}")
|
|
388
594
|
print(f" kind: {args.kind}")
|
|
389
|
-
print(f" loom repo: {loom_repo}")
|
|
390
595
|
print(f" registry: {args.registry_url}")
|
|
391
596
|
print(f" auth: {'Bearer token (hosted)' if args.token else 'self-host fallback (no token)'}")
|
|
392
597
|
print()
|
|
@@ -399,7 +604,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
399
604
|
print(f" Pass --force to override.", file=sys.stderr)
|
|
400
605
|
return 1
|
|
401
606
|
|
|
402
|
-
print(f"--> [1/
|
|
607
|
+
print(f"--> [1/6] Check Project Registry for existing slug '{args.slug}'...")
|
|
403
608
|
_warm_registry(args.registry_url)
|
|
404
609
|
|
|
405
610
|
try:
|
|
@@ -414,7 +619,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
414
619
|
print(f" Skipping Registry POST; proceeding with local file setup.")
|
|
415
620
|
project_uuid = existing.get("id")
|
|
416
621
|
else:
|
|
417
|
-
print(f"--> [2/
|
|
622
|
+
print(f"--> [2/6] Register '{args.slug}' with the Project Registry...")
|
|
418
623
|
try:
|
|
419
624
|
row = _register_project(
|
|
420
625
|
args.registry_url, args.slug, name, args.description, args.kind, args.token,
|
|
@@ -425,36 +630,44 @@ def run(args: argparse.Namespace) -> int:
|
|
|
425
630
|
project_uuid = row.get("id")
|
|
426
631
|
print(f" registered. UUID: {project_uuid}")
|
|
427
632
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
633
|
+
# Guard: the observer requires a real 36-char UUID in project-context.json.
|
|
634
|
+
# If registration didn't return one, refuse to seed an invalid project
|
|
635
|
+
# rather than write "unknown" (which the observer silently rejects).
|
|
636
|
+
if not project_uuid:
|
|
637
|
+
print("error: the Project Registry did not return a project UUID; cannot",
|
|
638
|
+
file=sys.stderr)
|
|
639
|
+
print(" seed a valid .project-intelligence/project-context.json.",
|
|
640
|
+
file=sys.stderr)
|
|
641
|
+
print(f" Check --registry-url ({args.registry_url}) and registry health,",
|
|
642
|
+
file=sys.stderr)
|
|
643
|
+
print(" then re-run. No local files were written.", file=sys.stderr)
|
|
644
|
+
return 1
|
|
435
645
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
print(f" committing OTel credentials.")
|
|
646
|
+
print(f"--> [3/6] Create .env in {project_dir} (LOOM_PROJECT_ID + OTel from env)...")
|
|
647
|
+
_write_env_file(project_dir, args.slug)
|
|
439
648
|
|
|
440
|
-
print(f"--> [4/
|
|
649
|
+
print(f"--> [4/6] Wire loom-memory MCP server (concrete-rule Layer 5)...")
|
|
441
650
|
_write_mcp_config(project_dir)
|
|
442
651
|
|
|
443
|
-
print(f"--> [5/
|
|
444
|
-
_write_project_intelligence(project_dir, args.slug, project_uuid
|
|
652
|
+
print(f"--> [5/6] Initialize .project-intelligence/...")
|
|
653
|
+
_write_project_intelligence(project_dir, args.slug, project_uuid, name)
|
|
654
|
+
|
|
655
|
+
print(f"--> [6/6] Seed project skeleton (CLAUDE.md, .gitignore, docs/, skills/)...")
|
|
656
|
+
_write_project_skeleton(project_dir, args.slug, name, args.description)
|
|
445
657
|
|
|
446
658
|
print()
|
|
447
659
|
print(f"==> Done.")
|
|
448
|
-
print(f" Project '{args.slug}' is
|
|
660
|
+
print(f" Project '{args.slug}' is registered and seeded.")
|
|
449
661
|
print(f" UUID: {project_uuid}")
|
|
450
662
|
print()
|
|
451
663
|
print(f"Next steps:")
|
|
452
|
-
print(f" 1. If you don't already have the tapestry
|
|
453
|
-
print(f"
|
|
454
|
-
print(f"
|
|
455
|
-
print(f"
|
|
456
|
-
print(f"
|
|
457
|
-
print(f"
|
|
458
|
-
print(f"
|
|
459
|
-
print(f"
|
|
664
|
+
print(f" 1. If you don't already have the tapestry plugins installed:")
|
|
665
|
+
print(f" /plugin marketplace add Lizo-RoadTown/tapestry")
|
|
666
|
+
print(f" /plugin install tapestry-discipline@tapestry")
|
|
667
|
+
print(f" /plugin install tapestry-patterns@tapestry")
|
|
668
|
+
print(f" 2. Start a Claude Code session here. The SessionStart hook auto-recalls")
|
|
669
|
+
print(f" relevant memories and writes architecture snapshots to")
|
|
670
|
+
print(f" docs/architecture-snapshots/.")
|
|
671
|
+
print(f" 3. To enable telemetry, set the OTEL_* vars in .env (or export them);")
|
|
672
|
+
print(f" hook events then flow tagged with project_id={args.slug}.")
|
|
460
673
|
return 0
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Tests for tapestry_cli.init seed helpers (0.1.5 full-skeleton redesign).
|
|
2
|
+
|
|
3
|
+
Run from packages/cli/: python -m pytest tests/ -q
|
|
4
|
+
These test the pure file-writing helpers directly; they do NOT hit the network
|
|
5
|
+
(the registry calls are not exercised here).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
15
|
+
from tapestry_cli import init # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# _write_env_file — LOOM_PROJECT_ID + OTel from env, no the-loom dependency
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_env_file_writes_project_id_and_sources_otel_from_env(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://otlp.example/v1")
|
|
25
|
+
monkeypatch.delenv("OTEL_EXPORTER_OTLP_HEADERS", raising=False)
|
|
26
|
+
init._write_env_file(tmp_path, "demo-proj")
|
|
27
|
+
env = (tmp_path / ".env").read_text(encoding="utf-8")
|
|
28
|
+
assert "LOOM_PROJECT_ID=demo-proj" in env
|
|
29
|
+
# set var is written as a real line
|
|
30
|
+
assert "OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.example/v1" in env
|
|
31
|
+
# unset var is written as a commented placeholder, never a bare/blank secret
|
|
32
|
+
assert "# OTEL_EXPORTER_OTLP_HEADERS=" in env
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_env_file_all_placeholders_when_no_otel_in_env(tmp_path, monkeypatch):
|
|
36
|
+
for k in init._OTEL_KEYS:
|
|
37
|
+
monkeypatch.delenv(k, raising=False)
|
|
38
|
+
init._write_env_file(tmp_path, "p")
|
|
39
|
+
env = (tmp_path / ".env").read_text(encoding="utf-8")
|
|
40
|
+
for k in init._OTEL_KEYS:
|
|
41
|
+
assert f"# {k}=" in env
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_env_file_does_not_overwrite(tmp_path):
|
|
45
|
+
(tmp_path / ".env").write_text("PRE=existing\n", encoding="utf-8")
|
|
46
|
+
init._write_env_file(tmp_path, "p")
|
|
47
|
+
assert (tmp_path / ".env").read_text(encoding="utf-8") == "PRE=existing\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_no_the_loom_reader_remains():
|
|
51
|
+
# the the-loom coupling was removed
|
|
52
|
+
assert not hasattr(init, "_read_loom_env")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# _write_project_skeleton — full skeleton, idempotent, no secrets
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_EXPECTED = [
|
|
60
|
+
"CLAUDE.md",
|
|
61
|
+
".gitignore",
|
|
62
|
+
"docs/architecture-snapshots/.gitkeep",
|
|
63
|
+
"docs/architecture/README.md",
|
|
64
|
+
"docs/adr/README.md",
|
|
65
|
+
"skills/README.md",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_skeleton_creates_all_expected_files(tmp_path):
|
|
70
|
+
init._write_project_skeleton(tmp_path, "demo-proj", "Demo Project", "A demo repo.")
|
|
71
|
+
for rel in _EXPECTED:
|
|
72
|
+
assert (tmp_path / rel).exists(), f"missing {rel}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_claude_md_is_parameterized_and_inlines_directive_1(tmp_path):
|
|
76
|
+
init._write_project_skeleton(tmp_path, "demo-proj", "Demo Project", "A demo repo.")
|
|
77
|
+
claude = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
|
78
|
+
assert "demo-proj" in claude
|
|
79
|
+
assert "Demo Project" in claude
|
|
80
|
+
assert "A demo repo." in claude
|
|
81
|
+
assert "CORE DIRECTIVE 1" in claude # inlined, not referenced
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_gitignore_has_env_and_snapshot_block(tmp_path):
|
|
85
|
+
init._write_project_skeleton(tmp_path, "p", "P", "")
|
|
86
|
+
gi = (tmp_path / ".gitignore").read_text(encoding="utf-8")
|
|
87
|
+
assert "\n.env\n" in "\n" + gi + "\n"
|
|
88
|
+
assert "docs/architecture-snapshots/*" in gi
|
|
89
|
+
assert "!docs/architecture-snapshots/.gitkeep" in gi
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_gitignore_appends_missing_lines_without_clobber(tmp_path):
|
|
93
|
+
(tmp_path / ".gitignore").write_text("customrule/\n", encoding="utf-8")
|
|
94
|
+
init._write_project_skeleton(tmp_path, "p", "P", "")
|
|
95
|
+
gi = (tmp_path / ".gitignore").read_text(encoding="utf-8")
|
|
96
|
+
assert "customrule/" in gi # preserved
|
|
97
|
+
assert ".env" in gi # appended
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_skeleton_is_idempotent(tmp_path):
|
|
101
|
+
init._write_project_skeleton(tmp_path, "p", "P", "desc")
|
|
102
|
+
claude_before = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
|
103
|
+
# hand-edit CLAUDE.md, then re-run — must NOT overwrite
|
|
104
|
+
(tmp_path / "CLAUDE.md").write_text("EDITED BY HAND\n", encoding="utf-8")
|
|
105
|
+
init._write_project_skeleton(tmp_path, "p", "P", "desc")
|
|
106
|
+
assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "EDITED BY HAND\n"
|
|
107
|
+
# and gitignore doesn't duplicate lines on re-run
|
|
108
|
+
gi = (tmp_path / ".gitignore").read_text(encoding="utf-8")
|
|
109
|
+
assert gi.count("docs/architecture-snapshots/*") == 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_skills_readme_distinguishes_three_homes(tmp_path):
|
|
113
|
+
init._write_project_skeleton(tmp_path, "p", "P", "")
|
|
114
|
+
readme = (tmp_path / "skills" / "README.md").read_text(encoding="utf-8")
|
|
115
|
+
assert ".project-intelligence/local-skills/" in readme
|
|
116
|
+
assert "tapestry-patterns" in readme
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_no_secret_literal_in_seeded_files(tmp_path):
|
|
120
|
+
init._write_project_skeleton(tmp_path, "p", "P", "")
|
|
121
|
+
# the .mcp.json header references the env var by NAME; the skeleton itself
|
|
122
|
+
# must never embed a literal key. Sanity: no obvious secret prefixes.
|
|
123
|
+
for rel in _EXPECTED:
|
|
124
|
+
text = (tmp_path / rel).read_text(encoding="utf-8")
|
|
125
|
+
assert "rnd_" not in text
|
|
126
|
+
assert "Bearer eyJ" not in text
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(pytest.main([__file__, "-q"]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|